Compare commits

...
5 Commits
Author SHA1 Message Date
niteshandClaude Sonnet 5.5 ab8ed7e67e feat(vouchers): voucher correction screens and a correction status on the voucher registers
A cash deposit posted to the wrong account is moved to the right one in four steps: unpass the voucher, a manager
approves, the cashier enters the correct account, a manager approves again. Nothing changes on any account until
the final approval.

- Voucher Correction page with four-step strip and popups: today's vouchers to unpass (admin or clerk), the
  unpassed vouchers waiting for their correct account (cashier) with the wrong and correct account side by side
  and the balance check, and the caller's own requests. A cashier whose customer has used the money closes the
  request and deposits separately.
- Reached from the cashier bar, the clerk bar and the Cashier menu; the manager reviews both steps under
  Approvals > Voucher Corrections, laid out like the other approval tabs. Routes for Cash Transfers approvals are
  added to the same tab bar.
- A Correction column on My Vouchers, the day book and the voucher register (and in the voucher detail popup)
  says whether a voucher is unpassed, revoked in favour of another, a correction, or closed, with the reason.
- The deposit taken off the wrong account no longer shows on its passbook or transaction list.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 12:28:27 +05:30
niteshandClaude Sonnet 5.5 7ee230e58a feat(cashier): maker-checker on cash transfer, real branches, compact Approval Center
- Cash Transfer now raises a request ("Send for approval") instead of posting. A My Requests button in the
  page header opens the caller's requests with their status, and a Cash Transfers approvals page (review
  popup with the full denomination breakdown, approve, reject with a reason) lets a different manager release
  the cash.
- Transaction Entry's Branch dropdown lists the installation's real offices instead of a fixed list. A login
  that cannot read offices is offered only its own branch (read-only), so no "Unauthorized Request" popup.
- Approval Center header and tab bar made compact so more tabs fit on a 100% window.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 12:27:39 +05:30
niteshandClaude Sonnet 5.5 7f3c97a205 feat(cashier): GL withdrawal slips, GL account panel with parent GL, and Payment lock on token withdrawals
Transaction Entry:
- GL withdrawal slip: a GL Withdrawal Slip is always a debit. The clerk raises it as a
  request (GL, cash vault GL, office and currency ride on the payload), the manager
  approves it and the cashier pays it against its token; nothing posts directly from this
  screen any more (submitGlJournalEntry removed). Cashiers without an approved token are
  told to enter one. GL credit-only rules moved into isGlCreditOnly(), and extra rows are
  not allowed while a Withdrawal Slip is selected.
- A/C NO. now starts the automatic account search after six characters.
- Clerk: with AC. TP. = GL show a "GL Account Information" panel (code, name, type, usage,
  normal balance, parent GL, manual entries, status, description) instead of the empty
  client panels; the parent GL name is looked up from /glaccounts/{parentId} for GL only.
- Cashier: once a token (withdrawal / closure request) is verified, TYPE is disabled and
  fixed to Payment, and onTypChange refuses to change it.

Requires the backend change "GL withdrawal slips through request, approval and cashier payment".

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 13:51:38 +05:30
niteshandClaude Sonnet 5.5 4e3b49a96a feat(ui): remove Undo / Revert transaction actions
Removes the Undo button and Undo menu item from savings, current, fixed deposit, recurring deposit
and loan transactions, the account-transfer Undo, and the Revert Transaction button on the
journal entry transaction detail, to prevent accidental reversal of posted entries.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 18:41:39 +05:30
niteshandClaude Sonnet 5.5 915deddb74 feat(cashier): cashier-only cash operations, exchange PAN/denomination checks and Branch Cash Position
- Cash Exchange Entry: customer details with PAN (mandatory above Rs 50,000), cashier card,
  denomination-wise validation against the master and real-time impact preview.
- Inter-Cashier Transfer: sender fixed to the logged-in cashier, both sides' details and
  denomination holdings shown, blocked until both are allocated cash.
- Reconciliation, exchange and inter-cashier operate on the logged-in cashier only.
- New Branch Cash Position screen (vault + cashier drawers, denomination-wise).
- Customer search dialog shows the KYC PAN.
- Cashier menu cleanup: Excess/Closing report and Exchange Detail removed, nav compacted to one row.
- Reverse actions removed from cash transfer, settlement, exchange and inter-cashier.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 18:41:24 +05:30
59 changed files with 6807 additions and 996 deletions
@@ -7,16 +7,6 @@
-->
<div class="container">
<div class="container m-b-20 align-end gap-2px">
@if (!viewAccountTransferData.reversed) {
<span>
<button mat-raised-button color="warn" *mifosxHasPermission="'ADJUST_ACCOUNTTRANSFER'">
<fa-icon icon="undo" class="m-r-10"></fa-icon>{{ 'labels.buttons.Undo' | translate }}
</button>
</span>
}
</div>
<mat-card>
<mat-card-content>
<div class="transfer-container">
@@ -0,0 +1,114 @@
<!--
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/.
-->
<div class="pg">
<header class="pg-head">
<div>
<div class="pg-eyebrow">BRANCH OPERATIONS · BRANCH CASH POSITION</div>
<h1 class="pg-title">Branch Cash Position</h1>
<p class="pg-sub">
The notes and coins the branch holds, denomination by denomination: the vault plus every cashier's drawer. Cash
moving between the vault and a cashier nets out; deposits, withdrawals and exchanges change it.
</p>
</div>
</header>
<div class="pg-toolbar pg-toolbar--mat">
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>As of Date</mat-label>
<input matInput type="date" [value]="asOfDate" (change)="onDateChange($any($event.target).value)" />
</mat-form-field>
<button type="button" class="pg-btn pg-btn--primary" (click)="load()" [disabled]="loading">
<mat-icon>refresh</mat-icon> Refresh
</button>
</div>
@if (errorMsg) {
<div class="pg-empty bcp-error">{{ errorMsg }}</div>
}
@if (loading) {
<div class="pg-empty">Loading…</div>
}
@if (position; as p) {
<div class="bcp-totals">
<div class="bcp-total">
<span>Branch</span><strong>{{ p.officeName }}</strong>
</div>
<div class="bcp-total">
<span>Vault</span><strong>₹{{ p.vaultAmount | number: '1.2-2' }}</strong>
</div>
<div class="bcp-total">
<span>Cashiers' drawers</span><strong>₹{{ p.cashiersAmount | number: '1.2-2' }}</strong>
</div>
<div class="bcp-total bcp-total--main">
<span>Bank total</span><strong>₹{{ p.totalAmount | number: '1.2-2' }}</strong>
</div>
</div>
@if (!p.vaultHasBreakdown) {
<div class="pg-empty bcp-note">
No vault denomination breakdown was recorded on or before {{ p.asOfDate }}, so the vault column reads 0. Open
the business day with a note/coin count to start tracking it.
</div>
}
<ng-template #table let-rows let-label="label" let-coin="coin">
@if (rows.length > 0) {
<div class="pos-section-label" [class.pos-section-label--coin]="coin">{{ label }}</div>
<table class="pg-table">
<thead>
<tr>
<th>Denomination</th>
<th class="num">Vault</th>
<th class="num">Cashiers</th>
<th class="num">Bank total (count)</th>
<th class="num">Bank total (₹)</th>
<th class="num">Exchange today</th>
</tr>
</thead>
<tbody>
@for (d of rows; track d.denominationId) {
<tr>
<td>
<span class="pg-chip" [class.pg-chip--coin]="coin">₹{{ d.denominationValue }}</span>
</td>
<td class="num">{{ d.vaultCount }}</td>
<td class="num">{{ d.cashierCount }}</td>
<td class="num bcp-strong">{{ d.totalCount }}</td>
<td class="num">₹{{ d.totalAmount | number: '1.2-2' }}</td>
<td
class="num bcp-move"
[class.bcp-move--in]="d.exchangeNet > 0"
[class.bcp-move--out]="d.exchangeNet < 0"
>
{{ movement(d.exchangeNet) }}
</td>
</tr>
}
</tbody>
</table>
}
</ng-template>
<div class="pg-card bcp-card">
<ng-container
*ngTemplateOutlet="table; context: { $implicit: notes(), label: 'Notes', coin: false }"
></ng-container>
<ng-container
*ngTemplateOutlet="table; context: { $implicit: coins(), label: 'Coins', coin: true }"
></ng-container>
</div>
<p class="bcp-foot">
<strong>Exchange today</strong> is the notes taken in less the notes given out by cash exchanges on
{{ p.asOfDate }}. It is already part of the Cashiers column; it is shown beside the total so a change in the mix
can be seen.
</p>
}
</div>
@@ -0,0 +1,82 @@
/**
* 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/.
*/
.bcp-error {
color: #dc2626;
}
.bcp-note {
margin-bottom: 12px;
text-align: left;
}
.bcp-totals {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 14px;
}
.bcp-total {
flex: 1 1 180px;
padding: 12px 14px;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 12px;
span {
display: block;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: #64748b;
}
strong {
font-size: 18px;
font-weight: 700;
color: #0f172a;
}
&--main {
background: #e6f4ee;
border-color: #1f7a4d;
strong {
color: #0f4f3c;
}
}
}
.bcp-card {
margin-bottom: 14px;
}
.bcp-strong {
font-weight: 700;
}
.bcp-move {
font-weight: 700;
color: #94a3b8;
&--in {
color: #16a34a;
}
&--out {
color: #dc2626;
}
}
.bcp-foot {
margin: 0;
font-size: 12px;
color: #64748b;
}
@@ -0,0 +1,112 @@
/**
* 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/.
*/
import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { MatIconModule } from '@angular/material/icon';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { CashierService } from '../cashier.service';
import { businessToday } from 'app/core/utils/business-today';
interface BranchDenomRow {
denominationId: number;
denominationValue: number;
kind: string;
vaultCount: number;
cashierCount: number;
totalCount: number;
totalAmount: number;
/** Notes taken in less notes given out by exchanges on the day. */
exchangeNet: number;
}
interface BranchPosition {
officeId: number;
officeName: string;
asOfDate: string;
vaultHasBreakdown: boolean;
vaultAmount: number;
cashiersAmount: number;
totalAmount: number;
denominations: BranchDenomRow[];
}
/**
* What the branch holds, denomination by denomination: the vault plus every cashier's drawer. An exchange
* (a customer hands in 10 x ₹500 and leaves with 50 x ₹100) leaves the vault alone and changes a drawer, so
* it only shows up in this total -- +10 on ₹500, -50 on ₹100.
*/
@Component({
selector: 'mifosx-branch-cash-position',
templateUrl: './branch-cash-position.component.html',
styleUrls: [
'../cashier-shared.scss',
'../cashier-position/cashier-position.component.scss',
'./branch-cash-position.component.scss'
],
standalone: true,
imports: [
CommonModule,
MatIconModule,
MatFormFieldModule,
MatInputModule
]
})
export class BranchCashPositionComponent implements OnInit {
private service = inject(CashierService);
asOfDate = '';
loading = false;
errorMsg = '';
position: BranchPosition | null = null;
ngOnInit(): void {
const d = businessToday();
this.asOfDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
this.load();
}
load(): void {
this.loading = true;
this.errorMsg = '';
this.position = null;
this.service
.branchCashPosition(this.asOfDate)
.pipe(catchError(() => of(null)))
.subscribe((data: BranchPosition | null) => {
this.loading = false;
if (!data) {
this.errorMsg = 'Could not load the branch cash position.';
return;
}
this.position = data;
});
}
onDateChange(value: string): void {
this.asOfDate = value;
this.load();
}
notes(): BranchDenomRow[] {
return (this.position?.denominations || []).filter((d) => d.kind !== 'coin');
}
coins(): BranchDenomRow[] {
return (this.position?.denominations || []).filter((d) => d.kind === 'coin');
}
/** "+10", "-50", or a dash when the day's exchanges did not touch the denomination. */
movement(net: number): string {
if (!net) return '—';
return net > 0 ? `+${net}` : `${net}`;
}
}
@@ -22,50 +22,255 @@
</div>
</header>
<!-- ── Customer Info Bar ── -->
<div class="cx-customer-bar">
<div
class="cx-customer-toggle"
[class.cx-customer-toggle--active]="isAccountHolder"
(click)="toggleAccountHolder()"
>
<mat-icon>{{ isAccountHolder ? 'check_box' : 'check_box_outline_blank' }}</mat-icon>
<span>Account Holder</span>
</div>
@if (isAccountHolder) {
<label class="cx-field cx-field--inline">
<span>Account No.</span>
<input
type="text"
[value]="accountNo"
(input)="accountNo = $any($event.target).value"
placeholder="e.g. 000101234567"
/>
</label>
} @else {
<span class="cx-walk-in"><mat-icon>person_outline</mat-icon> Walk-in Customer</span>
}
@if (needsIdProof) {
<div class="cx-pan-required">
<mat-icon>warning</mat-icon>
<span>Amount ≥ ₹50,000 — Customer PAN / Aadhaar required (RBI)</span>
<!-- ── Cashier: whose drawer takes the customer's notes and gives the change ── -->
@if (cashier) {
<section class="cx-cashier">
<div class="cx-avatar cx-avatar--sm">{{ cashierInitials }}</div>
<div class="cx-cashier__id">
<div class="cx-cashier__label">Cashier at the counter</div>
<div class="cx-cashier__name">{{ cashier.name || '—' }}</div>
<div class="cx-cashier__flow">
<span><mat-icon>south</mat-icon> Receives from customer</span>
<span><mat-icon>north</mat-icon> Gives to customer</span>
</div>
</div>
<label class="cx-field cx-field--inline cx-field--pan">
<span>PAN / Aadhaar *</span>
<input
type="text"
[value]="customerIdProof"
(input)="customerIdProof = $any($event.target).value"
placeholder="ABCDE1234F"
maxlength="20"
/>
</label>
}
</div>
<div class="cx-cashier__stats">
<div>
<span>Counter</span><strong>{{ cashier.tellerName || '—' }}</strong>
</div>
<div>
<span>Branch</span><strong>{{ cashier.officeName || '—' }}</strong>
</div>
<div>
<span>Allocated today</span
><strong>{{
cashier.allocatedAmount === null ? '—' : '₹' + (cashier.allocatedAmount | number: '1.2-2')
}}</strong>
</div>
<div>
<span>Cash in hand</span><strong>₹{{ cashier.totalAmount | number: '1.2-2' }}</strong>
</div>
</div>
</section>
} @else if (noCashier) {
<section class="cx-cashier cx-cashier--none">
<mat-icon>person_off</mat-icon>
<div>
<div class="cx-cashier__name">Your login is not linked to a cashier</div>
<div class="cx-cashier__label">
A cash exchange moves notes in and out of a cashier's drawer, so it can only be recorded by a cashier. Log in
as a cashier to continue.
</div>
</div>
</section>
}
<!-- ── Customer: an existing client (details from their record) or a walk-in identified at the counter ── -->
<section class="cx-cust">
<header class="cx-cust__head">
<div class="cx-cust__title">
<span class="cx-cust__title-icon"><mat-icon>groups</mat-icon></span>
<div>
<div class="cx-cust__title-text">Customer</div>
<div class="cx-cust__title-sub">Who is exchanging cash at the counter</div>
</div>
</div>
<div class="cx-cust__modes" role="tablist" aria-label="Customer type">
<button
type="button"
role="tab"
class="cx-cust__mode"
[class.cx-cust__mode--active]="customerMode === 'existing'"
[attr.aria-selected]="customerMode === 'existing'"
(click)="setCustomerMode('existing')"
>
<mat-icon>badge</mat-icon> Existing Customer
</button>
<button
type="button"
role="tab"
class="cx-cust__mode"
[class.cx-cust__mode--active]="customerMode === 'walkin'"
[attr.aria-selected]="customerMode === 'walkin'"
(click)="setCustomerMode('walkin')"
>
<mat-icon>person_outline</mat-icon> Walk-in Customer
</button>
</div>
</header>
<div class="cx-cust__body">
<!-- Who -->
<div class="cx-cust__who">
@if (customerMode === 'existing') {
@if (loadingCustomer) {
<div class="cx-cust__empty">
<mat-icon class="cx-cust__empty-icon cx-spin">autorenew</mat-icon>
<div class="cx-cust__empty-title">Loading customer details…</div>
</div>
} @else if (!selectedCustomer) {
<div class="cx-cust__empty">
<mat-icon class="cx-cust__empty-icon">person_search</mat-icon>
<div class="cx-cust__empty-title">No customer selected</div>
<div class="cx-cust__empty-sub">Search by name, account no., PAN or Aadhaar</div>
<button type="button" class="cx-btn cx-btn--primary" (click)="openCustomerSearch()">
<mat-icon>search</mat-icon> Search Customer
</button>
</div>
} @else {
<div class="cx-cust__card">
<div class="cx-cust__card-top">
<div class="cx-avatar">{{ customerInitials }}</div>
<div class="cx-cust__card-id">
<div class="cx-cust__name">{{ selectedCustomer.displayName }}</div>
<div class="cx-cust__badges">
<span
class="cx-kyc"
[class.cx-kyc--ok]="selectedCustomer.kycStatus === 'Verified'"
[class.cx-kyc--warn]="selectedCustomer.kycStatus !== 'Verified'"
>
<mat-icon>{{
selectedCustomer.kycStatus === 'Verified' ? 'verified_user' : 'gpp_maybe'
}}</mat-icon>
KYC: {{ selectedCustomer.kycStatus }}
</span>
@if (selectedCustomer.accountType) {
<span class="cx-chip-tag">{{ selectedCustomer.accountType }}</span>
}
</div>
</div>
<div class="cx-cust__card-actions">
<button type="button" class="cx-link-btn" (click)="openCustomerSearch()">
<mat-icon>swap_horiz</mat-icon> Change
</button>
<button type="button" class="cx-link-btn cx-link-btn--danger" (click)="clearCustomer()">
<mat-icon>close</mat-icon> Remove
</button>
</div>
</div>
<div class="cx-cust__grid">
<div>
<span>CIF No.</span><strong>{{ selectedCustomer.cifNumber || '—' }}</strong>
</div>
<div>
<span>Client ID</span><strong>{{ selectedCustomer.clientId }}</strong>
</div>
<div>
<span>Mobile</span><strong>{{ selectedCustomer.mobileNo || '—' }}</strong>
</div>
<div>
<span>Branch</span><strong>{{ selectedCustomer.officeName || '—' }}</strong>
</div>
<div>
<span>Account</span><strong>{{ selectedCustomer.accountNo || 'No account' }}</strong>
</div>
<div>
<span>PAN on record</span>
<strong>
{{ selectedCustomer.kycPan || 'Not on record' }}
@if (selectedCustomer.kycPan) {
<span
class="cx-kyc cx-kyc--sm"
[class.cx-kyc--ok]="selectedCustomer.panVerified"
[class.cx-kyc--warn]="!selectedCustomer.panVerified"
>{{ selectedCustomer.panVerified ? 'Verified' : 'Not verified' }}</span
>
}
</strong>
</div>
</div>
</div>
}
} @else {
<div class="cx-cust__walkin">
<label class="cx-field">
<span>Customer Name{{ needsPan ? ' *' : '' }}</span>
<input
type="text"
[value]="walkInName"
(input)="walkInName = $any($event.target).value"
placeholder="Full name as on ID"
maxlength="150"
autocomplete="off"
/>
</label>
<label class="cx-field">
<span>Mobile</span>
<input
type="tel"
inputmode="numeric"
[value]="walkInMobile"
(input)="walkInMobile = $any($event.target).value"
placeholder="10-digit mobile number"
maxlength="15"
autocomplete="off"
/>
</label>
</div>
}
</div>
<!-- PAN -->
<div
class="cx-pan"
[class.cx-pan--required]="panMissing"
[class.cx-pan--invalid]="panFormatInvalid"
[class.cx-pan--valid]="panValue && !panFormatInvalid"
>
<div class="cx-pan__head">
<mat-icon>credit_card</mat-icon>
<span class="cx-pan__label">PAN Card</span>
<span class="cx-pan__tag" [class.cx-pan__tag--on]="needsPan">{{ needsPan ? 'Mandatory' : 'Optional' }}</span>
</div>
<div class="cx-pan__input">
<input
type="text"
[value]="panNumber"
(input)="panNumber = $any($event.target).value.toUpperCase()"
placeholder="ABCDE1234F"
maxlength="10"
autocomplete="off"
spellcheck="false"
aria-label="PAN number"
/>
@if (panValue && !panFormatInvalid) {
<mat-icon class="cx-pan__state cx-pan__state--ok">check_circle</mat-icon>
} @else if (panFormatInvalid) {
<mat-icon class="cx-pan__state cx-pan__state--bad">error</mat-icon>
}
</div>
<div class="cx-pan__hint">
@if (panFormatInvalid) {
PAN is 5 letters, 4 digits, 1 letter (e.g. ABCDE1234F).
} @else if (panFromKyc) {
<mat-icon>link</mat-icon> Fetched from the customer's KYC record.
} @else if (panMissing) {
Required: this exchange is above ₹50,000 (Income Tax Rule 114B / RBI).
} @else {
Mandatory for exchanges above ₹50,000.
}
</div>
</div>
</div>
</section>
<div class="cx-cols">
<!-- The cashier's count of a denomination and what this exchange does to it, updated as counts are typed. -->
<ng-template #drawerCell let-d="d">
@if (availableFor(d) === null) {
—
} @else {
In drawer <strong>{{ availableFor(d) }}</strong>
@if (netFor(d) !== 0) {
<span class="cx-after">
<mat-icon>{{ netFor(d) > 0 ? 'trending_up' : 'trending_down' }}</mat-icon>
<strong>{{ drawerAfter(d) }}</strong>
<small>({{ netFor(d) > 0 ? '+' : '' }}{{ netFor(d) }})</small>
</span>
}
}
</ng-template>
<!-- RECEIVED -->
<section class="cx-col cx-col--received">
<header class="cx-col-head">
@@ -78,7 +283,7 @@
<div class="cx-subhead">Bank Notes</div>
}
@for (d of notes; track d.id) {
<div class="cx-row">
<div class="cx-row cx-row--stock">
<span class="cx-chip">{{ d.label }}</span>
<div class="cx-step">
<button type="button" (click)="bump('r', d, -1)" [disabled]="countFor('r', d) === 0">−</button>
@@ -90,6 +295,9 @@
/>
<button type="button" (click)="bump('r', d, 1)">+</button>
</div>
<span class="cx-avail" [class.cx-avail--up]="netFor(d) > 0" [class.cx-avail--down]="netFor(d) < 0">
<ng-container *ngTemplateOutlet="drawerCell; context: { d: d }"></ng-container>
</span>
<span class="cx-amt" [class.cx-amt--zero]="countFor('r', d) === 0"
>₹{{ subtotal('r', d) | number: '1.2-2' }}</span
>
@@ -99,7 +307,7 @@
<div class="cx-subhead">Coins</div>
}
@for (d of coins; track d.id) {
<div class="cx-row">
<div class="cx-row cx-row--stock">
<span class="cx-chip cx-chip--coin">{{ d.label }}</span>
<div class="cx-step">
<button type="button" (click)="bump('r', d, -1)" [disabled]="countFor('r', d) === 0">−</button>
@@ -111,6 +319,9 @@
/>
<button type="button" (click)="bump('r', d, 1)">+</button>
</div>
<span class="cx-avail" [class.cx-avail--up]="netFor(d) > 0" [class.cx-avail--down]="netFor(d) < 0">
<ng-container *ngTemplateOutlet="drawerCell; context: { d: d }"></ng-container>
</span>
<span class="cx-amt" [class.cx-amt--zero]="countFor('r', d) === 0"
>₹{{ subtotal('r', d) | number: '1.2-2' }}</span
>
@@ -131,7 +342,7 @@
<div class="cx-subhead">Bank Notes</div>
}
@for (d of notes; track d.id) {
<div class="cx-row">
<div class="cx-row cx-row--stock" [class.cx-row--over]="exceedsAvailable(d)">
<span class="cx-chip">{{ d.label }}</span>
<div class="cx-step">
<button type="button" (click)="bump('g', d, -1)" [disabled]="countFor('g', d) === 0">−</button>
@@ -141,8 +352,16 @@
[value]="countFor('g', d)"
(input)="onChange('g', d, $any($event.target).value)"
/>
<button type="button" (click)="bump('g', d, 1)">+</button>
<button type="button" (click)="bump('g', d, 1)" [disabled]="!canGiveMore(d)">+</button>
</div>
<span
class="cx-avail"
[class.cx-avail--up]="netFor(d) > 0"
[class.cx-avail--down]="netFor(d) < 0"
[class.cx-avail--over]="exceedsAvailable(d)"
>
<ng-container *ngTemplateOutlet="drawerCell; context: { d: d }"></ng-container>
</span>
<span class="cx-amt" [class.cx-amt--zero]="countFor('g', d) === 0"
>₹{{ subtotal('g', d) | number: '1.2-2' }}</span
>
@@ -152,7 +371,7 @@
<div class="cx-subhead">Coins</div>
}
@for (d of coins; track d.id) {
<div class="cx-row">
<div class="cx-row cx-row--stock" [class.cx-row--over]="exceedsAvailable(d)">
<span class="cx-chip cx-chip--coin">{{ d.label }}</span>
<div class="cx-step">
<button type="button" (click)="bump('g', d, -1)" [disabled]="countFor('g', d) === 0">−</button>
@@ -162,8 +381,16 @@
[value]="countFor('g', d)"
(input)="onChange('g', d, $any($event.target).value)"
/>
<button type="button" (click)="bump('g', d, 1)">+</button>
<button type="button" (click)="bump('g', d, 1)" [disabled]="!canGiveMore(d)">+</button>
</div>
<span
class="cx-avail"
[class.cx-avail--up]="netFor(d) > 0"
[class.cx-avail--down]="netFor(d) < 0"
[class.cx-avail--over]="exceedsAvailable(d)"
>
<ng-container *ngTemplateOutlet="drawerCell; context: { d: d }"></ng-container>
</span>
<span class="cx-amt" [class.cx-amt--zero]="countFor('g', d) === 0"
>₹{{ subtotal('g', d) | number: '1.2-2' }}</span
>
@@ -173,6 +400,30 @@
</section>
</div>
<!-- What this exchange does to the cashier's drawer, denomination by denomination. -->
@if (impactRows.length) {
<div class="cx-impact">
<div class="cx-impact__title"><mat-icon>inventory_2</mat-icon> Drawer after this exchange</div>
<div class="cx-impact__chips">
@for (r of impactRows; track r.id) {
<span
class="cx-impact__chip"
[class.cx-impact__chip--up]="r.net > 0"
[class.cx-impact__chip--down]="r.net < 0"
>
<b>{{ r.label }}</b>
{{ r.before }} → {{ r.after }}
<em>({{ r.net > 0 ? '+' : '' }}{{ r.net }})</em>
</span>
}
</div>
<div class="cx-impact__note">
The branch's total for each denomination moves by the same amount; the vault is not touched (Branch Ops → Branch
Cash Position).
</div>
</div>
}
<div class="cx-summary">
<div class="cx-sum">
<span>Received</span><strong>₹{{ receivedTotal | number: '1.2-2' }}</strong>
@@ -213,9 +464,15 @@
</div>
</div>
}
@if (needsIdProof && !customerIdProof.trim()) {
@if (panMissing) {
<div class="cx-warn cx-warn--pan">
<mat-icon>badge</mat-icon> Enter customer PAN / Aadhaar to proceed (amount ≥ ₹50,000).
<mat-icon>badge</mat-icon> Enter the customer's PAN to proceed (amount above ₹50,000).
</div>
}
@if (!customerIdentified && !loadingCustomer) {
<div class="cx-warn cx-warn--pan">
<mat-icon>person_search</mat-icon>
{{ customerMode === 'existing' ? 'Search and select the customer.' : "Enter the walk-in customer's name." }}
</div>
}
@@ -73,99 +73,584 @@
}
}
/* ── Customer info bar ── */
.cx-customer-bar {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 14px;
background: #f8fafc;
/* ── Customer: existing client or walk-in, plus PAN ── */
.cx-cust {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 10px;
flex-wrap: wrap;
}
border-radius: 14px;
box-shadow: 0 1px 2px rgb(15 23 42 / 4%);
overflow: hidden;
.cx-customer-toggle {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
font-weight: 600;
color: #475569;
cursor: pointer;
user-select: none;
&__head {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
padding: 12px 16px;
background: linear-gradient(180deg, #f8fafc, #f1f5f9);
border-bottom: 1px solid #e2e8f0;
}
mat-icon {
font-size: 20px;
height: 20px;
width: 20px;
&__title {
display: flex;
align-items: center;
gap: 10px;
}
&__title-icon {
display: grid;
place-items: center;
width: 36px;
height: 36px;
border-radius: 10px;
background: #e6f4ee;
color: #0f4f3c;
mat-icon {
font-size: 20px;
width: 20px;
height: 20px;
}
}
&__title-text {
font-size: 14px;
font-weight: 700;
color: #0f172a;
}
&__title-sub {
font-size: 12px;
color: #64748b;
}
&__modes {
display: inline-flex;
padding: 3px;
background: #e2e8f0;
border-radius: 10px;
}
&__mode {
display: inline-flex;
align-items: center;
gap: 6px;
height: 32px;
padding: 0 14px;
border: 0;
border-radius: 8px;
background: transparent;
font-size: 13px;
font-weight: 600;
color: #475569;
cursor: pointer;
transition:
background 0.15s ease,
color 0.15s ease;
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
}
&:hover {
color: #0f172a;
}
&--active,
&--active:hover {
background: #fff;
color: #0f4f3c;
box-shadow: 0 1px 3px rgb(15 23 42 / 15%);
}
}
&__body {
display: grid;
grid-template-columns: minmax(0, 1.7fr) minmax(260px, 1fr);
gap: 16px;
padding: 16px;
}
&__who {
min-width: 0;
}
/* Nothing picked yet, or loading */
&__empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
min-height: 150px;
padding: 20px;
text-align: center;
background: #f8fafc;
border: 1.5px dashed #cbd5e1;
border-radius: 12px;
}
&__empty-icon {
font-size: 36px;
width: 36px;
height: 36px;
color: #94a3b8;
}
&--active {
color: #1e40af;
&__empty-title {
font-size: 14px;
font-weight: 600;
color: #334155;
}
&__empty-sub {
margin-bottom: 8px;
font-size: 12px;
color: #64748b;
}
/* A picked client */
&__card {
padding: 14px;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 12px;
}
&__card-top {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 12px;
}
&__card-id {
flex: 1 1 180px;
min-width: 0;
}
&__name {
font-size: 16px;
font-weight: 700;
color: #0f172a;
overflow-wrap: anywhere;
}
&__badges {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
margin-top: 4px;
}
&__card-actions {
display: flex;
gap: 4px;
}
&__grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 12px 16px;
margin-top: 12px;
padding-top: 12px;
border-top: 1px dashed #e2e8f0;
span {
display: block;
margin-bottom: 2px;
font-size: 11px;
color: #64748b;
}
strong {
font-size: 13px;
font-weight: 600;
color: #0f172a;
}
}
/* A walk-in, identified at the counter */
&__walkin {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
.cx-field {
font-size: 12px;
}
.cx-field input {
width: 100%;
height: 40px;
box-sizing: border-box;
}
}
.cx-field input:focus {
outline: none;
border-color: #1f7a4d;
box-shadow: 0 0 0 3px rgb(31 122 77 / 15%);
}
}
.cx-avatar {
display: grid;
place-items: center;
flex: 0 0 auto;
width: 44px;
height: 44px;
border-radius: 50%;
background: linear-gradient(135deg, #1f7a4d, #0f4f3c);
font-size: 15px;
font-weight: 700;
letter-spacing: 0.02em;
color: #fff;
&--sm {
width: 40px;
height: 40px;
font-size: 14px;
}
}
/* The cashier at the counter, or why there is none */
.cx-cashier {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 14px 20px;
padding: 12px 16px;
background: #fff;
border: 1px solid #e2e8f0;
border-left: 4px solid #1f7a4d;
border-radius: 14px;
box-shadow: 0 1px 2px rgb(15 23 42 / 4%);
&__id {
flex: 1 1 220px;
min-width: 0;
}
&__label {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: #64748b;
}
&__name {
font-size: 16px;
font-weight: 700;
color: #0f172a;
}
&__flow {
display: flex;
flex-wrap: wrap;
gap: 4px 14px;
margin-top: 2px;
font-size: 12px;
color: #475569;
span {
display: inline-flex;
align-items: center;
gap: 2px;
}
mat-icon {
color: #1e40af;
font-size: 14px;
width: 14px;
height: 14px;
color: #94a3b8;
}
}
&__stats {
display: flex;
flex-wrap: wrap;
gap: 10px 28px;
span {
display: block;
font-size: 11px;
color: #64748b;
}
strong {
font-size: 14px;
font-weight: 600;
color: #0f172a;
}
}
&--none {
background: #fffbeb;
border-color: #fcd34d;
border-left-color: #d97706;
mat-icon {
color: #d97706;
}
.cx-cashier__label {
font-weight: 500;
letter-spacing: 0;
text-transform: none;
color: #92400e;
}
}
}
.cx-walk-in {
display: flex;
.cx-kyc {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: #64748b;
padding: 2px 8px;
border-radius: 999px;
font-size: 11px;
font-weight: 600;
mat-icon {
font-size: 16px;
height: 16px;
width: 16px;
font-size: 14px;
width: 14px;
height: 14px;
}
&--ok {
background: #dcfce7;
color: #166534;
}
&--warn {
background: #fef3c7;
color: #92400e;
}
&--sm {
margin-left: 6px;
padding: 1px 6px;
font-size: 10px;
}
}
.cx-pan-required {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
background: #fffbeb;
border: 1px solid #fcd34d;
.cx-chip-tag {
padding: 2px 8px;
border-radius: 6px;
background: #eef2ff;
font-size: 11px;
font-weight: 600;
color: #3730a3;
}
.cx-link-btn {
display: inline-flex;
align-items: center;
gap: 4px;
height: 30px;
padding: 0 10px;
border: 0;
border-radius: 8px;
background: transparent;
font-size: 12px;
font-weight: 600;
color: #92400e;
color: #1e40af;
cursor: pointer;
mat-icon {
font-size: 16px;
height: 16px;
width: 16px;
color: #d97706;
height: 16px;
}
&:hover {
background: #eff6ff;
}
&--danger {
color: #b91c1c;
&:hover {
background: #fef2f2;
}
}
}
.cx-field--inline {
flex-direction: row;
align-items: center;
/* PAN: neutral until needed, amber when required and missing, red when malformed, green when valid */
.cx-pan {
display: flex;
flex-direction: column;
align-self: start;
gap: 8px;
padding: 14px;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 12px;
span {
white-space: nowrap;
&__head {
display: flex;
align-items: center;
gap: 8px;
mat-icon {
font-size: 20px;
width: 20px;
height: 20px;
color: #475569;
}
}
input {
width: 180px;
&__label {
font-size: 13px;
font-weight: 700;
color: #0f172a;
}
&__tag {
margin-left: auto;
padding: 2px 8px;
border-radius: 999px;
background: #e2e8f0;
font-size: 11px;
font-weight: 600;
color: #475569;
&--on {
background: #fef3c7;
color: #92400e;
}
}
&__input {
position: relative;
input {
width: 100%;
height: 44px;
box-sizing: border-box;
padding: 0 40px 0 12px;
background: #fff;
border: 1.5px solid #cbd5e1;
border-radius: 10px;
font-family: ui-monospace, sfmono-regular, menlo, consolas, monospace;
font-size: 18px;
font-weight: 600;
letter-spacing: 0.18em;
text-transform: uppercase;
color: #0f172a;
&::placeholder {
font-weight: 500;
color: #cbd5e1;
}
&:focus {
outline: none;
border-color: #1f7a4d;
box-shadow: 0 0 0 3px rgb(31 122 77 / 15%);
}
}
}
&__state {
position: absolute;
top: 50%;
right: 10px;
transform: translateY(-50%);
font-size: 22px;
width: 22px;
height: 22px;
&--ok {
color: #16a34a;
}
&--bad {
color: #dc2626;
}
}
&__hint {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: #64748b;
mat-icon {
font-size: 14px;
width: 14px;
height: 14px;
}
}
&--required {
background: #fffbeb;
border-color: #fcd34d;
.cx-pan__input input {
border-color: #f59e0b;
}
.cx-pan__hint {
font-weight: 600;
color: #92400e;
}
}
&--invalid {
background: #fef2f2;
border-color: #fecaca;
.cx-pan__input input {
border-color: #ef4444;
}
.cx-pan__hint {
font-weight: 600;
color: #b91c1c;
}
}
&--valid {
background: #f0fdf4;
border-color: #bbf7d0;
.cx-pan__input input {
border-color: #22c55e;
}
.cx-pan__hint {
color: #166534;
}
}
}
.cx-field--pan input {
width: 160px;
border-color: #fcd34d;
.cx-spin {
animation: cx-spin 1s linear infinite;
}
&:focus {
outline: none;
border-color: #d97706;
@keyframes cx-spin {
to {
transform: rotate(360deg);
}
}
@media (width <= 900px) {
.cx-cust__body {
grid-template-columns: 1fr;
}
}
@media (width <= 600px) {
.cx-cust__walkin {
grid-template-columns: 1fr;
}
}
@@ -345,6 +830,129 @@
border-bottom: 1px dotted #eef2f7;
}
/* Both sides: the cashier's count of each denomination sits between the counter and the amount. */
.cx-row--stock {
grid-template-columns: 64px 120px 1fr 96px;
}
.cx-row--over {
background: #fef2f2;
}
.cx-avail {
padding-left: 6px;
font-size: 12px;
color: #64748b;
strong {
font-weight: 700;
color: #0f172a;
}
/* the count after this exchange: green when it goes up, red when it goes down */
.cx-after {
display: inline-flex;
align-items: center;
gap: 3px;
margin-left: 6px;
font-weight: 600;
mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
}
small {
font-size: 11px;
}
}
&--up .cx-after,
&--up .cx-after strong {
color: #15803d;
}
&--down .cx-after,
&--down .cx-after strong {
color: #b45309;
}
&--over,
&--over strong,
&--over .cx-after,
&--over .cx-after strong {
color: #b91c1c;
}
}
/* The drawer after the whole exchange, one chip per denomination it changes */
.cx-impact {
padding: 12px 14px;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 12px;
&__title {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 8px;
font-size: 13px;
font-weight: 700;
color: #0f172a;
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
color: #475569;
}
}
&__chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
&__chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 10px;
border-radius: 8px;
background: #f1f5f9;
font-size: 12px;
color: #334155;
b {
font-weight: 700;
}
em {
font-style: normal;
font-weight: 700;
}
&--up {
background: #dcfce7;
color: #166534;
}
&--down {
background: #fef3c7;
color: #92400e;
}
}
&__note {
margin-top: 8px;
font-size: 11px;
color: #64748b;
}
}
.cx-chip {
display: inline-flex;
align-items: center;
@@ -11,7 +11,15 @@ import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MatIconModule } from '@angular/material/icon';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
import { forkJoin, of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { CashierService } from '../cashier.service';
import { ClientsService } from 'app/clients/clients.service';
import {
CustomerSearchDialogComponent,
CustomerSearchResult
} from 'app/shared/customer-search-dialog/customer-search-dialog.component';
import { businessToday } from 'app/core/utils/business-today';
interface Denom {
@@ -21,6 +29,47 @@ interface Denom {
label: string;
}
/** An existing client picked through Customer Search, with what the counter needs to identify them. */
interface SelectedCustomer {
clientId: number;
displayName: string;
cifNumber: string | null;
mobileNo: string | null;
officeName: string | null;
accountNo: string | null;
accountType: string | null;
/** PAN on the client's KYC record, if any. */
kycPan: string | null;
panVerified: boolean;
kycStatus: string;
}
/** The cashier at the counter: the one who takes the customer's notes and hands over the change. */
interface CashierInfo {
cashierId: number;
name: string;
tellerName: string | null;
officeName: string | null;
/** Cash the vault allocated to them for the day, once known. */
allocatedAmount: number | null;
/** Cash they hold now. */
totalAmount: number;
}
/** Same rule as ClientKycApiConstants.PAN_REGEX on the server: 5 letters, 4 digits, 1 letter. */
const PAN_PATTERN = /^[A-Z]{5}[0-9]{4}[A-Z]$/;
/** Income Tax Rule 114B / RBI: a cash exchange above this needs the customer's PAN. */
const PAN_MANDATORY_ABOVE = 50000;
const KYC_STATUS: Record<number, string> = {
100: 'Pending',
200: 'In Progress',
300: 'Verified',
400: 'Rejected',
500: 'Expired'
};
/** Cashier cash-exchange entry: customer gives some denominations and takes equal-value change (net 0). */
@Component({
selector: 'mifosx-cash-exchange-entry',
@@ -31,11 +80,14 @@ interface Denom {
CommonModule,
FormsModule,
MatIconModule,
MatSnackBarModule
MatSnackBarModule,
MatDialogModule
]
})
export class CashExchangeEntryComponent implements OnInit {
private service = inject(CashierService);
private clientsService = inject(ClientsService);
private dialog = inject(MatDialog);
private snackBar = inject(MatSnackBar);
notes: Denom[] = [];
@@ -47,11 +99,17 @@ export class CashExchangeEntryComponent implements OnInit {
saving = false;
// ── Customer info ──
isAccountHolder = false;
accountNo = '';
customerIdProof = '';
/** An existing client found through Customer Search, or a walk-in identified at the counter. */
customerMode: 'existing' | 'walkin' = 'walkin';
selectedCustomer: SelectedCustomer | null = null;
loadingCustomer = false;
walkInName = '';
walkInMobile = '';
panNumber = '';
// ── Cashier position (for denomination overdraft check) ──
// ── Cashier at the counter, and their position (for the denomination overdraft check) ──
/** The cashier linked to the logged-in user; null when the login is not a cashier's. */
cashier: CashierInfo | null = null;
private availableMap = new Map<number, number>();
positionLoaded = false;
@@ -88,7 +146,19 @@ export class CashExchangeEntryComponent implements OnInit {
((res?.denominations || []) as any[]).forEach((d: any) => {
if (d.denominationId) this.availableMap.set(Number(d.denominationId), Number(d.count ?? 0));
});
const cashierId = res?.cashierId ? Number(res.cashierId) : null;
this.cashier = cashierId
? {
cashierId,
name: res.cashierName || '',
tellerName: null,
officeName: null,
allocatedAmount: null,
totalAmount: Number(res.totalAmount ?? 0)
}
: null;
this.positionLoaded = true;
if (cashierId) this.loadCashierDetails(cashierId);
},
error: () => {
this.positionLoaded = true;
@@ -96,6 +166,31 @@ export class CashExchangeEntryComponent implements OnInit {
});
}
/** The counter, branch and today's allocation; the card still shows name and cash in hand without them. */
private loadCashierDetails(cashierId: number): void {
this.service
.getInterCashierCashierSummary(cashierId, this.exchangeDate)
.pipe(catchError(() => of(null)))
.subscribe((r: any) => {
if (!r || this.cashier?.cashierId !== cashierId) return;
this.cashier = {
...this.cashier,
tellerName: r.tellerName || null,
officeName: r.officeName || null,
allocatedAmount: Number(r.allocatedAmount ?? 0)
};
});
}
/** Up to two letters for the cashier's avatar. */
get cashierInitials(): string {
const words = (this.cashier?.name || '').trim().split(/\s+/).filter(Boolean);
if (words.length === 0) return '?';
const first = words[0].charAt(0);
const last = words.length > 1 ? words[words.length - 1].charAt(0) : '';
return (first + last).toUpperCase();
}
countFor(side: 'r' | 'g', d: Denom): number {
return (side === 'r' ? this.receivedCounts : this.givenCounts)[d.id] || 0;
}
@@ -129,14 +224,102 @@ export class CashExchangeEntryComponent implements OnInit {
return this.receivedTotal > 0 && this.receivedTotal === this.givenTotal;
}
/** Above ₹50,000 PAN/ID is mandatory per RBI guideline */
get needsIdProof(): boolean {
return this.receivedTotal >= 50000;
/** Above ₹50,000 the customer's PAN is mandatory (Income Tax Rule 114B, followed under RBI guidelines). */
get needsPan(): boolean {
return this.receivedTotal > PAN_MANDATORY_ABOVE;
}
/** Up to two letters for the avatar: first and last word of the customer's name. */
get customerInitials(): string {
const words = (this.selectedCustomer?.displayName || '').trim().split(/\s+/).filter(Boolean);
if (words.length === 0) return '?';
const first = words[0].charAt(0);
const last = words.length > 1 ? words[words.length - 1].charAt(0) : '';
return (first + last).toUpperCase();
}
get panValue(): string {
return this.panNumber.trim().toUpperCase();
}
/** A PAN that is entered must be well-formed, required or not. */
get panFormatInvalid(): boolean {
return !!this.panValue && !PAN_PATTERN.test(this.panValue);
}
get panMissing(): boolean {
return this.needsPan && !this.panValue;
}
/** The PAN comes from the client's KYC record, so the cashier confirms it rather than types it. */
get panFromKyc(): boolean {
return !!this.selectedCustomer?.kycPan && this.panValue === this.selectedCustomer.kycPan.toUpperCase();
}
/** Who the customer is has to be known: a picked client, or a walk-in's name once PAN is required. */
get customerIdentified(): boolean {
if (this.customerMode === 'existing') return !!this.selectedCustomer;
return !this.needsPan || this.walkInName.trim().length > 0;
}
/** A login with no cashier behind it has no drawer to take notes into or give change from. */
get noCashier(): boolean {
return this.positionLoaded && !this.cashier;
}
/** How many of the denomination the cashier holds, or null while that is not known (loading, or no cashier). */
availableFor(d: Denom): number | null {
if (!this.positionLoaded || !this.cashier) return null;
return this.availableMap.get(d.id) ?? 0;
}
/** The cashier is giving more of this denomination than they hold. */
exceedsAvailable(d: Denom): boolean {
const available = this.availableFor(d);
return available !== null && this.countFor('g', d) > available;
}
/** The + on the given side stops at what the cashier holds; a larger typed number is still caught and shown. */
canGiveMore(d: Denom): boolean {
const available = this.availableFor(d);
return available === null || this.countFor('g', d) < available;
}
/** What this exchange does to the cashier's count of the denomination: notes taken in less notes given out. */
netFor(d: Denom): number {
return this.countFor('r', d) - this.countFor('g', d);
}
/** The cashier's count of the denomination once this exchange is recorded, or null while it is not known. */
drawerAfter(d: Denom): number | null {
const available = this.availableFor(d);
return available === null ? null : available + this.netFor(d);
}
/**
* The denominations this exchange changes, with the drawer's count before and after, so the effect shows
* while the counts are being typed. The branch's total for each denomination moves by the same amount, as
* the customer's notes go into the drawer and the change comes out of it; the vault is not involved.
*/
get impactRows(): Array<{ id: number; label: string; kind: string; net: number; before: number; after: number }> {
if (!this.positionLoaded || !this.cashier) return [];
return this.denoms
.filter((d) => this.netFor(d) !== 0)
.map((d) => ({
id: d.id,
label: d.label,
kind: d.kind,
net: this.netFor(d),
before: this.availableFor(d) ?? 0,
after: this.drawerAfter(d) ?? 0
}));
}
/** Denominations where cashier is giving more than currently available. */
get givenOverdraftItems(): Array<{ label: string; available: number; requested: number }> {
if (!this.positionLoaded) return [];
// Without a cashier every denomination would read as "0 available"; the missing cashier is the
// thing to tell the user, not a stock shortage.
if (!this.positionLoaded || !this.cashier) return [];
return this.denoms
.map((d) => ({ label: d.label, available: this.availableMap.get(d.id) ?? 0, requested: this.countFor('g', d) }))
.filter((item) => item.requested > 0 && item.requested > item.available);
@@ -147,21 +330,77 @@ export class CashExchangeEntryComponent implements OnInit {
}
get valid(): boolean {
return this.matches && (!this.needsIdProof || this.customerIdProof.trim().length > 0) && !this.hasGivenOverdraft;
return (
this.matches &&
!!this.cashier &&
!this.hasGivenOverdraft &&
!this.panFormatInvalid &&
!this.panMissing &&
this.customerIdentified &&
!this.loadingCustomer
);
}
toggleAccountHolder(): void {
this.isAccountHolder = !this.isAccountHolder;
if (!this.isAccountHolder) this.accountNo = '';
setCustomerMode(mode: 'existing' | 'walkin'): void {
if (this.customerMode === mode) return;
this.customerMode = mode;
this.selectedCustomer = null;
this.walkInName = '';
this.walkInMobile = '';
this.panNumber = '';
}
/** Pick an existing client through Customer Search, then load their details and KYC (PAN). */
openCustomerSearch(): void {
this.dialog
// An existing customer without an account is still a known customer, so the account is not required.
.open(CustomerSearchDialogComponent, { width: '960px', maxWidth: '95vw', data: { requireAccount: false } })
.afterClosed()
.subscribe((result: CustomerSearchResult | undefined) => {
if (!result?.clientId) return;
this.loadCustomer(result);
});
}
private loadCustomer(result: CustomerSearchResult): void {
this.loadingCustomer = true;
const id = String(result.clientId);
forkJoin({
client: this.clientsService.getClientData(id, true).pipe(catchError(() => of(null))),
kyc: this.clientsService.getClientKyc(id, true).pipe(catchError(() => of(null)))
}).subscribe(({ client, kyc }: { client: any; kyc: any }) => {
const kycPan: string | null = kyc?.panNumber || result.panNumber || null;
this.selectedCustomer = {
clientId: result.clientId,
displayName: client?.displayName || result.displayName,
cifNumber: client?.cifNumber || null,
mobileNo: client?.mobileNo || result.mobileNo || null,
officeName: client?.officeName || result.officeName || null,
accountNo: result.accountNo || null,
accountType: result.accountType || null,
kycPan,
panVerified: !!kyc?.panVerified,
kycStatus: KYC_STATUS[Number(kyc?.kycStatus?.id ?? kyc?.kycStatus)] || 'Not on record'
};
this.panNumber = kycPan ? kycPan.toUpperCase() : '';
this.loadingCustomer = false;
});
}
clearCustomer(): void {
this.selectedCustomer = null;
this.panNumber = '';
}
clear(): void {
this.receivedCounts = {};
this.givenCounts = {};
this.note = '';
this.accountNo = '';
this.customerIdProof = '';
this.isAccountHolder = false;
this.customerMode = 'walkin';
this.selectedCustomer = null;
this.walkInName = '';
this.walkInMobile = '';
this.panNumber = '';
}
submit(): void {
@@ -171,11 +410,17 @@ export class CashExchangeEntryComponent implements OnInit {
this.denoms
.filter((d) => this.countFor(side, d) > 0)
.map((d) => ({ denominationId: d.id, denominationValue: d.value, count: this.countFor(side, d) }));
const customer = this.customerMode === 'existing' ? this.selectedCustomer : null;
const pan = this.panValue || null;
const payload: any = {
exchangeDate: this.exchangeDate,
note: this.note || null,
customerIdProof: this.customerIdProof.trim() || null,
accountNo: this.isAccountHolder && this.accountNo.trim() ? this.accountNo.trim() : null,
clientId: customer?.clientId ?? null,
accountNo: customer?.accountNo ?? null,
customerName: customer ? customer.displayName : this.walkInName.trim() || null,
customerMobile: customer ? customer.mobileNo : this.walkInMobile.trim() || null,
panNumber: pan,
customerIdProof: pan,
received: build('r'),
given: build('g')
};
@@ -11,25 +11,11 @@
<div>
<div class="pg-eyebrow">CASHIER · HISTORY</div>
<h1 class="pg-title">Cash Movement History</h1>
<p class="pg-sub">All transfers, settlements, and inter-cashier movements. Reverse any active transaction.</p>
<p class="pg-sub">All transfers, settlements, inter-cashier movements and cash exchanges.</p>
</div>
<button type="button" class="pg-btn pg-btn--ghost" (click)="load()"><mat-icon>refresh</mat-icon> Refresh</button>
</header>
<!-- Reversal confirm panel -->
@if (reversingId !== null) {
<div class="reverse-panel">
<mat-icon class="reverse-icon">warning</mat-icon>
<span
>Reverse <strong>#{{ reversingId }}</strong
>? This cannot be undone.</span
>
<input class="reverse-reason" type="text" [(ngModel)]="reverseReason" placeholder="Reason (optional)" />
<button type="button" class="pg-btn pg-btn--danger" (click)="confirmReverse()">Confirm Reversal</button>
<button type="button" class="pg-btn pg-btn--ghost" (click)="cancelReverse()">Cancel</button>
</div>
}
@if (loading) {
<div class="pg-empty">Loading…</div>
}
@@ -134,11 +120,6 @@
>
<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>
@@ -187,6 +168,12 @@
<span class="detail-meta__label">Date</span>
<span class="detail-meta__val">{{ detail.row.date }}</span>
</div>
@if (detail.row.type === 'exchange') {
<div class="detail-meta__item">
<span class="detail-meta__label">Customer PAN</span>
<span class="detail-meta__val">{{ detail.row.panNumber || '—' }}</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>
@@ -47,38 +47,6 @@
}
}
.reverse-panel {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
background: #fffbeb;
border: 1px solid #fcd34d;
border-radius: 10px;
flex-wrap: wrap;
font-size: 13px;
color: #92400e;
.reverse-icon {
color: #f59e0b;
font-size: 20px;
height: 20px;
width: 20px;
}
.reverse-reason {
flex: 1 1 200px;
height: 34px;
padding: 0 10px;
border: 1px solid #fcd34d;
border-radius: 7px;
font-size: 13px;
font-family: inherit;
background: #fff;
color: #1a1a1a;
}
}
/* ── Action buttons cell ── */
.action-btns {
display: flex;
@@ -8,9 +8,7 @@
import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MatIconModule } from '@angular/material/icon';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { MatTabsModule } from '@angular/material/tabs';
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
import { forkJoin, of } from 'rxjs';
@@ -31,6 +29,8 @@ interface TxnRow {
reversedBy: number | null;
reversedAt: string | null;
reverseReason: string | null;
/** Customer PAN recorded on a cash exchange. */
panNumber?: string | null;
type: 'transfer' | 'settlement' | 'inter' | 'exchange';
}
@@ -52,7 +52,10 @@ interface DetailView {
loading: boolean;
}
/** Unified history of all cash movements with reversal and detail view support. */
/**
* Unified history of all cash movements with a detail view. Read-only: cash movements cannot be reversed
* from here (an earlier reversal still shows as Reversed, with who, when and why).
*/
@Component({
selector: 'mifosx-cash-history',
templateUrl: './cash-history.component.html',
@@ -63,16 +66,13 @@ interface DetailView {
standalone: true,
imports: [
CommonModule,
FormsModule,
MatIconModule,
MatSnackBarModule,
MatTabsModule,
MatPaginatorModule
]
})
export class CashHistoryComponent implements OnInit {
private service = inject(CashierService);
private snackBar = inject(MatSnackBar);
transfers: TxnRow[] = [];
settlements: TxnRow[] = [];
@@ -99,10 +99,6 @@ export class CashHistoryComponent implements OnInit {
private denomKindMap = new Map<number, 'note' | 'coin'>();
reversingId: number | null = null;
reversingType: string | null = null;
reverseReason = '';
detail: DetailView | null = null;
ngOnInit(): void {
@@ -212,7 +208,8 @@ export class CashHistoryComponent implements OnInit {
this.exchanges = (exchangeItems as any[]).map((e) => ({
id: e.id,
date: e.exchangeDate,
fromParty: e.accountNo ? `Account ${e.accountNo}` : 'Walk-in Customer',
fromParty: this.exchangeParty(e),
panNumber: e.panNumber ?? null,
toParty: e.performedByUsername || '—',
amount: e.exchangeAmount,
note: e.note,
@@ -310,7 +307,8 @@ export class CashHistoryComponent implements OnInit {
this.exchanges = (res.pageItems ?? []).map((e: any) => ({
id: e.id,
date: e.exchangeDate,
fromParty: e.accountNo ? `Account ${e.accountNo}` : 'Walk-in Customer',
fromParty: this.exchangeParty(e),
panNumber: e.panNumber ?? null,
toParty: e.performedByUsername || '—',
amount: e.exchangeAmount,
note: e.note,
@@ -392,39 +390,12 @@ export class CashHistoryComponent implements OnInit {
this.detail = null;
}
startReverse(row: TxnRow): void {
this.reversingId = row.id;
this.reversingType = row.type;
this.reverseReason = '';
}
cancelReverse(): void {
this.reversingId = null;
this.reversingType = null;
this.reverseReason = '';
}
confirmReverse(): void {
if (!this.reversingId || !this.reversingType) return;
const id = this.reversingId;
const type = this.reversingType;
const reason = this.reverseReason;
this.cancelReverse();
let obs$;
if (type === 'transfer') obs$ = this.service.reverseCashTransfer(id, reason);
else if (type === 'settlement') obs$ = this.service.reverseCashSettlement(id, reason);
else if (type === 'exchange') obs$ = this.service.reverseCashExchange(id, reason);
else obs$ = this.service.reverseInterCashierTransfer(id, reason);
obs$.pipe(catchError(() => of(null))).subscribe((res: any) => {
if (res?.reversed) {
this.snackBar.open('Transaction reversed successfully', 'Close', { duration: 3000 });
this.load();
} else {
this.snackBar.open('Reversal failed', 'Close', { duration: 3000 });
}
});
/** Who the exchange was with: the customer's name (and account, if one), else the account, else a walk-in. */
private exchangeParty(e: any): string {
if (e.customerName) {
return e.accountNo ? `${e.customerName} (A/C ${e.accountNo})` : e.customerName;
}
return e.accountNo ? `Account ${e.accountNo}` : 'Walk-in Customer';
}
typeLabel(type: string): string {
@@ -0,0 +1,229 @@
<!--
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/.
-->
<div class="cv-tab-content cta-tab">
<!-- ── TITLE BAR ── -->
<div class="cta-action-bar">
<div class="cta-action-bar__left">
<mat-icon class="cta-action-bar__icon">swap_horiz</mat-icon>
<div>
<div class="cta-action-bar__title">Cash Transfers</div>
<div class="cta-action-bar__sub">
Approve or reject an allocation of cash from the vault to a cashier. The cash leaves the vault only when it is
approved.
</div>
</div>
</div>
<div class="cta-counts">
<span class="cta-count"
><strong>{{ all.length }}</strong> waiting</span
>
</div>
</div>
<!-- ── FILTER BAR ── -->
<div class="cta-filter-bar">
<div class="cta-search-box">
<mat-icon>search</mat-icon>
<input type="text" placeholder="Search by cashier, vault, user or note" [(ngModel)]="searchTerm" />
@if (searchTerm) {
<button type="button" class="cta-search-box__clear" (click)="searchTerm = ''">
<mat-icon>close</mat-icon>
</button>
}
</div>
<button type="button" class="cta-btn cta-btn--refresh" (click)="load()" [disabled]="loading">
<mat-icon>refresh</mat-icon>
</button>
</div>
<!-- ── TABLE ── -->
@if (loading && !loaded) {
<div class="cta-empty"><p class="cta-empty__title">Loading…</p></div>
} @else if (rows.length === 0) {
<div class="cta-empty">
<mat-icon class="cta-empty__icon">inbox</mat-icon>
<p class="cta-empty__title">No cash transfer waiting</p>
<p class="cta-empty__text">There is no cash allocation waiting for approval.</p>
</div>
} @else {
<div class="cta-table-wrap">
<table class="cta-table">
<thead>
<tr>
<th>Date</th>
<th>From</th>
<th>To (Cashier)</th>
<th class="num">Amount</th>
<th>Note</th>
<th>Raised By</th>
<th>Raised At</th>
<th class="cta-th-action">Action</th>
</tr>
</thead>
<tbody>
@for (r of rows; track r.id) {
<tr class="cta-row">
<td class="cta-dt">{{ r.transfer_date || '—' }}</td>
<td>{{ r.from_party || '—' }}</td>
<td class="cta-strong">{{ r.to_party || '—' }}</td>
<td class="num cta-amount">₹{{ r.total_amount | number: '1.2-2' }}</td>
<td class="cta-reason" [title]="r.note || ''">{{ r.note || '—' }}</td>
<td>{{ r.maker_name || '—' }}</td>
<td class="cta-dt">{{ formatDateTime(r.maker_at) }}</td>
<td class="cta-action-cell">
<button type="button" class="cta-btn cta-btn--approve" (click)="review(r)">
<mat-icon>rate_review</mat-icon> Review
</button>
</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
<!-- ─── Popup: review a request, every note and coin it asks for ───────────── -->
<ng-template #reviewDialog>
@if (reviewing) {
<div class="cta-dlg">
<header class="cta-dlg__head">
<mat-icon class="cta-dlg__ico cta-dlg__ico--ok">swap_horiz</mat-icon>
<div class="cta-dlg__titles">
<div class="cta-dlg__title">Review cash transfer</div>
<div class="cta-dlg__sub">
Raised by {{ reviewing.maker_name || '—' }} · {{ formatDateTime(reviewing.maker_at) }}
</div>
</div>
<button type="button" class="cta-dlg__x" aria-label="Close" (click)="closeReview()">
<mat-icon>close</mat-icon>
</button>
</header>
<div class="cta-dlg__body">
<div class="cta-total">
<span>Amount to release</span>
<strong>₹{{ reviewing.total_amount | number: '1.2-2' }}</strong>
</div>
<div class="cta-grid">
<div class="cta-cell">
<div class="cta-label">Transfer date</div>
<div class="cta-value">{{ reviewing.transfer_date || '—' }}</div>
</div>
<div class="cta-cell">
<div class="cta-label">From</div>
<div class="cta-value">{{ reviewing.from_party || '—' }}</div>
</div>
<div class="cta-cell">
<div class="cta-label">To (cashier)</div>
<div class="cta-value">{{ reviewing.to_party || '—' }}</div>
</div>
<div class="cta-cell">
<div class="cta-label">Notes and coins</div>
<div class="cta-value">
{{ piecesOf(reviewing) }} pieces · {{ linesOf(reviewing).length }} denominations
</div>
</div>
@if (reviewing.note) {
<div class="cta-cell cta-cell--wide">
<div class="cta-label">Note</div>
<div class="cta-value">{{ reviewing.note }}</div>
</div>
}
</div>
<div class="cta-label">Denomination breakdown</div>
<table class="cta-mini">
<thead>
<tr>
<th>Denomination</th>
<th class="num">Count</th>
<th class="num">Amount</th>
</tr>
</thead>
<tbody>
@for (l of linesOf(reviewing); track l.value) {
<tr>
<td>₹{{ l.value }}</td>
<td class="num">{{ l.count }}</td>
<td class="num">₹{{ l.amount | number: '1.2-2' }}</td>
</tr>
}
</tbody>
<tfoot>
<tr>
<td>Total</td>
<td class="num">{{ piecesOf(reviewing) }}</td>
<td class="num">₹{{ reviewing.total_amount | number: '1.2-2' }}</td>
</tr>
</tfoot>
</table>
</div>
<footer class="cta-dlg__foot">
<button type="button" class="cta-btn cta-btn--ghost" (click)="closeReview()">Close</button>
<button
type="button"
class="cta-btn cta-btn--reject"
[disabled]="busyId === reviewing.id"
(click)="rejectFromReview(reviewing)"
>
<mat-icon>close</mat-icon> Reject
</button>
<button
type="button"
class="cta-btn cta-btn--approve"
[disabled]="busyId === reviewing.id"
(click)="approve(reviewing)"
>
<mat-icon>check</mat-icon> Approve
</button>
</footer>
</div>
}
</ng-template>
<!-- ─── Popup: reject with a reason ───────────────────────────────────────── -->
<ng-template #rejectDialog>
@if (rejecting) {
<div class="cta-dlg">
<header class="cta-dlg__head">
<mat-icon class="cta-dlg__ico">block</mat-icon>
<div class="cta-dlg__titles">
<div class="cta-dlg__title">Reject cash transfer</div>
<div class="cta-dlg__sub">₹{{ rejecting.total_amount | number: '1.2-2' }} to {{ rejecting.to_party }}</div>
</div>
<button type="button" class="cta-dlg__x" aria-label="Close" (click)="cancelReject()">
<mat-icon>close</mat-icon>
</button>
</header>
<div class="cta-dlg__body">
<label class="cta-label" for="cta-reason">Reason (required)</label>
<textarea
id="cta-reason"
rows="3"
placeholder="Why is this being rejected?"
[(ngModel)]="rejectReason"
></textarea>
</div>
<footer class="cta-dlg__foot">
<button type="button" class="cta-btn cta-btn--ghost" (click)="cancelReject()">Cancel</button>
<button
type="button"
class="cta-btn cta-btn--reject-solid"
[disabled]="!rejectReason.trim() || busyId === rejecting.id"
(click)="confirmReject()"
>
Confirm reject
</button>
</footer>
</div>
}
</ng-template>
@@ -0,0 +1,573 @@
/**
* 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/.
*/
// Laid out like the other approval tabs (Withdrawal Requests): title bar, filter bar, one table.
:host {
display: block;
}
.cta-tab {
padding: 0;
}
.cta-action-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 10px 16px;
background: #fff;
border-bottom: 1px solid #e2e8f0;
&__left {
display: flex;
align-items: center;
gap: 12px;
}
&__icon {
font-size: 22px;
width: 22px;
height: 22px;
color: #0f4f3c;
}
&__title {
font-size: 13px;
font-weight: 700;
color: #0d1b2a;
}
&__sub {
max-width: 640px;
margin-top: 1px;
font-size: 11px;
color: #64748b;
}
}
.cta-counts {
display: flex;
gap: 8px;
}
.cta-count {
padding: 2px 10px;
border-radius: 999px;
background: #f1f5f9;
font-size: 11px;
color: #475569;
strong {
color: #0f4f3c;
}
}
.cta-filter-bar {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
padding: 6px 16px;
background: #f8fafc;
border-bottom: 1px solid #e2e8f0;
}
.cta-fb-sep {
flex-shrink: 0;
width: 1px;
height: 22px;
margin: 0 4px;
background: #e2e8f0;
}
.cta-search-box {
display: inline-flex;
align-items: center;
gap: 6px;
height: 30px;
min-width: 240px;
padding: 0 10px;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #fff;
mat-icon {
flex-shrink: 0;
font-size: 17px;
width: 17px;
height: 17px;
color: #94a3b8;
}
&:hover {
border-color: #cbd5e1;
}
&:focus-within {
border-color: #0f4f3c;
box-shadow: 0 0 0 3px rgb(15 79 60 / 8%);
}
input {
flex: 1;
border: none;
outline: none;
background: transparent;
font-family: inherit;
font-size: 12px;
color: #0d1b2a;
&::placeholder {
color: #94a3b8;
}
}
&__clear {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 20px;
height: 20px;
padding: 0;
border: none;
border-radius: 50%;
background: none;
color: #94a3b8;
cursor: pointer;
mat-icon {
font-size: 15px;
width: 15px;
height: 15px;
color: inherit;
}
&:hover {
color: #dc2626;
background: #fef2f2;
}
}
}
.cta-filter-select {
height: 30px;
min-width: 140px;
padding: 0 10px;
border: 1px solid #e2e8f0;
border-radius: 7px;
background: #fff;
font-family: inherit;
font-size: 12px;
color: #0d1b2a;
cursor: pointer;
&:focus {
outline: none;
border-color: #0f4f3c;
}
}
// ── Buttons ─────────────────────────────────────────────────────────────────
.cta-btn {
display: inline-flex;
align-items: center;
gap: 4px;
height: 26px;
padding: 0 10px;
border: 1px solid transparent;
border-radius: 7px;
font-family: inherit;
font-size: 11px;
font-weight: 600;
white-space: nowrap;
cursor: pointer;
mat-icon {
font-size: 14px;
width: 14px;
height: 14px;
}
&:disabled {
opacity: 0.45;
cursor: not-allowed;
}
&--refresh {
height: 30px;
width: 30px;
justify-content: center;
padding: 0;
margin-left: auto;
border-color: #e2e8f0;
background: #fff;
color: #475569;
}
&--approve {
background: #0f4f3c;
color: #fff;
&:hover:not(:disabled) {
background: #0a3d2e;
}
}
&--reject {
border-color: #fecaca;
background: #fff;
color: #dc2626;
&:hover:not(:disabled) {
background: #fef2f2;
}
}
&--reject-solid {
background: #dc2626;
color: #fff;
}
&--ghost {
border-color: #e2e8f0;
background: #fff;
color: #475569;
}
}
// ── Table ───────────────────────────────────────────────────────────────────
.cta-table-wrap {
overflow-x: auto;
background: #fff;
}
.cta-table {
width: 100%;
border-collapse: collapse;
font-size: 11px;
th {
padding: 7px 10px;
border-bottom: 1px solid #e2e8f0;
background: #f8fafc;
font-size: 9.5px;
font-weight: 800;
letter-spacing: 0.06em;
text-align: left;
text-transform: uppercase;
color: #94a3b8;
white-space: nowrap;
}
td {
padding: 5px 10px;
border-bottom: 1px solid #eef2f7;
vertical-align: middle;
color: #0d1b2a;
}
.num {
text-align: right;
font-variant-numeric: tabular-nums;
}
.cta-th-action {
text-align: right;
}
}
.cta-row:hover td {
background: #f8fafc;
}
.cta-mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
letter-spacing: 0.02em;
}
.cta-voucher {
font-weight: 700;
color: #0f4f3c;
}
.cta-strong {
font-weight: 700;
}
.cta-sub {
margin-top: 1px;
font-size: 11px;
color: #64748b;
}
.cta-none {
color: #cbd5e1;
}
.cta-amount {
font-weight: 700;
}
.cta-reason {
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cta-dt {
white-space: nowrap;
color: #475569;
}
.cta-kind {
display: inline-block;
padding: 2px 10px;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
&--unpass {
background: #fef3c7;
color: #92400e;
}
&--correction {
background: #dbeafe;
color: #1e40af;
}
}
.cta-action-cell {
display: flex;
justify-content: flex-end;
gap: 6px;
white-space: nowrap;
}
// ── Empty ───────────────────────────────────────────────────────────────────
.cta-empty {
padding: 28px 16px;
text-align: center;
color: #94a3b8;
&__icon {
display: block;
margin: 0 auto 6px;
font-size: 30px;
width: 30px;
height: 30px;
opacity: 0.6;
}
&__title {
margin: 0;
font-size: 12px;
font-weight: 700;
color: #64748b;
}
&__text {
margin: 3px 0 0;
font-size: 11px;
}
}
// ── Reject popup ────────────────────────────────────────────────────────────
.cta-dlg {
font-size: 12px;
&__head {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
border-bottom: 1px solid #e2e8f0;
background: #f8fafc;
}
&__ico {
flex: 0 0 32px;
height: 32px;
width: 32px;
border-radius: 9px;
background: #fef2f2;
color: #dc2626;
font-size: 18px;
line-height: 32px;
text-align: center;
}
&__titles {
flex: 1;
min-width: 0;
}
&__title {
font-size: 14px;
font-weight: 700;
color: #0f172a;
}
&__sub {
font-size: 11px;
color: #64748b;
}
&__x {
display: inline-flex;
align-items: center;
justify-content: center;
height: 28px;
width: 28px;
border: 0;
border-radius: 50%;
background: transparent;
color: #64748b;
cursor: pointer;
&:hover {
background: #e2e8f0;
}
mat-icon {
font-size: 18px;
height: 18px;
width: 18px;
}
}
&__body {
padding: 12px 14px;
max-height: 64vh;
overflow-y: auto;
textarea {
width: 100%;
padding: 7px 10px;
border: 1px solid #cbd5e1;
border-radius: 8px;
font: inherit;
font-size: 12px;
resize: vertical;
&:focus {
outline: 0;
border-color: #0f4f3c;
box-shadow: 0 0 0 3px rgb(15 79 60 / 8%);
}
}
}
&__foot {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 10px 14px;
border-top: 1px solid #e2e8f0;
background: #f8fafc;
}
}
.cta-label {
display: block;
margin-bottom: 4px;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
color: #94a3b8;
}
.cta-dlg__ico--ok {
background: #f0fdf4;
color: #1f7a4d;
}
.cta-total {
display: flex;
align-items: baseline;
justify-content: space-between;
margin-bottom: 10px;
padding: 8px 12px;
border-radius: 10px;
background: #ecfdf5;
border: 1px solid #a7f3d0;
color: #065f46;
font-size: 12px;
font-weight: 600;
strong {
font-size: 18px;
font-weight: 800;
font-variant-numeric: tabular-nums;
}
}
.cta-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 6px;
margin-bottom: 12px;
}
.cta-cell {
padding: 6px 10px;
border-radius: 8px;
background: #f8fafc;
border: 1px solid #eef2f7;
&--wide {
grid-column: 1 / -1;
}
}
.cta-value {
font-size: 12px;
font-weight: 600;
color: #0f172a;
word-break: break-word;
}
.cta-mini {
width: 100%;
margin-top: 4px;
border-collapse: collapse;
font-size: 12px;
th {
padding: 6px 10px;
border-bottom: 1px solid #e2e8f0;
background: #f8fafc;
font-size: 10px;
font-weight: 800;
letter-spacing: 0.05em;
text-align: left;
text-transform: uppercase;
color: #94a3b8;
}
td {
padding: 5px 10px;
border-bottom: 1px solid #eef2f7;
}
.num {
text-align: right;
font-variant-numeric: tabular-nums;
}
tfoot td {
border-top: 2px solid #e2e8f0;
border-bottom: 0;
font-weight: 800;
background: #f8fafc;
}
}
@@ -0,0 +1,190 @@
/**
* 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/.
*/
import { Component, OnInit, TemplateRef, ViewChild, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MatDialog, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
import { MatIconModule } from '@angular/material/icon';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { CashierService } from '../cashier.service';
/** A cash transfer request waiting for a checker, as the server keeps it (snake_case columns). */
interface CashTransferRequest {
id: number;
transfer_date: string | null;
from_party: string | null;
to_party: string | null;
note: string | null;
total_amount: number;
maker_name: string | null;
maker_at: string | null;
denominations: { denominationValue: number; count: number }[];
}
/**
* Checker side of Cash Transfer: approves or rejects an allocation of cash from the vault to a cashier. The cash
* leaves the vault only when it is approved, by someone other than the person who raised it.
*/
@Component({
selector: 'mifosx-cash-transfer-approvals',
templateUrl: './cash-transfer-approvals.component.html',
styleUrls: ['./cash-transfer-approvals.component.scss'],
standalone: true,
imports: [
CommonModule,
FormsModule,
MatDialogModule,
MatIconModule,
MatSnackBarModule
]
})
export class CashTransferApprovalsComponent implements OnInit {
private service = inject(CashierService);
private snackBar = inject(MatSnackBar);
private dialog = inject(MatDialog);
@ViewChild('rejectDialog') rejectDialog!: TemplateRef<unknown>;
@ViewChild('reviewDialog') reviewDialog!: TemplateRef<unknown>;
private dialogRef: MatDialogRef<unknown> | null = null;
private reviewRef: MatDialogRef<unknown> | null = null;
/** The request open in the review popup, with every note and coin it asks for. */
reviewing: CashTransferRequest | null = null;
all: CashTransferRequest[] = [];
loading = false;
loaded = false;
busyId: number | null = null;
searchTerm = '';
rejecting: CashTransferRequest | null = null;
rejectReason = '';
ngOnInit(): void {
this.load();
}
load(): void {
this.loading = true;
this.service
.getCashTransferRequests('PENDING')
.pipe(catchError(() => of([] as CashTransferRequest[])))
.subscribe((rows) => {
this.all = rows as CashTransferRequest[];
this.loading = false;
this.loaded = true;
});
}
get rows(): CashTransferRequest[] {
const term = this.searchTerm.trim().toLowerCase();
if (!term) return this.all;
return this.all.filter((r) => [
r.from_party,
r.to_party,
r.maker_name,
r.note
].some((v) => (v ?? '').toLowerCase().includes(term)));
}
/** One line per denomination asked for, largest first, with what each line adds up to. */
linesOf(r: CashTransferRequest): { value: number; count: number; amount: number }[] {
return [...(r.denominations ?? [])]
.filter((d) => d.count > 0)
.sort((a, b) => b.denominationValue - a.denominationValue)
.map((d) => ({ value: d.denominationValue, count: d.count, amount: d.denominationValue * d.count }));
}
/** How many notes and coins the request asks for in all. */
piecesOf(r: CashTransferRequest): number {
return this.linesOf(r).reduce((sum, l) => sum + l.count, 0);
}
review(r: CashTransferRequest): void {
this.reviewing = r;
this.reviewRef?.close();
this.reviewRef = this.dialog.open(this.reviewDialog, { width: '560px', maxWidth: '95vw', autoFocus: false });
this.reviewRef.afterClosed().subscribe(() => (this.reviewing = null));
}
closeReview(): void {
this.reviewRef?.close();
}
/** Rejecting from the review popup: the review closes, the reason popup opens for the same request. */
rejectFromReview(r: CashTransferRequest): void {
this.reviewRef?.close();
this.startReject(r);
}
formatDateTime(value: string | null): string {
if (!value) return '—';
const d = new Date(value);
return isNaN(d.getTime())
? value
: d.toLocaleString('en-IN', { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' });
}
approve(r: CashTransferRequest): void {
if (this.busyId) return;
this.busyId = r.id;
this.service.approveCashTransferRequest(r.id).subscribe({
next: (res: any) => {
this.busyId = null;
this.reviewRef?.close();
this.snackBar.open(
res?.glWarning ? `Approved (GL warning: ${res.glWarning})` : 'Approved — cash allocated to the cashier',
'Close',
{ duration: 6000 }
);
this.load();
},
error: (err) => {
this.busyId = null;
this.fail(err, 'Could not approve');
}
});
}
startReject(r: CashTransferRequest): void {
this.rejecting = r;
this.rejectReason = '';
this.dialogRef = this.dialog.open(this.rejectDialog, { width: '440px', maxWidth: '95vw', autoFocus: false });
this.dialogRef.afterClosed().subscribe(() => (this.rejecting = null));
}
cancelReject(): void {
this.dialogRef?.close();
}
confirmReject(): void {
if (!this.rejecting || !this.rejectReason.trim() || this.busyId) return;
const id = this.rejecting.id;
this.busyId = id;
this.service.rejectCashTransferRequest(id, this.rejectReason.trim()).subscribe({
next: () => {
this.busyId = null;
this.dialogRef?.close();
this.snackBar.open('Rejected', 'Close', { duration: 4000 });
this.load();
},
error: (err) => {
this.busyId = null;
this.fail(err, 'Could not reject');
}
});
}
private fail(err: any, fallback: string): void {
const msg = err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.defaultUserMessage ?? fallback;
this.snackBar.open(msg, 'Close', { duration: 8000 });
}
}
@@ -13,7 +13,15 @@
<h1 class="pg-title">Cash Transfer</h1>
<p class="pg-sub">Move cash denomination-wise from one party to another (e.g. Vault → Cashier for opening).</p>
</div>
<button type="button" class="pg-btn pg-btn--ghost" (click)="clear()"><mat-icon>undo</mat-icon> Clear</button>
<div class="ct-head-actions">
<button type="button" class="pg-btn pg-btn--ghost" (click)="openRequests()">
<mat-icon>pending_actions</mat-icon> My Requests
@if (pendingCount) {
<span class="ct-badge">{{ pendingCount }}</span>
}
</button>
<button type="button" class="pg-btn pg-btn--ghost" (click)="clear()"><mat-icon>undo</mat-icon> Clear</button>
</div>
</header>
<div class="pg-toolbar pg-toolbar--mat">
@@ -155,7 +163,56 @@
<div class="pg-total-bar">
Total to transfer: <strong>₹{{ total | number: '1.2-2' }}</strong>
<button type="button" class="pg-btn pg-btn--primary" [disabled]="!valid || saving" (click)="submit()">
<mat-icon>swap_horiz</mat-icon> Record Transfer
<mat-icon>send</mat-icon> Send for approval
</button>
</div>
</div>
<!-- Popup: this person's own cash transfer requests, and where each stands. The cash moves only once a manager approves. -->
<ng-template #requestsDialog>
<div class="ct-dlg">
<header class="ct-dlg__head">
<mat-icon class="ct-dlg__ico">pending_actions</mat-icon>
<div class="ct-dlg__titles">
<div class="ct-dlg__title">My transfer requests</div>
<div class="ct-dlg__sub">Cash leaves the vault only after a manager approves</div>
</div>
<button type="button" class="ct-dlg__x" aria-label="Close" (click)="closeRequests()">
<mat-icon>close</mat-icon>
</button>
</header>
<div class="ct-dlg__body">
@if (myRequests.length) {
<table class="pg-table">
<thead>
<tr>
<th>Date</th>
<th>To</th>
<th class="num">Amount</th>
<th>Status</th>
<th>Approved by</th>
</tr>
</thead>
<tbody>
@for (r of myRequests; track r.id) {
<tr>
<td>{{ r.transfer_date || '—' }}</td>
<td>{{ r.to_party || '—' }}</td>
<td class="num">₹{{ r.total_amount | number: '1.2-2' }}</td>
<td>
<span class="ct-status" [class]="'ct-status ct-status--' + r.status">{{ r.status }}</span>
@if (r.rejection_reason) {
<div class="ct-requests__note">{{ r.rejection_reason }}</div>
}
</td>
<td>{{ r.checker_name || '—' }}</td>
</tr>
}
</tbody>
</table>
} @else {
<div class="pg-empty">You have not raised any cash transfer request yet.</div>
}
</div>
</div>
</ng-template>
@@ -49,3 +49,131 @@
font-size: 12px;
color: #991b1b;
}
.ct-requests {
padding: 0;
&__title {
padding: 10px 14px;
border-bottom: 1px solid #e2e8f0;
font-size: 13px;
font-weight: 700;
color: #0d1b2a;
}
&__note {
margin-top: 2px;
font-size: 11px;
color: #64748b;
}
}
.ct-status {
display: inline-block;
padding: 2px 10px;
border-radius: 999px;
background: #e2e8f0;
font-size: 11px;
font-weight: 700;
&--PENDING {
background: #fef3c7;
color: #92400e;
}
&--APPROVED {
background: #d1fae5;
color: #065f46;
}
&--REJECTED {
background: #fee2e2;
color: #991b1b;
}
}
.ct-head-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.ct-badge {
min-width: 18px;
padding: 0 6px;
border-radius: 999px;
background: #f59e0b;
color: #fff;
font-size: 11px;
font-weight: 700;
text-align: center;
}
.ct-dlg {
font-size: 12px;
&__head {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
border-bottom: 1px solid #e2e8f0;
background: #f8fafc;
}
&__ico {
flex: 0 0 32px;
height: 32px;
width: 32px;
border-radius: 9px;
background: #f0fdf4;
color: #1f7a4d;
font-size: 18px;
line-height: 32px;
text-align: center;
}
&__titles {
flex: 1;
min-width: 0;
}
&__title {
font-size: 14px;
font-weight: 700;
color: #0f172a;
}
&__sub {
font-size: 11px;
color: #64748b;
}
&__x {
display: inline-flex;
align-items: center;
justify-content: center;
height: 28px;
width: 28px;
border: 0;
border-radius: 50%;
background: transparent;
color: #64748b;
cursor: pointer;
&:hover {
background: #e2e8f0;
}
mat-icon {
font-size: 18px;
height: 18px;
width: 18px;
}
}
&__body {
max-height: 60vh;
overflow-y: auto;
}
}
@@ -6,9 +6,10 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, OnInit, inject } from '@angular/core';
import { Component, OnInit, TemplateRef, ViewChild, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MatDialog, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
import { MatIconModule } from '@angular/material/icon';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { MatFormFieldModule } from '@angular/material/form-field';
@@ -39,6 +40,7 @@ interface Denom {
CommonModule,
FormsModule,
MatIconModule,
MatDialogModule,
MatSnackBarModule,
MatFormFieldModule,
MatSelectModule,
@@ -48,6 +50,10 @@ interface Denom {
export class CashTransferComponent implements OnInit {
private service = inject(CashierService);
private snackBar = inject(MatSnackBar);
private dialog = inject(MatDialog);
@ViewChild('requestsDialog') requestsDialog!: TemplateRef<unknown>;
private requestsRef: MatDialogRef<unknown> | null = null;
notes: Denom[] = [];
coins: Denom[] = [];
@@ -82,7 +88,34 @@ export class CashTransferComponent implements OnInit {
vaultHasBreakdown = true;
loadingVault = false;
/** The caller's own transfer requests, newest first, so a request can be followed to its approval. */
myRequests: any[] = [];
loadMyRequests(): void {
this.service
.getCashTransferRequests(undefined, true)
.pipe(catchError(() => of([] as any[])))
.subscribe((rows) => (this.myRequests = rows.slice(0, 20)));
}
/** How many of this person's requests are still waiting for a manager. */
get pendingCount(): number {
return this.myRequests.filter((r) => r.status === 'PENDING').length;
}
/** Opens the list of this person's requests, freshly read, so a request can be followed to its approval. */
openRequests(): void {
this.loadMyRequests();
this.requestsRef?.close();
this.requestsRef = this.dialog.open(this.requestsDialog, { width: '720px', maxWidth: '95vw', autoFocus: false });
}
closeRequests(): void {
this.requestsRef?.close();
}
ngOnInit(): void {
this.loadMyRequests();
const d = businessToday();
this.transferDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
this.service.getActiveDenominations().subscribe({
@@ -245,16 +278,11 @@ export class CashTransferComponent implements OnInit {
.filter((d) => this.countFor(d) > 0)
.map((d) => ({ denominationId: d.id, denominationValue: d.value, count: this.countFor(d) }))
};
this.service.createCashTransfer(payload).subscribe({
next: (res: any) => {
if (res?.glWarning) {
this.snackBar.open(`Transfer saved (GL warning: ${res.glWarning})`, 'Close', {
duration: 8000,
panelClass: ['snack-warn']
});
} else {
this.snackBar.open('Cash transfer recorded', 'Close', { duration: 3000 });
}
this.service.createCashTransferRequest(payload).subscribe({
next: () => {
// Nothing has moved yet: a manager approves the allocation first, and only then does the cash leave the vault.
this.snackBar.open('Cash transfer sent for manager approval', 'Close', { duration: 5000 });
this.loadMyRequests();
this.clear();
this.note = '';
this.fromParty = '';
+21
View File
@@ -23,6 +23,7 @@ import { DayBookComponent } from './day-book/day-book.component';
import { VoucherReportComponent } from './voucher-report/voucher-report.component';
import { BranchDayComponent } from './branch-day/branch-day.component';
import { VaultPositionComponent } from './vault-position/vault-position.component';
import { BranchCashPositionComponent } from './branch-cash-position/branch-cash-position.component';
import { AccountRegisterComponent } from './account-register/account-register.component';
import { ClosingCashReportComponent } from './closing-cash-report/closing-cash-report.component';
import { ExcessTransactionReportComponent } from './excess-transaction-report/excess-transaction-report.component';
@@ -82,6 +83,13 @@ const routes: Routes = [
),
data: { title: 'My Share Requests', breadcrumb: 'My Share Requests' }
},
{
// The same page as the cashier's, reached from the clerk dashboard: a clerk unpasses vouchers here.
path: 'voucher-correction',
loadComponent: () =>
import('./voucher-correction/voucher-correction.component').then((m) => m.VoucherCorrectionComponent),
data: { title: 'Voucher Correction', breadcrumb: 'Voucher Correction' }
},
{
path: 'closure-requests',
loadComponent: () =>
@@ -225,6 +233,13 @@ const routes: Routes = [
component: VaultPositionComponent,
data: { title: 'Vault Position', breadcrumb: 'Vault Position' }
},
{
// What the whole branch holds per denomination: the vault plus every cashier's drawer, so an
// exchange (which leaves the vault alone) is visible in the bank's total.
path: 'branch-position',
component: BranchCashPositionComponent,
data: { title: 'Branch Cash Position', breadcrumb: 'Branch Cash Position' }
},
{
// The same day book the clerk and cashier read, reached from where a principal officer
// signing the day off actually looks. It carries the Officer Verification Sheet — each
@@ -283,6 +298,12 @@ const routes: Routes = [
component: ReconciliationComponent,
data: { title: 'Reconciliation', breadcrumb: 'Reconciliation' }
},
{
path: 'voucher-correction',
loadComponent: () =>
import('./voucher-correction/voucher-correction.component').then((m) => m.VoucherCorrectionComponent),
data: { title: 'Voucher Correction', breadcrumb: 'Voucher Correction' }
},
{
path: 'cash-history',
component: CashHistoryComponent,
+29 -13
View File
@@ -32,9 +32,6 @@ export class CashierService {
getCashExchange(id: number): Observable<any> {
return this.http.get(`/cashexchange/${id}`);
}
reverseCashExchange(id: number, reason: string): Observable<any> {
return this.http.post(`/cashexchange/${id}/reverse`, { reason });
}
// ── Tellers / Cashiers ──
getTellers(officeId?: number): Observable<any[]> {
@@ -50,8 +47,21 @@ export class CashierService {
}
// ── Cash Transfer ──
createCashTransfer(data: any): Observable<any> {
return this.http.post('/cashtransfer', data);
/** Cash leaves the vault only when a different person approves, so the screen raises a request, not a transfer. */
createCashTransferRequest(data: any): Observable<any> {
return this.http.post('/cashtransfer/requests', data);
}
/** Requests by status (comma separated); mine narrows them to the ones the caller raised. */
getCashTransferRequests(status?: string, mine = false): Observable<any[]> {
let params = new HttpParams().set('mine', String(mine));
if (status) params = params.set('status', status);
return this.http.get<any[]>('/cashtransfer/requests', { params });
}
approveCashTransferRequest(id: number): Observable<any> {
return this.http.post(`/cashtransfer/requests/${id}/approve`, {});
}
rejectCashTransferRequest(id: number, reason: string): Observable<any> {
return this.http.post(`/cashtransfer/requests/${id}/reject`, { reason });
}
getCashTransfers(offset = 0, limit = 20): Observable<any> {
const params = new HttpParams().set('offset', offset).set('limit', limit);
@@ -60,9 +70,6 @@ export class CashierService {
getCashTransferById(id: number): Observable<any> {
return this.http.get(`/cashtransfer/${id}`);
}
reverseCashTransfer(id: number, reason: string): Observable<any> {
return this.http.post(`/cashtransfer/${id}/reverse`, { reason });
}
// ── Cash Settlement ──
createCashSettlement(data: any): Observable<any> {
@@ -75,9 +82,6 @@ export class CashierService {
getCashSettlementById(id: number): Observable<any> {
return this.http.get(`/cashsettlement/${id}`);
}
reverseCashSettlement(id: number, reason: string): Observable<any> {
return this.http.post(`/cashsettlement/${id}/reverse`, { reason });
}
// ── Inter-cashier Transfer ──
createInterCashierTransfer(data: any): Observable<any> {
@@ -90,8 +94,10 @@ export class CashierService {
getInterCashierById(id: number): Observable<any> {
return this.http.get(`/cashintercashier/${id}`);
}
reverseInterCashierTransfer(id: number, reason: string): Observable<any> {
return this.http.post(`/cashintercashier/${id}/reverse`, { reason });
/** One cashier's details, today's vault allocation and denomination-wise cash, for either side of a transfer. */
getInterCashierCashierSummary(cashierId: number, date: string): Observable<any> {
const params = new HttpParams().set('cashierId', cashierId).set('date', date);
return this.http.get<any>('/cashintercashier/cashier-summary', { params });
}
// ── Reconciliation ──
@@ -318,6 +324,16 @@ export class CashierService {
if (asOfDate) params = params.set('asOfDate', asOfDate);
return this.http.get('/cashreports/vault-position', { params });
}
/**
* Denomination-wise cash the whole branch holds: its vault(s) plus every cashier's drawer, with the day's
* exchange movement beside it. Not for a cashier -- the server refuses it.
*/
branchCashPosition(asOfDate?: string, officeId?: number): Observable<any> {
let params = new HttpParams();
if (asOfDate) params = params.set('asOfDate', asOfDate);
if (officeId) params = params.set('officeId', String(officeId));
return this.http.get('/cashreports/branch-position', { params });
}
excessTransactionReport(threshold: number, fromDate?: string, toDate?: string): Observable<any[]> {
let params = new HttpParams().set('threshold', String(threshold));
if (fromDate) params = params.set('fromDate', fromDate);
@@ -202,6 +202,7 @@
<th class="num">Amount</th>
<th>Payment</th>
<th>Status</th>
<th>Correction</th>
<th>By</th>
<th>Approved By</th>
<th>Time</th>
@@ -231,6 +232,18 @@
<td>
<span [class]="statusClass(r.status)">{{ r.status }}</span>
</td>
<td>
<!-- Where the voucher stands after a correction: unpassed, revoked, closed or corrected. -->
<mifosx-voucher-status
[status]="r.voucherStatus"
[revokedBy]="r.revokedByVoucherNo"
[replaces]="r.replacesVoucherNo"
[note]="r.correctionNote"
></mifosx-voucher-status>
@if (!r.voucherStatus || r.voucherStatus === 'ACTIVE') {
<span class="db-muted">—</span>
}
</td>
<td>{{ r.handledBy ?? '—' }}</td>
<td>
{{ r.approvedBy ?? '—' }}
@@ -256,7 +269,7 @@
—
}
</td>
<td colspan="5"></td>
<td colspan="6"></td>
</tr>
</tfoot>
</table>
@@ -14,6 +14,7 @@ import { Router } from '@angular/router';
import { catchError, of } from 'rxjs';
import { CashierService } from '../cashier.service';
import { businessToday } from 'app/core/utils/business-today';
import { VoucherStatusComponent, VoucherStatus } from '../voucher-status/voucher-status.component';
/**
* Which part of the day's work a row came from. Set by the server, one per source register.
@@ -26,6 +27,11 @@ type DayBookSection = 'CASHIER' | 'CLERK' | 'SYSTEM';
interface DayBookRow {
voucherNo: string | null;
/** Where the voucher stands after a correction: active, revoked, under correction, or corrected. */
voucherStatus?: VoucherStatus;
revokedByVoucherNo?: string | null;
replacesVoucherNo?: string | null;
correctionNote?: string | null;
section: DayBookSection;
side: 'CR' | 'DR';
kind: string;
@@ -58,7 +64,8 @@ interface DayBookRow {
imports: [
CommonModule,
FormsModule,
MatIconModule
MatIconModule,
VoucherStatusComponent
]
})
export class DayBookComponent implements OnInit {
@@ -11,41 +11,29 @@
<div>
<div class="pg-eyebrow">CASHIER · INTER-CASHIER TRANSFER</div>
<h1 class="pg-title">Inter-Cashier Transfer</h1>
<p class="pg-sub">Move denomination-wise cash between two cashiers within the same teller.</p>
<p class="pg-sub">Hand over denomination-wise cash from your drawer to another cashier at your counter.</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">
@if (tellers.length === 1) {
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Teller</mat-label>
<input matInput type="text" [value]="tellers[0].name" readonly />
</mat-form-field>
} @else {
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Teller</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>
}
@if (ownCashierMissing) {
<div class="ict-alert">
<mat-icon>person_off</mat-icon>
Your login is not linked to a cashier. An inter-cashier transfer is made by the cashier handing the cash over, so
log in as that cashier.
</div>
}
<div class="pg-toolbar pg-toolbar--mat">
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>From (Cashier)</mat-label>
<mat-select [value]="fromCashierId" (selectionChange)="onFromSelect($event.value)" [disabled]="!selectedTellerId">
@for (c of availableFrom; track c.id) {
<mat-option [value]="c.id">{{ c.label }}</mat-option>
}
@if (selectedTellerId && allCashiers.length === 0 && !loadingCashiers) {
<mat-option [value]="null" disabled>No cashiers</mat-option>
}
</mat-select>
<mat-label>Teller</mat-label>
<input matInput type="text" [value]="tellerName || '—'" readonly />
</mat-form-field>
<!-- The sender is always the cashier who is logged in; nothing to pick. -->
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>From (You)</mat-label>
<input matInput type="text" [value]="fromParty || '—'" readonly />
</mat-form-field>
<mat-form-field appearance="outline" subscriptSizing="dynamic">
@@ -54,7 +42,7 @@
@for (c of availableTo; track c.id) {
<mat-option [value]="c.id">{{ c.label }}</mat-option>
}
@if (fromCashierId && availableTo.length === 0) {
@if (fromCashierId && !loadingCashiers && availableTo.length === 0) {
<mat-option [value]="null" disabled>No other cashiers</mat-option>
}
</mat-select>
@@ -62,7 +50,7 @@
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Date</mat-label>
<input matInput type="date" [value]="transferDate" (change)="transferDate = $any($event.target).value" />
<input matInput type="date" [value]="transferDate" (change)="onDateChange($any($event.target).value)" />
</mat-form-field>
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="pg-note-field">
@@ -71,26 +59,102 @@
</mat-form-field>
</div>
<!-- Both sides of the transfer: who they are, whether the vault has allocated them cash today,
and what they hold now. -->
@if (fromCashierId || toCashierId) {
<div class="ict-parties">
<ng-container
*ngTemplateOutlet="
partyCard;
context: { title: 'From (Sending Cashier)', s: fromSummary, loading: loadingFrom, picked: !!fromCashierId }
"
></ng-container>
<mat-icon class="ict-parties__arrow">arrow_forward</mat-icon>
<ng-container
*ngTemplateOutlet="
partyCard;
context: { title: 'To (Receiving Cashier)', s: toSummary, loading: loadingTo, picked: !!toCashierId }
"
></ng-container>
</div>
}
<ng-template #partyCard let-title="title" let-s="s" let-loading="loading" let-picked="picked">
<div class="ict-party" [class.ict-party--blocked]="s && !s.cashAllocated">
<div class="ict-party__title">{{ title }}</div>
@if (!picked) {
<div class="ict-party__empty">Select a cashier</div>
} @else if (loading) {
<div class="ict-party__empty">Loading…</div>
} @else if (!s) {
<div class="ict-party__empty">Could not load this cashier's details.</div>
} @else {
<div class="ict-party__name">{{ s.cashierName || '—' }}</div>
<div class="ict-party__meta">
<span><mat-icon>point_of_sale</mat-icon> {{ s.tellerName || '—' }}</span>
<span><mat-icon>account_balance</mat-icon> {{ s.officeName || '—' }}</span>
</div>
<div class="ict-party__figures">
<div>
<div class="ict-party__label">Allocated today</div>
<div class="ict-party__value">₹{{ s.allocatedAmount | number: '1.2-2' }}</div>
</div>
<div>
<div class="ict-party__label">Cash in hand</div>
<div class="ict-party__value">₹{{ s.totalAmount | number: '1.2-2' }}</div>
</div>
</div>
@if (!s.cashAllocated) {
<div class="ict-party__warn">
<mat-icon>block</mat-icon> No cash allocated from the vault for this date — inter-cashier transfer is not
allowed.
</div>
}
}
</div>
</ng-template>
@if (fromSummary && toSummary && !bothAllocated) {
<div class="ict-alert">
<mat-icon>block</mat-icon>
Both cashiers must first be allocated cash from the vault (Cash Transfer) for {{ transferDate }} before an
inter-cashier transfer.
</div>
}
@if (anyExceedsHeld) {
<div class="ict-alert">
<mat-icon>error_outline</mat-icon>
The sending cashier does not hold enough of the highlighted denominations.
</div>
}
<div class="pg-card">
<table class="pg-table">
<thead>
<tr>
<th>Denomination</th>
<th class="num">From holds</th>
<th style="text-align: center">Count</th>
<th class="num">Amount</th>
<th class="num">To holds</th>
</tr>
</thead>
<tbody>
@if (notes.length) {
<tr class="pg-section">
<td colspan="3">Bank Notes</td>
<td colspan="5">Bank Notes</td>
</tr>
}
@for (d of notes; track d.id) {
<tr>
<tr [class.ict-row--over]="exceedsHeld(d)">
<td>
<span class="pg-chip">{{ d.label }}</span>
</td>
<td class="num" [class.ict-held--over]="exceedsHeld(d)">
<ng-container
*ngTemplateOutlet="heldCell; context: { n: heldBy(fromSummary, d), delta: -countFor(d) }"
></ng-container>
</td>
<td style="display: flex; justify-content: center">
<div class="pg-step">
<button type="button" (click)="bump(d, -1)" [disabled]="countFor(d) === 0">−</button>
@@ -99,18 +163,28 @@
</div>
</td>
<td class="num">₹{{ subtotal(d) | number: '1.2-2' }}</td>
<td class="num">
<ng-container
*ngTemplateOutlet="heldCell; context: { n: heldBy(toSummary, d), delta: countFor(d) }"
></ng-container>
</td>
</tr>
}
@if (coins.length) {
<tr class="pg-section">
<td colspan="3">Coins</td>
<td colspan="5">Coins</td>
</tr>
}
@for (d of coins; track d.id) {
<tr>
<tr [class.ict-row--over]="exceedsHeld(d)">
<td>
<span class="pg-chip pg-chip--coin">{{ d.label }}</span>
</td>
<td class="num" [class.ict-held--over]="exceedsHeld(d)">
<ng-container
*ngTemplateOutlet="heldCell; context: { n: heldBy(fromSummary, d), delta: -countFor(d) }"
></ng-container>
</td>
<td style="display: flex; justify-content: center">
<div class="pg-step">
<button type="button" (click)="bump(d, -1)" [disabled]="countFor(d) === 0">−</button>
@@ -119,17 +193,34 @@
</div>
</td>
<td class="num">₹{{ subtotal(d) | number: '1.2-2' }}</td>
<td class="num">
<ng-container
*ngTemplateOutlet="heldCell; context: { n: heldBy(toSummary, d), delta: countFor(d) }"
></ng-container>
</td>
</tr>
}
@if (denoms.length === 0) {
<tr>
<td colspan="3" class="pg-empty">No active denominations. Add some in Cash Denomination.</td>
<td colspan="5" class="pg-empty">No active denominations. Add some in Cash Denomination.</td>
</tr>
}
</tbody>
</table>
</div>
<!-- A held count, with what it becomes once this transfer is recorded. -->
<ng-template #heldCell let-n="n" let-delta="delta">
@if (n === null) {
—
} @else {
{{ n }}
@if (delta !== 0) {
<span class="ict-after">→ {{ n + delta }}</span>
}
}
</ng-template>
<div class="pg-total-bar">
Total to transfer: <strong>₹{{ total | number: '1.2-2' }}</strong>
<button type="button" class="pg-btn pg-btn--primary" [disabled]="!valid || saving" (click)="submit()">
@@ -0,0 +1,150 @@
/**
* 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/.
*/
.ict-parties {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: stretch;
gap: 12px;
margin-bottom: 12px;
&__arrow {
align-self: center;
color: #64748b;
}
@media (width <= 760px) {
grid-template-columns: 1fr;
&__arrow {
justify-self: center;
transform: rotate(90deg);
}
}
}
.ict-party {
padding: 12px 14px;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 10px;
&--blocked {
background: #fef2f2;
border-color: #fecaca;
}
&__title {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
color: #64748b;
}
&__empty {
margin-top: 8px;
font-size: 13px;
color: #94a3b8;
}
&__name {
margin-top: 4px;
font-size: 16px;
font-weight: 700;
color: #0f172a;
}
&__meta {
display: flex;
flex-wrap: wrap;
gap: 14px;
margin-top: 4px;
font-size: 12px;
color: #475569;
span {
display: inline-flex;
align-items: center;
gap: 4px;
}
mat-icon {
font-size: 14px;
width: 14px;
height: 14px;
}
}
&__figures {
display: flex;
gap: 24px;
margin-top: 10px;
}
&__label {
font-size: 11px;
color: #64748b;
}
&__value {
font-size: 15px;
font-weight: 700;
color: #0f172a;
}
&__warn {
display: flex;
align-items: center;
gap: 6px;
margin-top: 10px;
font-size: 12px;
font-weight: 600;
color: #991b1b;
mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
}
}
}
.ict-alert {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
padding: 8px 14px;
background: #fef2f2;
border: 1px solid #fecaca;
border-radius: 8px;
font-size: 12px;
color: #991b1b;
mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
}
}
.ict-row--over {
background: #fef2f2;
}
.ict-held--over {
color: #b91c1c;
font-weight: 700;
}
.ict-after {
margin-left: 4px;
font-size: 11px;
color: #64748b;
}
@@ -29,12 +29,27 @@ interface Cashier {
id: number;
label: string;
}
/** One side of the transfer, from GET /cashintercashier/cashier-summary. */
interface CashierSummary {
cashierId: number;
cashierName: string;
tellerName: string;
officeName: string;
cashAllocated: boolean;
allocatedAmount: number;
totalAmount: number;
/** Count held per denomination id. */
held: Record<number, number>;
}
/** Move cash denomination-wise between two cashiers under the same teller. */
@Component({
selector: 'mifosx-inter-cashier-transfer',
templateUrl: './inter-cashier-transfer.component.html',
styleUrls: ['../cashier-shared.scss'],
styleUrls: [
'../cashier-shared.scss',
'./inter-cashier-transfer.component.scss'
],
standalone: true,
imports: [
CommonModule,
@@ -57,7 +72,12 @@ export class InterCashierTransferComponent implements OnInit {
note = '';
saving = false;
tellers: { id: number; name: string }[] = [];
/**
* The sending cashier is always the one logged in: cash is handed over from their own drawer, so there is
* no one else to pick on that side. Only the receiving cashier is chosen.
*/
ownCashierMissing = false;
tellerName = '';
allCashiers: Cashier[] = [];
selectedTellerId: number | null = null;
fromCashierId: number | null = null;
@@ -66,15 +86,19 @@ export class InterCashierTransferComponent implements OnInit {
toParty = '';
loadingCashiers = false;
/** Details and denomination-wise cash of each side, so both are seen before the transfer is recorded. */
fromSummary: CashierSummary | null = null;
toSummary: CashierSummary | null = null;
loadingFrom = false;
loadingTo = false;
get denoms(): Denom[] {
return [
...this.notes,
...this.coins
];
}
get availableFrom(): Cashier[] {
return this.allCashiers;
}
/** Everyone under the same teller except the sender. */
get availableTo(): Cashier[] {
return this.allCashiers.filter((c) => c.id !== this.fromCashierId);
}
@@ -98,25 +122,20 @@ export class InterCashierTransferComponent implements OnInit {
this.coins = [];
}
});
this.service
.getTellers()
.pipe(catchError(() => of([] as any[])))
.subscribe((list) => {
this.tellers = (list || []).map((t: any) => ({ id: t.id, name: t.name }));
if (this.tellers.length === 1) {
this.onTellerSelect(this.tellers[0].id);
}
});
// The sender is the cashier behind this login; their counter then gives the receivers to pick from.
this.service.getCurrentCashierInfo().subscribe((info) => {
if (!info) {
this.ownCashierMissing = true;
return;
}
this.fromCashierId = info.cashierId;
this.fromParty = info.cashierName;
this.loadSummary('from');
});
}
onTellerSelect(tellerId: number): void {
this.selectedTellerId = tellerId;
this.fromCashierId = null;
this.toCashierId = null;
this.fromParty = '';
this.toParty = '';
this.allCashiers = [];
if (!tellerId) return;
/** The other cashiers at this counter, one of whom receives the cash. */
private loadReceivers(tellerId: number): void {
this.loadingCashiers = true;
this.service
.getCashiersForTeller(tellerId)
@@ -127,20 +146,90 @@ export class InterCashierTransferComponent implements OnInit {
});
}
onFromSelect(cashierId: number): void {
this.fromCashierId = cashierId;
const c = this.allCashiers.find((x) => x.id === cashierId);
this.fromParty = c ? c.label : '';
if (this.toCashierId === cashierId) {
this.toCashierId = null;
this.toParty = '';
}
}
onToSelect(cashierId: number): void {
this.toCashierId = cashierId;
const c = this.allCashiers.find((x) => x.id === cashierId);
this.toParty = c ? c.label : '';
this.loadSummary('to');
}
onDateChange(value: string): void {
this.transferDate = value;
this.loadSummary('from');
this.loadSummary('to');
}
/** Fetch one side's details, vault allocation and denomination-wise cash for the transfer date. */
private loadSummary(side: 'from' | 'to'): void {
const cashierId = side === 'from' ? this.fromCashierId : this.toCashierId;
if (side === 'from') {
this.fromSummary = null;
} else {
this.toSummary = null;
}
if (!cashierId || !this.transferDate) return;
this.setLoading(side, true);
this.service
.getInterCashierCashierSummary(cashierId, this.transferDate)
.pipe(catchError(() => of(null)))
.subscribe((r: any) => {
this.setLoading(side, false);
// A late answer for a cashier no longer selected on that side is dropped.
const current = side === 'from' ? this.fromCashierId : this.toCashierId;
if (!r || current !== cashierId) return;
// The sender's summary also tells which counter they are at, hence who they can hand cash to.
if (side === 'from' && r.tellerId && this.selectedTellerId !== Number(r.tellerId)) {
this.selectedTellerId = Number(r.tellerId);
this.tellerName = r.tellerName || '';
this.loadReceivers(this.selectedTellerId);
}
const held: Record<number, number> = {};
for (const d of r.denominations || []) {
held[d.denominationId] = (held[d.denominationId] || 0) + Number(d.count || 0);
}
const summary: CashierSummary = {
cashierId: r.cashierId,
cashierName: r.cashierName,
tellerName: r.tellerName,
officeName: r.officeName,
cashAllocated: !!r.cashAllocated,
allocatedAmount: Number(r.allocatedAmount || 0),
totalAmount: Number(r.totalAmount || 0),
held
};
if (side === 'from') {
this.fromSummary = summary;
} else {
this.toSummary = summary;
}
});
}
private setLoading(side: 'from' | 'to', loading: boolean): void {
if (side === 'from') {
this.loadingFrom = loading;
} else {
this.loadingTo = loading;
}
}
/** Count of the denomination the cashier holds, or null while their figures are not loaded. */
heldBy(summary: CashierSummary | null, d: Denom): number | null {
return summary ? summary.held[d.id] || 0 : null;
}
/** The sending cashier is asked to hand on more of this denomination than they hold. */
exceedsHeld(d: Denom): boolean {
return !!this.fromSummary && this.countFor(d) > (this.fromSummary.held[d.id] || 0);
}
get anyExceedsHeld(): boolean {
return this.denoms.some((d) => this.exceedsHeld(d));
}
/** Both sides must have been allocated cash from the vault for the day before they can transfer. */
get bothAllocated(): boolean {
return !!this.fromSummary?.cashAllocated && !!this.toSummary?.cashAllocated;
}
countFor(d: Denom): number {
@@ -160,7 +249,14 @@ export class InterCashierTransferComponent implements OnInit {
return this.denoms.reduce((s, d) => s + this.subtotal(d), 0);
}
get valid(): boolean {
return this.total > 0 && !!this.selectedTellerId && !!this.fromCashierId && !!this.toCashierId;
return (
this.total > 0 &&
!!this.selectedTellerId &&
!!this.fromCashierId &&
!!this.toCashierId &&
this.bothAllocated &&
!this.anyExceedsHeld
);
}
clear(): void {
@@ -187,10 +283,12 @@ export class InterCashierTransferComponent implements OnInit {
this.snackBar.open('Inter-cashier transfer recorded', 'Close', { duration: 3000 });
this.clear();
this.note = '';
this.fromCashierId = null;
// The sender stays the logged-in cashier; the receiver is picked afresh, and the sender's holdings
// are read again now that part of them has gone.
this.toCashierId = null;
this.fromParty = '';
this.toParty = '';
this.toSummary = null;
this.loadSummary('from');
this.saving = false;
},
error: () => {
@@ -146,6 +146,7 @@
<th class="num">Amount</th>
<th>My part</th>
<th>Status</th>
<th>Correction</th>
<th>Time</th>
</tr>
</thead>
@@ -203,6 +204,18 @@
{{ statusLabel(r.verificationStatus) }}
</span>
</td>
<td>
<!-- Where the voucher stands after a correction: unpassed, revoked, closed or corrected. -->
<mifosx-voucher-status
[status]="r.voucherStatus"
[revokedBy]="r.revokedByVoucherNo"
[replaces]="r.replacesVoucherNo"
[note]="r.correctionNote"
></mifosx-voucher-status>
@if (!r.voucherStatus || r.voucherStatus === 'ACTIVE') {
<span class="mw-acct">—</span>
}
</td>
<td class="mw-time">{{ r.createdAt | date: 'HH:mm' }}</td>
</tr>
}
+8 -1
View File
@@ -15,6 +15,7 @@ import { catchError, finalize, of } from 'rxjs';
import { CashierService } from '../cashier.service';
import { VoucherDetailDialogComponent } from './voucher-detail-dialog.component';
import { businessToday } from 'app/core/utils/business-today';
import { VoucherStatusComponent, VoucherStatus } from '../voucher-status/voucher-status.component';
/** What the signed-in user's part in a voucher was. */
export type MyRole = 'RAISED' | 'VERIFIED' | 'RAISED_AND_VERIFIED';
@@ -24,6 +25,11 @@ export type VoucherSide = 'CR' | 'DR' | 'JV';
interface MyWorkRow {
voucherNo: string;
/** Where the voucher stands after a correction: active, revoked, under correction, or corrected. */
voucherStatus?: VoucherStatus;
revokedByVoucherNo?: string | null;
replacesVoucherNo?: string | null;
correctionNote?: string | null;
side: VoucherSide | null;
voucherType: string | null;
amount: number | null;
@@ -76,7 +82,8 @@ interface MyWorkReport {
CommonModule,
FormsModule,
MatIconModule,
MatDialogModule
MatDialogModule,
VoucherStatusComponent
]
})
export class MyWorkComponent implements OnInit {
@@ -11,6 +11,7 @@ import { CommonModule } from '@angular/common';
import { MatDialogModule, MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { VoucherStatusComponent, VoucherStatus } from '../voucher-status/voucher-status.component';
export interface VoucherDetailDialogData {
/** Already-loaded detail from CashierService.voucherDetail() — the caller fetches, this dialog
@@ -18,6 +19,10 @@ export interface VoucherDetailDialogData {
* duplicated inside every dialog that could ever show a voucher. */
detail: {
voucherNo: string;
voucherStatus?: VoucherStatus;
revokedByVoucherNo?: string | null;
replacesVoucherNo?: string | null;
correctionNote?: string | null;
voucherType: string | null;
amount: number | null;
branchName: string | null;
@@ -58,7 +63,8 @@ export interface VoucherDetailDialogData {
CommonModule,
MatDialogModule,
MatButtonModule,
MatIconModule
MatIconModule,
VoucherStatusComponent
],
template: `
<div class="vd-shell">
@@ -66,7 +72,15 @@ export interface VoucherDetailDialogData {
<div class="vd-header__title">
<mat-icon class="vd-icon">receipt_long</mat-icon>
<div>
<h2>{{ detail.voucherNo }}</h2>
<h2>
{{ detail.voucherNo }}
<mifosx-voucher-status
[status]="detail.voucherStatus"
[revokedBy]="detail.revokedByVoucherNo"
[replaces]="detail.replacesVoucherNo"
[note]="detail.correctionNote"
></mifosx-voucher-status>
</h2>
<p>{{ detail.voucherType || 'Voucher' }} &middot; {{ detail.branchName || 'Head Office' }}</p>
</div>
</div>
@@ -19,34 +19,21 @@
<!-- ── Entry Tab ── -->
<mat-tab label="New Reconciliation">
<div class="pg-tab-body">
@if (ownCashierMissing) {
<div class="pg-empty">
Your login is not linked to a cashier, so there is no cash drawer to reconcile. Ask an administrator to link
your user to a cashier.
</div>
}
<div class="pg-toolbar pg-toolbar--mat">
@if (tellers.length === 1) {
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Teller</mat-label>
<input matInput type="text" [value]="tellers[0].name" readonly />
</mat-form-field>
} @else {
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Teller</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>
}
</mat-select>
</mat-form-field>
}
<!-- A reconciliation is the cashier's own count of their own drawer: nothing to pick. -->
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Cashier</mat-label>
<mat-select
[value]="selectedCashierId"
(selectionChange)="onCashierSelect($event.value)"
[disabled]="!selectedTellerId"
>
@for (c of cashiers; track c.id) {
<mat-option [value]="c.id">{{ c.label }}</mat-option>
}
</mat-select>
<mat-label>Teller</mat-label>
<input matInput type="text" [value]="ownTellerName || '—'" readonly />
</mat-form-field>
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Cashier (You)</mat-label>
<input matInput type="text" [value]="cashierName || '—'" readonly />
</mat-form-field>
<mat-form-field appearance="outline" subscriptSizing="dynamic">
@@ -12,7 +12,6 @@ import { FormsModule } from '@angular/forms';
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 { MatTabsModule } from '@angular/material/tabs';
import { forkJoin, of } from 'rxjs';
@@ -58,7 +57,6 @@ interface ReconRecord {
MatIconModule,
MatSnackBarModule,
MatFormFieldModule,
MatSelectModule,
MatInputModule,
MatTabsModule
]
@@ -67,9 +65,14 @@ export class ReconciliationComponent implements OnInit {
private service = inject(CashierService);
private snackBar = inject(MatSnackBar);
/**
* A reconciliation is the cashier's own count of their own drawer, so nobody else makes one and there is
* no cashier to pick: the screen is fixed to the logged-in cashier. The server refuses anything else.
*/
ownCashierMissing = false;
ownTellerName = '';
// ── Entry form ──
tellers: { id: number; name: string }[] = [];
cashiers: { id: number; label: string }[] = [];
selectedTellerId: number | null = null;
selectedCashierId: number | null = null;
cashierName = '';
@@ -88,42 +91,28 @@ export class ReconciliationComponent implements OnInit {
ngOnInit(): void {
const d = businessToday();
this.reconDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
this.service
.getTellers()
.pipe(catchError(() => of([] as any[])))
.subscribe((list) => {
this.tellers = (list || []).map((t: any) => ({ id: t.id, name: t.name }));
if (this.tellers.length === 1) {
this.onTellerSelect(this.tellers[0].id);
}
});
this.lockToOwnCashier();
this.loadHistory();
}
onTellerSelect(tellerId: number): void {
this.selectedTellerId = tellerId;
this.selectedCashierId = null;
this.cashierName = '';
this.cashiers = [];
this.rows = [];
if (!tellerId) return;
this.service
.getCashiersForTeller(tellerId)
.pipe(catchError(() => of({ cashiers: [] })))
.subscribe((r: any) => {
this.cashiers = (r?.cashiers || []).map((c: any) => ({ id: c.id, label: c.staffName }));
if (this.cashiers.length === 1) {
this.onCashierSelect(this.cashiers[0].id);
}
});
}
onCashierSelect(cashierId: number): void {
this.selectedCashierId = cashierId;
const c = this.cashiers.find((x) => x.id === cashierId);
this.cashierName = c ? c.label : '';
this.rows = [];
this.fetchExpected();
/** Take the logged-in cashier as the one being reconciled, with their counter for the record. */
private lockToOwnCashier(): void {
this.service.getCurrentCashierInfo().subscribe((info) => {
if (!info) {
this.ownCashierMissing = true;
return;
}
this.selectedCashierId = info.cashierId;
this.cashierName = info.cashierName;
this.service
.getInterCashierCashierSummary(info.cashierId, this.reconDate)
.pipe(catchError(() => of(null)))
.subscribe((r: any) => {
this.selectedTellerId = r?.tellerId ?? null;
this.ownTellerName = r?.tellerName || '';
});
this.fetchExpected();
});
}
fetchExpected(): void {
@@ -117,6 +117,8 @@
<select
class="ws-f__ctl"
[(ngModel)]="row.typ"
[disabled]="isTypLocked(row)"
[title]="isTypLocked(row) ? 'Token withdrawal - TYPE is fixed to Payment' : ''"
(click)="$event.stopPropagation()"
(change)="onTypChange(row)"
>
@@ -127,11 +129,16 @@
</div>
<div class="ws-f ws-f--branch">
<label class="ws-f__lbl" title="Branch">BRANCH</label>
<select class="ws-f__ctl" [(ngModel)]="row.br" (click)="$event.stopPropagation()">
<option [ngValue]="1">001 · Main</option>
<option [ngValue]="2">002 · M.G. Road</option>
<option [ngValue]="14">014 · Station Rd</option>
<option [ngValue]="27">027 · Industrial</option>
<select
class="ws-f__ctl"
[(ngModel)]="row.br"
[disabled]="branches.length <= 1"
[title]="branches.length <= 1 ? 'Your branch' : 'Branch'"
(click)="$event.stopPropagation()"
>
@for (b of branches; track b.id) {
<option [ngValue]="b.id">{{ b.label }}</option>
}
</select>
</div>
@if (row.typ === 'DD') {
@@ -177,13 +184,7 @@
class="ws-f__ctl"
[(ngModel)]="row.acTyp"
(click)="$event.stopPropagation()"
(change)="
row.acTyp === 'GL' &&
row.typ !== 'Disbursement' &&
row.typ !== 'Fund Transfer' &&
row.typ !== 'Enter Bank Transfer' &&
(row.dbCr = 'Cr')
"
(change)="isGlCreditOnly(row) && (row.dbCr = 'Cr')"
>
@for (a of acTypOptionsFor(row); track a) {
<option [value]="a">{{ a }}</option>
@@ -266,12 +267,7 @@
type="button"
class="ws-seg__btn ws-seg__btn--dr"
[class.ws-seg__btn--active-dr]="row.dbCr === 'Dr'"
[disabled]="
row.acTyp === 'GL' &&
row.typ !== 'Disbursement' &&
row.typ !== 'Fund Transfer' &&
row.typ !== 'Enter Bank Transfer'
"
[disabled]="isGlCreditOnly(row)"
(click)="row.dbCr = 'Dr'"
>
DR
@@ -280,6 +276,7 @@
type="button"
class="ws-seg__btn ws-seg__btn--cr"
[class.ws-seg__btn--active-cr]="row.dbCr === 'Cr'"
[disabled]="row.typ === 'Withdrawal Slip'"
(click)="row.dbCr = 'Cr'"
>
CR
@@ -711,70 +708,81 @@
<circle cx="12" cy="8" r="4" />
<path d="M4 20c0-4 3.6-7 8-7s8 3 8 7" />
</svg>
Personal Information
{{ isGlAccount ? 'GL Account Information' : 'Personal Information' }}
</div>
<div class="te-panel__body">
<div class="te-field">
<span class="te-label">Branch</span><span class="te-value">{{ officeName }}</span>
@if (isGlAccount) {
<div class="te-panel__body">
@for (f of glInfo; track f.label) {
<div class="te-field">
<span class="te-label">{{ f.label }}</span
><span class="te-value te-value-wrap" [class.te-mono]="f.mono">{{ f.value }}</span>
</div>
}
</div>
<div class="te-field">
<span class="te-label">A/c No.</span
><span class="te-value te-mono">{{ accountDetails.accountNo }}</span>
</div>
<div class="te-field">
<span class="te-label">Name</span><span class="te-value te-value-name">{{ clientName }}</span>
</div>
<div class="te-field">
<span class="te-label">S/D/W/H/O</span><span class="te-value">{{ guardianName }}</span>
</div>
<div class="te-field">
<span class="te-label">Address</span><span class="te-value te-value-wrap">{{ clientAddress }}</span>
</div>
<div class="te-field">
<span class="te-label">Email</span><span class="te-value">{{ clientEmail }}</span>
</div>
<div class="te-field-row">
} @else {
<div class="te-panel__body">
<div class="te-field">
<span class="te-label">Last Txn</span><span class="te-value">{{ lastTransactionDate }}</span>
<span class="te-label">Branch</span><span class="te-value">{{ officeName }}</span>
</div>
<div class="te-field">
<span class="te-label">Type</span
><span class="te-value">{{ accountDetails.depositType?.value ?? selectedRow?.acTyp ?? '—' }}</span>
<span class="te-label">A/c No.</span
><span class="te-value te-mono">{{ accountDetails.accountNo }}</span>
</div>
<div class="te-field">
<span class="te-label">Name</span><span class="te-value te-value-name">{{ clientName }}</span>
</div>
<div class="te-field">
<span class="te-label">S/D/W/H/O</span><span class="te-value">{{ guardianName }}</span>
</div>
<div class="te-field">
<span class="te-label">Address</span><span class="te-value te-value-wrap">{{ clientAddress }}</span>
</div>
<div class="te-field">
<span class="te-label">Email</span><span class="te-value">{{ clientEmail }}</span>
</div>
<div class="te-field-row">
<div class="te-field">
<span class="te-label">Last Txn</span><span class="te-value">{{ lastTransactionDate }}</span>
</div>
<div class="te-field">
<span class="te-label">Type</span
><span class="te-value">{{ accountDetails.depositType?.value ?? selectedRow?.acTyp ?? '—' }}</span>
</div>
</div>
<div class="te-field-row">
<div class="te-field">
<span class="te-label">Opening Dt</span><span class="te-value">{{ openingDate }}</span>
</div>
<div class="te-field">
<span class="te-label">Status</span
><span class="te-value">{{ accountDetails.status?.value ?? '—' }}</span>
</div>
</div>
<div class="te-field">
<span class="te-label">CIF No.</span><span class="te-value te-mono">{{ cifNumber }}</span>
</div>
@if (isNonIndividual) {
<div class="te-field">
<span class="te-label">Constitution</span>
<span class="te-value">{{
nonPersonDetails?.constitution?.name ?? nonPersonDetails?.constitutionType?.name ?? '—'
}}</span>
</div>
<div class="te-field">
<span class="te-label">Incorp. No.</span>
<span class="te-value te-mono">{{
nonPersonDetails?.incorpNo ?? nonPersonDetails?.incorporationNo ?? '—'
}}</span>
</div>
<div class="te-field">
<span class="te-label">Main Business</span>
<span class="te-value">{{
nonPersonDetails?.mainBusinessLine?.name ?? nonPersonDetails?.mainBusinessLineType?.name ?? '—'
}}</span>
</div>
}
</div>
<div class="te-field-row">
<div class="te-field">
<span class="te-label">Opening Dt</span><span class="te-value">{{ openingDate }}</span>
</div>
<div class="te-field">
<span class="te-label">Status</span
><span class="te-value">{{ accountDetails.status?.value ?? '—' }}</span>
</div>
</div>
<div class="te-field">
<span class="te-label">CIF No.</span><span class="te-value te-mono">{{ cifNumber }}</span>
</div>
@if (isNonIndividual) {
<div class="te-field">
<span class="te-label">Constitution</span>
<span class="te-value">{{
nonPersonDetails?.constitution?.name ?? nonPersonDetails?.constitutionType?.name ?? '—'
}}</span>
</div>
<div class="te-field">
<span class="te-label">Incorp. No.</span>
<span class="te-value te-mono">{{
nonPersonDetails?.incorpNo ?? nonPersonDetails?.incorporationNo ?? '—'
}}</span>
</div>
<div class="te-field">
<span class="te-label">Main Business</span>
<span class="te-value">{{
nonPersonDetails?.mainBusinessLine?.name ?? nonPersonDetails?.mainBusinessLineType?.name ?? '—'
}}</span>
</div>
}
</div>
}
</div>
<!-- Joint Holders + Nominees -->
@@ -929,76 +937,78 @@
<!-- Other Info + KYC + Account Balances -->
<div class="te-panel">
<div class="te-panel__header">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="4" width="18" height="18" rx="2" />
<line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
Other Information
</div>
<div class="te-panel__body">
<div class="te-field">
<span class="te-label">Phone No.</span><span class="te-value">{{ clientPhone }}</span>
@if (!isGlAccount) {
<div class="te-panel__header">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="4" width="18" height="18" rx="2" />
<line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
Other Information
</div>
<div class="te-field">
<span class="te-label">Client ID</span
><span class="te-value te-mono">{{ accountDetails.clientId }}</span>
</div>
<div class="te-field">
<span class="te-label">PAN</span><span class="te-value te-mono">{{ panNumber }}</span>
</div>
<div class="te-field">
<span class="te-label">KYC Status</span>
<span class="te-value" [style.color]="kycDetails?.kycStatus === 300 ? '#059669' : '#f59e0b'">{{
kycStatus
}}</span>
</div>
<div class="te-field">
<span class="te-label">CKYC No.</span><span class="te-value te-mono">{{ ckycNumber }}</span>
</div>
<div class="te-field">
<span class="te-label">Aadhaar</span><span class="te-value te-mono">{{ aadhaarNumber }}</span>
</div>
<div class="te-field">
<span class="te-label">Product</span>
<span class="te-value">{{
accountDetails.savingsProductName ||
accountDetails.depositProductName ||
accountDetails.productName ||
'—'
}}</span>
</div>
@if (selectedRow?.acTyp === 'SHR') {
<div class="te-panel__body">
<div class="te-field">
<span class="te-label">No. of Shares Held</span
><span class="te-value te-mono">{{ accountDetails.summary?.totalApprovedShares ?? 0 }}</span>
<span class="te-label">Phone No.</span><span class="te-value">{{ clientPhone }}</span>
</div>
<div class="te-field">
<span class="te-label">Market Price / Share</span
><span class="te-value te-mono"
>{{ currencySymbol }}{{ accountDetails.currentMarketPrice | number: '1.2-2' }}</span
>
<span class="te-label">Client ID</span
><span class="te-value te-mono">{{ accountDetails.clientId }}</span>
</div>
<div class="te-field">
<span class="te-label">Resolution No.</span
><span class="te-value te-mono">{{ accountDetails.resolutionNumber || '—' }}</span>
<span class="te-label">PAN</span><span class="te-value te-mono">{{ panNumber }}</span>
</div>
<div class="te-field">
<span class="te-label">Linked Savings A/C No.</span
><span class="te-value te-mono">{{ accountDetails.savingsAccountNumber || '—' }}</span>
<span class="te-label">KYC Status</span>
<span class="te-value" [style.color]="kycDetails?.kycStatus === 300 ? '#059669' : '#f59e0b'">{{
kycStatus
}}</span>
</div>
@if (accountDetails.linkedLoanAccountNo) {
<div class="te-field">
<span class="te-label">CKYC No.</span><span class="te-value te-mono">{{ ckycNumber }}</span>
</div>
<div class="te-field">
<span class="te-label">Aadhaar</span><span class="te-value te-mono">{{ aadhaarNumber }}</span>
</div>
<div class="te-field">
<span class="te-label">Product</span>
<span class="te-value">{{
accountDetails.savingsProductName ||
accountDetails.depositProductName ||
accountDetails.productName ||
'—'
}}</span>
</div>
@if (selectedRow?.acTyp === 'SHR') {
<div class="te-field">
<span class="te-label">Linked Loan A/C No.</span
><span class="te-value te-mono">{{ accountDetails.linkedLoanAccountNo }}</span>
<span class="te-label">No. of Shares Held</span
><span class="te-value te-mono">{{ accountDetails.summary?.totalApprovedShares ?? 0 }}</span>
</div>
<div class="te-field">
<span class="te-label">Market Price / Share</span
><span class="te-value te-mono"
>{{ currencySymbol }}{{ accountDetails.currentMarketPrice | number: '1.2-2' }}</span
>
</div>
<div class="te-field">
<span class="te-label">Resolution No.</span
><span class="te-value te-mono">{{ accountDetails.resolutionNumber || '—' }}</span>
</div>
<div class="te-field">
<span class="te-label">Linked Savings A/C No.</span
><span class="te-value te-mono">{{ accountDetails.savingsAccountNumber || '—' }}</span>
</div>
@if (accountDetails.linkedLoanAccountNo) {
<div class="te-field">
<span class="te-label">Linked Loan A/C No.</span
><span class="te-value te-mono">{{ accountDetails.linkedLoanAccountNo }}</span>
</div>
}
}
}
</div>
</div>
}
<div class="te-panel__header te-panel__header--mt">
<div class="te-panel__header" [class.te-panel__header--mt]="!isGlAccount">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="12" y1="1" x2="12" y2="23" />
<path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6" />
@@ -1567,6 +1577,8 @@
<select
class="ws-f__ctl"
[(ngModel)]="row.typ"
[disabled]="isTypLocked(row)"
[title]="isTypLocked(row) ? 'Token withdrawal - TYPE is fixed to Payment' : ''"
(click)="$event.stopPropagation()"
(change)="onTypChange(row)"
>
@@ -1577,11 +1589,16 @@
</div>
<div class="ws-f ws-f--branch">
<label class="ws-f__lbl">BRANCH</label>
<select class="ws-f__ctl" [(ngModel)]="row.br" (click)="$event.stopPropagation()">
<option [ngValue]="1">001 · Main</option>
<option [ngValue]="2">002 · M.G. Road</option>
<option [ngValue]="14">014 · Station Rd</option>
<option [ngValue]="27">027 · Industrial</option>
<select
class="ws-f__ctl"
[(ngModel)]="row.br"
[disabled]="branches.length <= 1"
[title]="branches.length <= 1 ? 'Your branch' : 'Branch'"
(click)="$event.stopPropagation()"
>
@for (b of branches; track b.id) {
<option [ngValue]="b.id">{{ b.label }}</option>
}
</select>
</div>
@if (row.typ === 'DD') {
@@ -1627,13 +1644,7 @@
class="ws-f__ctl"
[(ngModel)]="row.acTyp"
(click)="$event.stopPropagation()"
(change)="
row.acTyp === 'GL' &&
row.typ !== 'Disbursement' &&
row.typ !== 'Fund Transfer' &&
row.typ !== 'Enter Bank Transfer' &&
(row.dbCr = 'Cr')
"
(change)="isGlCreditOnly(row) && (row.dbCr = 'Cr')"
>
@for (a of acTypOptionsFor(row); track a) {
<option [value]="a">{{ a }}</option>
@@ -1704,12 +1715,7 @@
type="button"
class="ws-seg__btn ws-seg__btn--dr"
[class.ws-seg__btn--active-dr]="row.dbCr === 'Dr'"
[disabled]="
row.acTyp === 'GL' &&
row.typ !== 'Disbursement' &&
row.typ !== 'Fund Transfer' &&
row.typ !== 'Enter Bank Transfer'
"
[disabled]="isGlCreditOnly(row)"
(click)="$event.stopPropagation(); row.dbCr = 'Dr'"
>
DR
@@ -1718,6 +1724,7 @@
type="button"
class="ws-seg__btn ws-seg__btn--cr"
[class.ws-seg__btn--active-cr]="row.dbCr === 'Cr'"
[disabled]="row.typ === 'Withdrawal Slip'"
(click)="$event.stopPropagation(); row.dbCr = 'Cr'"
>
CR
@@ -2043,7 +2050,13 @@
<button class="ws-btn ws-btn--ghost" type="button" (click)="onSearch()">Search</button>
<button class="ws-btn ws-btn--mode" type="button">Change Tr.Mode</button>
@if (isClerk) {
<button class="ws-btn ws-btn--add" type="button" (click)="addRow()" title="Add another transaction row">
<button
class="ws-btn ws-btn--add"
type="button"
[disabled]="selectedRow?.typ === 'Withdrawal Slip'"
(click)="addRow()"
title="Add another transaction row"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
@@ -1658,6 +1658,8 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
// Full account + client data
accountDetails: any = null;
/** Parent (header) GL of the selected GL account, shown in the GL Account Information panel. */
glParent: { glCode?: string; name?: string } | null = null;
clientDetails: any = null;
kycDetails: any = null;
nominees: any[] = [];
@@ -1685,10 +1687,12 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
private searchToken = 0;
private lastLoadedAcNo = '';
private acNoInput$ = new Subject<{ row: TxnRow; index: number; value: string }>();
/** How many characters of the A/C NO. start the automatic account search. */
private static readonly AC_NO_AUTO_SEARCH_MIN_LENGTH = 6;
cashVaultGlId: number | null = null;
/** The Cash Vault GL account's own code and name — the denomination breakdown for a manual GL
* entry has to be filed against THIS account, not whichever account the teller typed in, since
* that account is never the one physical notes actually moved through (see submitGlJournalEntry).
* that account is never the one physical notes actually moved through.
* Both resolved dynamically off whatever this tenant's own financial-activity mapping names as
* its cash/vault account (see loadCashVaultGl) — never a literal account name or code. */
cashVaultGlCode: string | null = null;
@@ -1873,6 +1877,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
ngOnInit(): void {
const d = businessToday();
this.today = `${String(d.getDate()).padStart(2, '0')}/${String(d.getMonth() + 1).padStart(2, '0')}/${d.getFullYear()}`;
this.loadBranches();
this.addRow();
this.loadDenominations();
this.loadOpeningPosition();
@@ -1903,7 +1908,8 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
distinctUntilChanged((a, b) => a.value === b.value && a.row === b.row)
)
.subscribe(({ row, index, value }) => {
if (value.trim().length >= 4) {
// Searches on its own once six characters are typed, for clerk and cashier alike.
if (value.trim().length >= TransactionEntryComponent.AC_NO_AUTO_SEARCH_MIN_LENGTH) {
this.selectedRow = row;
this.loadAccountDetails(value.trim(), row.acTyp);
}
@@ -2304,7 +2310,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
// anyway, just without the extra click to get there.
const row: TxnRow = {
typ: this.isClerk ? 'Withdrawal Slip' : 'Receipt',
br: 1,
br: this.defaultBranchId,
trTp: '',
acTyp: 'SD',
acNo: '',
@@ -2323,8 +2329,40 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
this.rows.push(row);
}
/**
* A GL row is credit-only (money received into the account) except on the transfer types, which pick
* either side, and on a Withdrawal Slip, which is always a debit: the GL is debited and the cash
* vault credited when cash is paid out against it (a clerk's request, approved, then paid by the cashier).
*/
isGlCreditOnly(row: TxnRow): boolean {
return (
row.acTyp === 'GL' &&
row.typ !== 'Disbursement' &&
row.typ !== 'Fund Transfer' &&
row.typ !== 'Enter Bank Transfer' &&
row.typ !== 'Withdrawal Slip'
);
}
/** Called when the TYPE dropdown changes — sets sensible defaults for TXN TP and DR/CR */
/**
* A cashier row that was filled from an approved token (withdrawal / account-closure) is cash
* leaving the branch to the member, so its TYPE is fixed to Payment and cannot be changed.
* Never true for the clerk screen or for rows keyed in by hand.
*/
isTypLocked(row: TxnRow): boolean {
return (
!this.isClerk &&
!!row.tokNo?.trim() &&
!!(this.activeWithdrawalRequest || this.activeDividendClaimRequest || this.activeClosureRequest)
);
}
onTypChange(row: TxnRow): void {
if (this.isTypLocked(row)) {
row.typ = 'Payment';
return;
}
switch (row.typ) {
case 'Withdrawal Slip':
row.dbCr = 'Dr';
@@ -2440,7 +2478,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
* such ambiguity — the account itself says what it's for.
*/
showsEntryType(row: TxnRow): boolean {
return row.acTyp === 'GL' && row.dbCr === 'Cr';
return row.acTyp === 'GL';
}
/**
@@ -2618,14 +2656,47 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
return !!this.selectedRow && Math.abs(this.denominationTotal - this.ddTargetTotal) < 0.005;
}
/**
* The branches an entry can be posted against: the real offices of this installation. Read from the offices
* themselves rather than from a fixed list, so a branch that is added or renamed shows up here without a code change.
*/
branches: { id: number; label: string }[] = [];
private loadBranches(): void {
const own = this.authService.getCredentials();
const ownBranch = own?.officeId
? [{ id: own.officeId, label: this.branchLabel(own.officeId, own.officeName) }]
: [];
// The office list is only asked for by a login allowed to read it. A cashier or clerk is not, and asking anyway
// makes the server refuse and the screen raise an "Unauthorized Request" popup; such a login works in its own
// office, which is the one branch it is offered.
const permissions = own?.permissions ?? [];
const mayReadOffices = permissions.includes('ALL_FUNCTIONS') || permissions.includes('READ_OFFICE');
if (!mayReadOffices) {
this.branches = ownBranch;
return;
}
this.http
.get('/offices')
.pipe(catchError(() => of([])))
.subscribe((list: any) => {
const offices: any[] = Array.isArray(list) ? list : [];
this.branches =
offices.length > 0 ? offices.map((o) => ({ id: o.id, label: this.branchLabel(o.id, o.name) })) : ownBranch;
});
}
private branchLabel(id: number, name: string | null | undefined): string {
return `${String(id).padStart(3, '0')} · ${name ?? 'Branch ' + id}`;
}
/** The office the signed-in user works in, which a new entry is posted against unless they pick another. */
get defaultBranchId(): number {
return this.authService.getCredentials()?.officeId ?? 1;
}
branchNameFor(branchId: number): string {
const map: Record<number, string> = {
1: '001 · Main',
2: '002 · M.G. Road',
14: '014 · Station Rd',
27: '027 · Industrial'
};
return map[branchId] || `Branch ${branchId}`;
return this.branches.find((b) => b.id === branchId)?.label ?? `Branch ${branchId}`;
}
/**
@@ -3918,7 +3989,8 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
SAVINGS: 'SD',
CURRENT: 'CA',
FIXED_DEPOSIT: 'FD',
RECURRING_DEPOSIT: 'RD'
RECURRING_DEPOSIT: 'RD',
GL: 'GL'
};
openCustomerSearch(): void {
@@ -4243,6 +4315,17 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
}
this.accountDetails = acc;
this.jointHolders = acc.jointHolders ?? [];
// GL only: look up the parent (header) GL's name. Never runs for any other account type.
this.glParent = null;
if (acTyp?.toUpperCase() === 'GL' && acc.parentId) {
this.http
.get<any>(`/glaccounts/${acc.parentId}`)
.pipe(catchError(() => of(null)))
.subscribe((p: any) => {
if (token !== this.searchToken) return;
this.glParent = p ? { glCode: p.glCode, name: p.name } : null;
});
}
// Cash Hold / Clearing Hold breakdown only exists for plain savings accounts (m_savings_account)
// — FD/RD/Current/Share/GL have no such row, so there's nothing to ask for.
if (acTyp?.toUpperCase() === 'SD' && acc.id) {
@@ -4813,6 +4896,40 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
return this.accountDetails?.officeName ?? this.clientDetails?.officeName ?? '—';
}
/** True when the loaded account is a General Ledger account (/glaccounts), which has no client behind it. */
get isGlAccount(): boolean {
return !!this.accountDetails?.glCode && this.selectedRow?.acTyp === 'GL';
}
/** The GL account's own details, shown in place of the client panels. */
get glInfo(): { label: string; value: string; mono?: boolean }[] {
const gl = this.accountDetails;
if (!gl) return [];
const type = gl.type?.value ?? '—';
const nature = [
'ASSET',
'EXPENSE'
].includes(String(type).toUpperCase()) ? 'Debit' : 'Credit';
return [
{ label: 'GL Code', value: gl.glCode ?? '—', mono: true },
{ label: 'GL Name', value: gl.name ?? '—' },
{ label: 'Account Type', value: type },
{ label: 'Usage', value: gl.usage?.value ?? '—' },
{ label: 'Normal Balance', value: nature },
{
label: 'Parent GL',
value: this.glParent?.name
? `${this.glParent.glCode ? this.glParent.glCode.trim() + ' - ' : ''}${this.glParent.name.trim()}`
: gl.parentId != null
? `ID ${gl.parentId}`
: '—'
},
{ label: 'Manual Entries', value: gl.manualEntriesAllowed ? 'Allowed' : 'Not allowed' },
{ label: 'Status', value: gl.disabled ? 'Disabled' : 'Enabled' },
{ label: 'Description', value: gl.description || '—' }
];
}
get openingDate(): string {
const raw =
this.accountDetails?.timeline?.submittedOnDate ??
@@ -5847,10 +5964,14 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
return;
}
// GL withdrawal → direct journal entry (no maker-checker)
// GL deposit → falls through to maker-checker block below
if (row.acTyp === 'GL' && !isDeposit) {
this.submitGlJournalEntry(row, amount, isDeposit);
// A GL withdrawal slip is raised by the clerk and paid by the cashier against its token, exactly like a
// savings withdrawal (the clerk branch and the token branch below). Nothing posts from this screen directly:
// manual journal entries are switched off on the server.
if (row.acTyp === 'GL' && !isDeposit && !this.clerkMode && !this.activeWithdrawalRequest) {
this.showAlert('error', 'GL Withdrawal', [
'A GL withdrawal is raised by a clerk and paid by the cashier against its token.',
'Enter the token number of the approved request to pay it.'
]);
return;
}
@@ -6223,17 +6344,34 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
const txnDateIso = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
const selectedPt = this.paymentTypes.find((p: any) => p.id === row.paymentTypeId);
const isGl = row.acTyp === 'GL';
// The Entry Type picked for a GL slip has no column of its own on the request, so it rides on the narration.
const entryTypeName = isGl ? this.entryTypeOptions.find((e) => e.id === row.entryTypeCvId)?.name : undefined;
const description = [
row.description,
entryTypeName ? `[Entry Type: ${entryTypeName}]` : ''
]
.filter(Boolean)
.join(' ');
return {
accountNo: this.accountDetails.accountNo,
accountNo: isGl ? (this.accountDetails.glCode ?? this.accountDetails.name) : this.accountDetails.accountNo,
accountType: this.creditEntryAccountType(row.acTyp),
savingsAccountId: this.accountDetails.id,
clientId: this.accountDetails.clientId ?? this.clientDetails?.id ?? null,
clientName: this.clientDetails?.displayName ?? this.accountDetails?.clientName ?? null,
savingsAccountId: isGl ? null : this.accountDetails.id,
// GL-specific fields: the GL being paid out of and the cash GL it is paid from (null for other types)
glAccountId: isGl ? this.accountDetails.id : null,
contraGlId: isGl ? this.cashVaultGlId : null,
officeId: isGl ? row.br || 1 : null,
currencyCode: isGl ? (this.accountDetails?.currency?.code ?? 'INR') : null,
clientId: isGl ? null : (this.accountDetails.clientId ?? this.clientDetails?.id ?? null),
clientName: isGl
? (this.accountDetails?.name ?? null)
: (this.clientDetails?.displayName ?? this.accountDetails?.clientName ?? null),
amount,
txnDate: txnDateIso,
paymentTypeId: row.paymentTypeId || null,
paymentTypeName: selectedPt?.name ?? 'Cash',
description: row.description || null,
description: description || null,
chequeNo: row.chequeNo || null,
bankName: row.bankName || null,
tokNo: row.tokNo?.trim() || null,
@@ -6425,106 +6563,6 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
this.addRow();
}
private submitGlJournalEntry(row: TxnRow, amount: number, isDeposit: boolean): void {
if (!this.cashVaultGlId) {
this.showAlert('error', 'Cash Vault GL Not Configured', [
'Financial Activity Account (CASH_AT_TELLER) is not configured.',
'Please configure it in System → Financial Activity Accounts.'
]);
this.saving = false;
return;
}
const today = businessToday();
const txnDate = `${String(today.getDate()).padStart(2, '0')}/${String(today.getMonth() + 1).padStart(2, '0')}/${today.getFullYear()}`;
const txnDateIso = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
const selectedGlId = this.accountDetails.id;
const contraGlId = this.cashVaultGlId;
// isDeposit (CR button) = cashier receives cash → credit selected GL, debit contra (cash vault)
// isWithdrawal (DR button) = cashier pays out cash → debit selected GL, credit contra (cash vault)
const credits = isDeposit ? [{ glAccountId: selectedGlId, amount }] : [{ glAccountId: contraGlId, amount }];
const debits = isDeposit ? [{ glAccountId: contraGlId, amount }] : [{ glAccountId: selectedGlId, amount }];
const payload: any = {
officeId: row.br || 1,
transactionDate: txnDate,
dateFormat: 'dd/MM/yyyy',
locale: 'en',
currencyCode: this.accountDetails?.currency?.code ?? 'INR',
comments: row.description || null,
credits,
debits,
paymentTypeId: row.paymentTypeId
};
// Check if cash payment so we can record denomination after journal entry
const selectedPt = this.paymentTypes.find((p: any) => p.id === row.paymentTypeId);
const isCashPayment = !!selectedPt?.isCashPayment;
this.saving = true;
this.http
.post('/journalentries', payload)
.pipe(
catchError((err: any) => {
const msg =
err?.error?.errors?.[0]?.defaultUserMessage ??
err?.error?.developerMessage ??
'Journal entry failed. Please try again.';
this.showAlert('error', 'Transaction Failed', [msg]);
this.saving = false;
return of(null);
})
)
.subscribe((res: any) => {
this.saving = false;
if (res) {
// Record denomination so cashier position stays accurate
// Same condition as savings: cash + denom entered + denom matches posted amount
if (isCashPayment && this.denominationTotal > 0 && this.denominationTotal === amount) {
const denomBreakdown = [
...this.notes,
...this.coins
]
.filter((d) => this.countFor(d) > 0)
.map((d) => ({
denominationId: d.id,
denominationValue: d.value,
kind: d.kind,
count: this.countFor(d),
subtotal: this.subtotalFor(d)
}));
// Filed against the Cash Vault GL, not the account the teller selected on screen: that
// selected account (e.g. Sundry Creditors) is the other leg of the journal entry, and
// never the one whose physical notes actually changed hands. Tagging the breakdown to it
// instead of the vault is why this cash never reached the cashier/vault position.
this.http
.post('/cashier/gl-denomination', {
txnId: res.transactionId ?? res.resourceId,
accountNo: this.cashVaultGlCode ?? row.acNo,
accountName: this.cashVaultGlName ?? 'Cash at Vault',
txnType: isDeposit ? 'DEPOSIT' : 'WITHDRAWAL',
txnDate: txnDateIso,
paymentTypeName: selectedPt?.name ?? 'Cash',
denominationBreakdown: denomBreakdown
})
.pipe(catchError(() => of(null)))
.subscribe();
}
const ref = res.transactionId ?? res.resourceId ?? 'N/A';
this.showAlert('success', 'GL Transaction Posted', [
`Journal entry of ₹${amount.toLocaleString('en-IN', { minimumFractionDigits: 2 })} posted successfully.`,
`Transaction ID: ${ref}`
]);
this.clearDenominations();
this.loadOpeningPosition();
this.loadAccountDetails(row.acNo.trim(), row.acTyp);
}
});
}
onSearch(): void {
if (this.selectedRow?.acNo.trim()) {
this.loadAccountDetails(this.selectedRow.acNo.trim(), this.selectedRow.acTyp);
@@ -0,0 +1,170 @@
<!--
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/.
-->
<div class="cv-tab-content vca-tab">
<!-- ── TITLE BAR ── -->
<div class="vca-action-bar">
<div class="vca-action-bar__left">
<mat-icon class="vca-action-bar__icon">undo</mat-icon>
<div>
<div class="vca-action-bar__title">Voucher Corrections</div>
<div class="vca-action-bar__sub">
Approve or reject a voucher unpass, and the correction entry that follows it. Only the correction approval
moves money.
</div>
</div>
</div>
<div class="vca-counts">
<span class="vca-count"
><strong>{{ unpassCount }}</strong> unpass</span
>
<span class="vca-count"
><strong>{{ correctionCount }}</strong> correction</span
>
</div>
</div>
<!-- ── FILTER BAR ── -->
<div class="vca-filter-bar">
<div class="vca-search-box">
<mat-icon>search</mat-icon>
<input type="text" placeholder="Search by voucher, account, customer or user" [(ngModel)]="searchTerm" />
@if (searchTerm) {
<button type="button" class="vca-search-box__clear" (click)="searchTerm = ''">
<mat-icon>close</mat-icon>
</button>
}
</div>
<span class="vca-fb-sep"></span>
<select class="vca-filter-select" [(ngModel)]="kindFilter">
@for (opt of kindOptions; track opt.value) {
<option [value]="opt.value">{{ opt.label }}</option>
}
</select>
<button type="button" class="vca-btn vca-btn--refresh" (click)="load()" [disabled]="loading">
<mat-icon>refresh</mat-icon>
</button>
</div>
<!-- ── TABLE ── -->
@if (loading && !loaded) {
<div class="vca-empty"><p class="vca-empty__title">Loading…</p></div>
} @else if (rows.length === 0) {
<div class="vca-empty">
<mat-icon class="vca-empty__icon">inbox</mat-icon>
<p class="vca-empty__title">No voucher correction waiting</p>
<p class="vca-empty__text">There is no unpass request or correction entry waiting for review.</p>
</div>
} @else {
<div class="vca-table-wrap">
<table class="vca-table">
<thead>
<tr>
<th>Voucher No</th>
<th>Request</th>
<th>Posted To (Wrong)</th>
<th>Correct Account</th>
<th class="num">Amount</th>
<th>Reason</th>
<th>Raised By</th>
<th>Raised At</th>
<th class="vca-th-action">Action</th>
</tr>
</thead>
<tbody>
@for (r of rows; track r.request.id) {
<tr class="vca-row">
<td class="vca-mono vca-voucher">{{ r.request.original_voucher_no }}</td>
<td>
<span class="vca-kind" [class]="'vca-kind vca-kind--' + r.kind">{{ kindLabel(r.kind) }}</span>
</td>
<td>
<div class="vca-mono">{{ r.request.wrong_account_no }}</div>
<div class="vca-sub">{{ r.request.wrong_client_name || '—' }}</div>
</td>
<td>
@if (r.kind === 'correction') {
<div class="vca-mono vca-strong">{{ r.request.correct_account_no }}</div>
<div class="vca-sub">{{ r.request.correct_client_name || '—' }}</div>
} @else {
<span class="vca-none">—</span>
}
</td>
<td class="num vca-amount">₹{{ r.request.amount | number: '1.2-2' }}</td>
<td class="vca-reason" [title]="r.request.reason || ''">{{ r.request.reason || '—' }}</td>
<td>{{ r.raisedBy || '—' }}</td>
<td class="vca-dt">{{ formatDateTime(r.raisedAt) }}</td>
<td class="vca-action-cell">
<button
type="button"
class="vca-btn vca-btn--reject"
[disabled]="busyId === r.request.id"
(click)="startReject(r)"
>
<mat-icon>close</mat-icon> Reject
</button>
<button
type="button"
class="vca-btn vca-btn--approve"
[disabled]="busyId === r.request.id"
(click)="approve(r)"
>
<mat-icon>check</mat-icon>
{{ r.kind === 'unpass' ? 'Approve unpass' : 'Approve correction' }}
</button>
</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
<!-- ─── Popup: reject with a reason ───────────────────────────────────────── -->
<ng-template #rejectDialog>
@if (rejecting) {
<div class="vca-dlg">
<header class="vca-dlg__head">
<mat-icon class="vca-dlg__ico">block</mat-icon>
<div class="vca-dlg__titles">
<div class="vca-dlg__title">
Reject {{ rejecting.kind === 'unpass' ? 'unpass request' : 'correction entry' }}
</div>
<div class="vca-dlg__sub vca-mono">{{ rejecting.request.original_voucher_no }}</div>
</div>
<button type="button" class="vca-dlg__x" aria-label="Close" (click)="cancelReject()">
<mat-icon>close</mat-icon>
</button>
</header>
<div class="vca-dlg__body">
<label class="vca-label" for="vca-reason">Reason (required)</label>
<textarea
id="vca-reason"
rows="3"
placeholder="Why is this being rejected?"
[(ngModel)]="rejectReason"
></textarea>
</div>
<footer class="vca-dlg__foot">
<button type="button" class="vca-btn vca-btn--ghost" (click)="cancelReject()">Cancel</button>
<button
type="button"
class="vca-btn vca-btn--reject-solid"
[disabled]="!rejectReason.trim() || busyId === rejecting.request.id"
(click)="confirmReject()"
>
Confirm reject
</button>
</footer>
</div>
}
</ng-template>
@@ -0,0 +1,485 @@
/**
* 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/.
*/
// Laid out like the other approval tabs (Withdrawal Requests): title bar, filter bar, one table.
:host {
display: block;
}
.vca-tab {
padding: 0;
}
.vca-action-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 10px 16px;
background: #fff;
border-bottom: 1px solid #e2e8f0;
&__left {
display: flex;
align-items: center;
gap: 12px;
}
&__icon {
font-size: 22px;
width: 22px;
height: 22px;
color: #0f4f3c;
}
&__title {
font-size: 13px;
font-weight: 700;
color: #0d1b2a;
}
&__sub {
max-width: 640px;
margin-top: 1px;
font-size: 11px;
color: #64748b;
}
}
.vca-counts {
display: flex;
gap: 8px;
}
.vca-count {
padding: 2px 10px;
border-radius: 999px;
background: #f1f5f9;
font-size: 11px;
color: #475569;
strong {
color: #0f4f3c;
}
}
.vca-filter-bar {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
padding: 6px 16px;
background: #f8fafc;
border-bottom: 1px solid #e2e8f0;
}
.vca-fb-sep {
flex-shrink: 0;
width: 1px;
height: 22px;
margin: 0 4px;
background: #e2e8f0;
}
.vca-search-box {
display: inline-flex;
align-items: center;
gap: 6px;
height: 30px;
min-width: 240px;
padding: 0 10px;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #fff;
mat-icon {
flex-shrink: 0;
font-size: 17px;
width: 17px;
height: 17px;
color: #94a3b8;
}
&:hover {
border-color: #cbd5e1;
}
&:focus-within {
border-color: #0f4f3c;
box-shadow: 0 0 0 3px rgb(15 79 60 / 8%);
}
input {
flex: 1;
border: none;
outline: none;
background: transparent;
font-family: inherit;
font-size: 12px;
color: #0d1b2a;
&::placeholder {
color: #94a3b8;
}
}
&__clear {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 20px;
height: 20px;
padding: 0;
border: none;
border-radius: 50%;
background: none;
color: #94a3b8;
cursor: pointer;
mat-icon {
font-size: 15px;
width: 15px;
height: 15px;
color: inherit;
}
&:hover {
color: #dc2626;
background: #fef2f2;
}
}
}
.vca-filter-select {
height: 30px;
min-width: 140px;
padding: 0 10px;
border: 1px solid #e2e8f0;
border-radius: 7px;
background: #fff;
font-family: inherit;
font-size: 12px;
color: #0d1b2a;
cursor: pointer;
&:focus {
outline: none;
border-color: #0f4f3c;
}
}
// ── Buttons ─────────────────────────────────────────────────────────────────
.vca-btn {
display: inline-flex;
align-items: center;
gap: 4px;
height: 26px;
padding: 0 10px;
border: 1px solid transparent;
border-radius: 7px;
font-family: inherit;
font-size: 11px;
font-weight: 600;
white-space: nowrap;
cursor: pointer;
mat-icon {
font-size: 14px;
width: 14px;
height: 14px;
}
&:disabled {
opacity: 0.45;
cursor: not-allowed;
}
&--refresh {
height: 30px;
width: 30px;
justify-content: center;
padding: 0;
margin-left: auto;
border-color: #e2e8f0;
background: #fff;
color: #475569;
}
&--approve {
background: #0f4f3c;
color: #fff;
&:hover:not(:disabled) {
background: #0a3d2e;
}
}
&--reject {
border-color: #fecaca;
background: #fff;
color: #dc2626;
&:hover:not(:disabled) {
background: #fef2f2;
}
}
&--reject-solid {
background: #dc2626;
color: #fff;
}
&--ghost {
border-color: #e2e8f0;
background: #fff;
color: #475569;
}
}
// ── Table ───────────────────────────────────────────────────────────────────
.vca-table-wrap {
overflow-x: auto;
background: #fff;
}
.vca-table {
width: 100%;
border-collapse: collapse;
font-size: 11px;
th {
padding: 7px 10px;
border-bottom: 1px solid #e2e8f0;
background: #f8fafc;
font-size: 9.5px;
font-weight: 800;
letter-spacing: 0.06em;
text-align: left;
text-transform: uppercase;
color: #94a3b8;
white-space: nowrap;
}
td {
padding: 5px 10px;
border-bottom: 1px solid #eef2f7;
vertical-align: middle;
color: #0d1b2a;
}
.num {
text-align: right;
font-variant-numeric: tabular-nums;
}
.vca-th-action {
text-align: right;
}
}
.vca-row:hover td {
background: #f8fafc;
}
.vca-mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
letter-spacing: 0.02em;
}
.vca-voucher {
font-weight: 700;
color: #0f4f3c;
}
.vca-strong {
font-weight: 700;
}
.vca-sub {
margin-top: 1px;
font-size: 11px;
color: #64748b;
}
.vca-none {
color: #cbd5e1;
}
.vca-amount {
font-weight: 700;
}
.vca-reason {
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.vca-dt {
white-space: nowrap;
color: #475569;
}
.vca-kind {
display: inline-block;
padding: 2px 10px;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
&--unpass {
background: #fef3c7;
color: #92400e;
}
&--correction {
background: #dbeafe;
color: #1e40af;
}
}
.vca-action-cell {
display: flex;
justify-content: flex-end;
gap: 6px;
white-space: nowrap;
}
// ── Empty ───────────────────────────────────────────────────────────────────
.vca-empty {
padding: 28px 16px;
text-align: center;
color: #94a3b8;
&__icon {
display: block;
margin: 0 auto 6px;
font-size: 30px;
width: 30px;
height: 30px;
opacity: 0.6;
}
&__title {
margin: 0;
font-size: 12px;
font-weight: 700;
color: #64748b;
}
&__text {
margin: 3px 0 0;
font-size: 11px;
}
}
// ── Reject popup ────────────────────────────────────────────────────────────
.vca-dlg {
font-size: 12px;
&__head {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
border-bottom: 1px solid #e2e8f0;
background: #f8fafc;
}
&__ico {
flex: 0 0 32px;
height: 32px;
width: 32px;
border-radius: 9px;
background: #fef2f2;
color: #dc2626;
font-size: 18px;
line-height: 32px;
text-align: center;
}
&__titles {
flex: 1;
min-width: 0;
}
&__title {
font-size: 14px;
font-weight: 700;
color: #0f172a;
}
&__sub {
font-size: 11px;
color: #64748b;
}
&__x {
display: inline-flex;
align-items: center;
justify-content: center;
height: 28px;
width: 28px;
border: 0;
border-radius: 50%;
background: transparent;
color: #64748b;
cursor: pointer;
&:hover {
background: #e2e8f0;
}
mat-icon {
font-size: 18px;
height: 18px;
width: 18px;
}
}
&__body {
padding: 12px 14px;
textarea {
width: 100%;
padding: 7px 10px;
border: 1px solid #cbd5e1;
border-radius: 8px;
font: inherit;
font-size: 12px;
resize: vertical;
&:focus {
outline: 0;
border-color: #0f4f3c;
box-shadow: 0 0 0 3px rgb(15 79 60 / 8%);
}
}
}
&__foot {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 10px 14px;
border-top: 1px solid #e2e8f0;
background: #f8fafc;
}
}
.vca-label {
display: block;
margin-bottom: 4px;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
color: #94a3b8;
}
@@ -0,0 +1,192 @@
/**
* 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/.
*/
import { Component, OnInit, TemplateRef, ViewChild, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MatDialog, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
import { MatIconModule } from '@angular/material/icon';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { VoucherCorrection, VoucherCorrectionService } from '../voucher-correction.service';
type Kind = 'unpass' | 'correction';
type KindFilter = 'ALL' | Kind;
/** One row of the approvals table: a request, and which of the two steps it is waiting at. */
interface ApprovalRow {
kind: Kind;
request: VoucherCorrection;
raisedBy: string | null;
raisedAt: string | null;
}
/**
* Manager side of the voucher correction, laid out like the other approval tabs: approves or rejects the unpass of a
* voucher, and later the correction entry the cashier made. Only the second approval moves money.
*/
@Component({
selector: 'mifosx-voucher-correction-approvals',
templateUrl: './voucher-correction-approvals.component.html',
styleUrls: ['./voucher-correction-approvals.component.scss'],
standalone: true,
imports: [
CommonModule,
FormsModule,
MatDialogModule,
MatIconModule,
MatSnackBarModule
]
})
export class VoucherCorrectionApprovalsComponent implements OnInit {
private service = inject(VoucherCorrectionService);
private snackBar = inject(MatSnackBar);
private dialog = inject(MatDialog);
@ViewChild('rejectDialog') rejectDialog!: TemplateRef<unknown>;
private dialogRef: MatDialogRef<unknown> | null = null;
all: ApprovalRow[] = [];
loading = false;
loaded = false;
busyId: number | null = null;
kindFilter: KindFilter = 'ALL';
searchTerm = '';
/** The row being rejected, and the reason typed for it. */
rejecting: ApprovalRow | null = null;
rejectReason = '';
readonly kindOptions: { value: KindFilter; label: string }[] = [
{ value: 'ALL', label: 'All requests' },
{ value: 'unpass', label: 'Unpass requests' },
{ value: 'correction', label: 'Correction entries' }
];
ngOnInit(): void {
this.load();
}
load(): void {
this.loading = true;
this.service
.list('UNPASS_PENDING,CORRECTION_PENDING')
.pipe(catchError(() => of([] as VoucherCorrection[])))
.subscribe((rows) => {
this.all = rows.map((r) =>
r.status === 'UNPASS_PENDING'
? { kind: 'unpass', request: r, raisedBy: r.unpass_by_name, raisedAt: r.unpass_at }
: { kind: 'correction', request: r, raisedBy: r.correction_by_name, raisedAt: r.correction_at }
);
this.loading = false;
this.loaded = true;
});
}
/** The table: the kind picked, narrowed by whatever is typed in the search box. */
get rows(): ApprovalRow[] {
const term = this.searchTerm.trim().toLowerCase();
return this.all.filter((r) => {
if (this.kindFilter !== 'ALL' && r.kind !== this.kindFilter) return false;
if (!term) return true;
const q = r.request;
return [
q.original_voucher_no,
q.wrong_account_no,
q.wrong_client_name,
q.correct_account_no,
q.correct_client_name,
r.raisedBy
].some((v) => (v ?? '').toLowerCase().includes(term));
});
}
get unpassCount(): number {
return this.all.filter((r) => r.kind === 'unpass').length;
}
get correctionCount(): number {
return this.all.filter((r) => r.kind === 'correction').length;
}
kindLabel(kind: Kind): string {
return kind === 'unpass' ? 'Unpass' : 'Correction';
}
formatDateTime(value: string | null): string {
if (!value) return '—';
const d = new Date(value);
return isNaN(d.getTime())
? value
: d.toLocaleString('en-IN', { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' });
}
approve(row: ApprovalRow): void {
if (this.busyId) return;
const id = row.request.id;
this.busyId = id;
const call = row.kind === 'unpass' ? this.service.approveUnpass(id) : this.service.approveCorrection(id);
call.subscribe({
next: (res: any) => {
this.busyId = null;
this.snackBar.open(
row.kind === 'unpass'
? `Voucher ${row.request.original_voucher_no} unpassed — the cashier can now enter the correct account`
: `Corrected. New voucher ${res?.newVoucherNo ?? ''}`,
'Close',
{ duration: 6000 }
);
this.load();
},
error: (err) => {
this.busyId = null;
this.fail(err, 'Could not approve');
}
});
}
startReject(row: ApprovalRow): void {
this.rejecting = row;
this.rejectReason = '';
this.dialogRef = this.dialog.open(this.rejectDialog, { width: '440px', maxWidth: '95vw', autoFocus: false });
this.dialogRef.afterClosed().subscribe(() => (this.rejecting = null));
}
cancelReject(): void {
this.dialogRef?.close();
}
confirmReject(): void {
if (!this.rejecting || !this.rejectReason.trim() || this.busyId) return;
const row = this.rejecting;
const id = row.request.id;
this.busyId = id;
const reason = this.rejectReason.trim();
const call =
row.kind === 'unpass' ? this.service.rejectUnpass(id, reason) : this.service.rejectCorrection(id, reason);
call.subscribe({
next: () => {
this.busyId = null;
this.dialogRef?.close();
this.snackBar.open('Rejected', 'Close', { duration: 4000 });
this.load();
},
error: (err) => {
this.busyId = null;
this.fail(err, 'Could not reject');
}
});
}
private fail(err: any, fallback: string): void {
const msg = err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.defaultUserMessage ?? fallback;
this.snackBar.open(msg, 'Close', { duration: 8000 });
}
}
@@ -0,0 +1,120 @@
/**
* 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/.
*/
import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
/** One row of a voucher correction, as the server keeps it (snake_case columns). */
export interface VoucherCorrection {
id: number;
original_voucher_no: string;
original_txn_id: number;
wrong_savings_account_id: number;
wrong_account_no: string;
wrong_client_name: string | null;
amount: number;
txn_date: string | null;
status: 'UNPASS_PENDING' | 'UNPASSED' | 'UNPASS_REJECTED' | 'CORRECTION_PENDING' | 'COMPLETED' | 'CLOSED';
reason: string | null;
rejection_reason: string | null;
unpass_by_name: string | null;
unpass_at: string | null;
unpass_approved_by_name: string | null;
correct_account_no: string | null;
correct_client_name: string | null;
correction_by_name: string | null;
correction_at: string | null;
correction_approved_by_name: string | null;
new_voucher_no: string | null;
}
/** A cash deposit voucher of today that can still be unpassed. */
export interface EligibleVoucher {
voucherNo: string;
amount: number;
createdAt: string;
cashier: string;
txnId: number;
savingsAccountId: number;
accountNo: string;
clientName: string | null;
paymentType: string;
}
/** What the server knows about a savings account typed in as the correct one. */
export interface AccountLookup {
id: number;
accountNo: string;
clientId: number | null;
clientName: string | null;
balance: number;
currencyCode: string;
canReceive: boolean;
}
/** HTTP service for the voucher correction workflow: unpass, approve, re-enter, approve. */
@Injectable({ providedIn: 'root' })
export class VoucherCorrectionService {
private http = inject(HttpClient);
private readonly base = '/vouchers/corrections';
eligible(search?: string): Observable<EligibleVoucher[]> {
let params = new HttpParams();
if (search) params = params.set('search', search);
return this.http.get<EligibleVoucher[]>(`${this.base}/eligible`, { params });
}
unpass(voucherNo: string, reason: string): Observable<any> {
return this.http.post(`${this.base}/unpass`, { voucherNo, reason });
}
list(status?: string, mine = false): Observable<VoucherCorrection[]> {
let params = new HttpParams().set('mine', String(mine));
if (status) params = params.set('status', status);
return this.http.get<VoucherCorrection[]>(this.base, { params });
}
get(id: number): Observable<VoucherCorrection> {
return this.http.get<VoucherCorrection>(`${this.base}/${id}`);
}
accountLookup(accountNo: string): Observable<AccountLookup> {
return this.http.get<AccountLookup>(`${this.base}/account-lookup`, { params: { accountNo } });
}
submitCorrection(id: number, correctAccountNo: string): Observable<any> {
return this.http.post(`${this.base}/${id}/correction`, { correctAccountNo });
}
/** The cashier gives up the correction, e.g. when the wrong account no longer holds the money. */
close(id: number, reason: string): Observable<any> {
return this.http.post(`${this.base}/${id}/close`, { reason });
}
approveUnpass(id: number): Observable<any> {
return this.http.post(`${this.base}/${id}/unpass/approve`, {});
}
rejectUnpass(id: number, reason: string): Observable<any> {
return this.http.post(`${this.base}/${id}/unpass/reject`, { reason });
}
approveCorrection(id: number): Observable<any> {
return this.http.post(`${this.base}/${id}/correction/approve`, {});
}
rejectCorrection(id: number, reason: string): Observable<any> {
return this.http.post(`${this.base}/${id}/correction/reject`, { reason });
}
/** Ids of the transactions of this account that a completed correction removed. */
voidedTransactions(savingsAccountId: number): Observable<number[]> {
return this.http.get<number[]>(`${this.base}/voided-transactions`, { params: { savingsAccountId } });
}
}
@@ -0,0 +1,446 @@
<!--
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/.
-->
<div class="pg">
<header class="pg-head">
<div>
<div class="pg-eyebrow">CASHIER · VOUCHER CORRECTION</div>
<h1 class="pg-title">Voucher Correction</h1>
<p class="pg-sub">
Move a cash deposit that was posted to the wrong account. Nothing changes on any account until the final
approval.
</p>
</div>
</header>
<!-- The four steps, so it is always clear where a voucher is. -->
<ol class="vc-steps">
<li class="vc-step" [class.vc-step--on]="tab === 'today'">
<span class="vc-step__no">1</span>
<span class="vc-step__txt"><strong>Unpass</strong> the voucher</span>
</li>
<li class="vc-step">
<span class="vc-step__no">2</span>
<span class="vc-step__txt"><strong>Manager</strong> approves</span>
</li>
<li class="vc-step" [class.vc-step--on]="tab === 'unpassed'">
<span class="vc-step__no">3</span>
<span class="vc-step__txt">Enter the <strong>correct account</strong></span>
</li>
<li class="vc-step">
<span class="vc-step__no">4</span>
<span class="vc-step__txt"><strong>Manager</strong> approves, deposit moves</span>
</li>
</ol>
<nav class="vc-tabs">
@if (mayUnpass) {
<button type="button" class="vc-tab" [class.vc-tab--on]="tab === 'today'" (click)="setTab('today')">
<mat-icon>receipt_long</mat-icon> Today's Vouchers
</button>
}
@if (mayCorrect) {
<button type="button" class="vc-tab" [class.vc-tab--on]="tab === 'unpassed'" (click)="setTab('unpassed')">
<mat-icon>edit_note</mat-icon> Unpassed — Correct Entry
@if (unpassed.length) {
<span class="vc-count">{{ unpassed.length }}</span>
}
</button>
}
<button type="button" class="vc-tab" [class.vc-tab--on]="tab === 'mine'" (click)="setTab('mine')">
<mat-icon>history</mat-icon> My Requests
</button>
</nav>
<!-- ─── Today's vouchers ─────────────────────────────────────────────── -->
@if (tab === 'today') {
<div class="vc-toolbar">
<div class="vc-search">
<mat-icon>search</mat-icon>
<input
type="text"
placeholder="Search by voucher, account or customer name"
[(ngModel)]="search"
(keyup.enter)="loadVouchers()"
/>
</div>
<button type="button" class="pg-btn pg-btn--primary" (click)="loadVouchers()">
<mat-icon>search</mat-icon> Search
</button>
<button type="button" class="pg-btn pg-btn--ghost" (click)="search = ''; loadVouchers()">
<mat-icon>refresh</mat-icon> Refresh
</button>
</div>
<div class="pg-card">
<table class="pg-table vc-table">
<thead>
<tr>
<th>Voucher</th>
<th>Account</th>
<th>Customer</th>
<th>Mode</th>
<th>Cashier</th>
<th class="num">Amount</th>
<th class="vc-col-action"></th>
</tr>
</thead>
<tbody>
@for (v of vouchers; track v.voucherNo) {
<tr [class.vc-row--on]="selectedVoucher?.voucherNo === v.voucherNo" (click)="pick(v)">
<td>
<span class="pg-chip">{{ v.voucherNo }}</span>
</td>
<td class="vc-mono">{{ v.accountNo }}</td>
<td>{{ v.clientName || '—' }}</td>
<td>{{ v.paymentType }}</td>
<td>{{ v.cashier }}</td>
<td class="num">₹{{ v.amount | number: '1.2-2' }}</td>
<td class="vc-col-action">
<button
type="button"
class="pg-btn pg-btn--ghost vc-btn-sm"
(click)="pick(v); $event.stopPropagation()"
>
<mat-icon>undo</mat-icon> Unpass
</button>
</td>
</tr>
}
@if (!vouchers.length) {
<tr>
<td colspan="7" class="vc-empty">
<mat-icon>{{ loadingVouchers ? 'hourglass_empty' : 'inbox' }}</mat-icon>
<div>{{ loadingVouchers ? 'Loading…' : 'No cash deposit voucher of today can be unpassed.' }}</div>
</td>
</tr>
}
</tbody>
</table>
</div>
}
<!-- ─── Unpassed: enter the correct account ──────────────────────────── -->
@if (tab === 'unpassed') {
<div class="pg-card">
<table class="pg-table vc-table">
<thead>
<tr>
<th>Voucher</th>
<th>Posted to</th>
<th>Customer</th>
<th>Reason</th>
<th class="num">Amount</th>
<th class="vc-col-action"></th>
</tr>
</thead>
<tbody>
@for (u of unpassed; track u.id) {
<tr [class.vc-row--on]="active?.id === u.id" (click)="open(u)">
<td>
<span class="pg-chip">{{ u.original_voucher_no }}</span>
</td>
<td class="vc-mono">{{ u.wrong_account_no }}</td>
<td>{{ u.wrong_client_name || '—' }}</td>
<td class="vc-clip">{{ u.reason }}</td>
<td class="num">₹{{ u.amount | number: '1.2-2' }}</td>
<td class="vc-col-action">
<button
type="button"
class="pg-btn pg-btn--primary vc-btn-sm"
(click)="open(u); $event.stopPropagation()"
>
<mat-icon>edit</mat-icon> Correct
</button>
</td>
</tr>
}
@if (!unpassed.length) {
<tr>
<td colspan="6" class="vc-empty">
<mat-icon>{{ loadingUnpassed ? 'hourglass_empty' : 'task_alt' }}</mat-icon>
<div>
{{ loadingUnpassed ? 'Loading…' : 'No unpassed voucher is waiting for its correct account.' }}
</div>
</td>
</tr>
}
</tbody>
</table>
</div>
}
<!-- ─── My requests ──────────────────────────────────────────────────── -->
@if (tab === 'mine') {
<div class="pg-card">
<table class="pg-table vc-table">
<thead>
<tr>
<th>Voucher</th>
<th>Posted to</th>
<th>Corrected to</th>
<th class="num">Amount</th>
<th>Status</th>
<th>New voucher</th>
</tr>
</thead>
<tbody>
@for (m of mine; track m.id) {
<tr>
<td>
<span class="pg-chip">{{ m.original_voucher_no }}</span>
</td>
<td class="vc-mono">{{ m.wrong_account_no }}</td>
<td class="vc-mono">{{ m.correct_account_no || '—' }}</td>
<td class="num">₹{{ m.amount | number: '1.2-2' }}</td>
<td>
<span class="vc-status vc-status--{{ m.status }}">{{ statusLabel[m.status] }}</span>
@if (m.rejection_reason) {
<div class="vc-note">{{ m.rejection_reason }}</div>
}
</td>
<td>{{ m.new_voucher_no || '—' }}</td>
</tr>
}
@if (!mine.length) {
<tr>
<td colspan="6" class="vc-empty">
<mat-icon>{{ loadingMine ? 'hourglass_empty' : 'history' }}</mat-icon>
<div>{{ loadingMine ? 'Loading…' : 'You have no voucher corrections yet.' }}</div>
</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
<!-- ─── Popup: unpass a voucher ──────────────────────────────────────────── -->
<ng-template #unpassDialog>
@if (selectedVoucher) {
<div class="vc-dlg">
<header class="vc-dlg__head">
<mat-icon class="vc-dlg__ico">undo</mat-icon>
<div class="vc-dlg__titles">
<div class="vc-dlg__title">Unpass voucher</div>
<div class="vc-dlg__sub vc-mono">{{ selectedVoucher.voucherNo }}</div>
</div>
<button type="button" class="vc-dlg__x" aria-label="Close" (click)="closeUnpass()">
<mat-icon>close</mat-icon>
</button>
</header>
<div class="vc-dlg__body">
<div class="vc-dlg__amount">
<span>Deposit amount</span>
<strong>₹{{ selectedVoucher.amount | number: '1.2-2' }}</strong>
</div>
<div class="vc-grid">
<div class="vc-cell">
<div class="vc-label">Voucher</div>
<div class="vc-value vc-mono">{{ selectedVoucher.voucherNo }}</div>
</div>
<div class="vc-cell">
<div class="vc-label">Created</div>
<div class="vc-value">{{ selectedVoucher.createdAt | date: 'dd MMM yyyy, hh:mm a' }}</div>
</div>
<div class="vc-cell">
<div class="vc-label">Cashier</div>
<div class="vc-value">{{ selectedVoucher.cashier }}</div>
</div>
<div class="vc-cell">
<div class="vc-label">Mode</div>
<div class="vc-value">{{ selectedVoucher.paymentType }}</div>
</div>
<div class="vc-cell">
<div class="vc-label">Account</div>
<div class="vc-value vc-mono">{{ selectedVoucher.accountNo }}</div>
</div>
<div class="vc-cell">
<div class="vc-label">Customer</div>
<div class="vc-value">{{ selectedVoucher.clientName || '—' }}</div>
</div>
<div class="vc-cell">
<div class="vc-label">Account balance now</div>
<div class="vc-value">
@if (voucherAccount) {
₹{{ voucherAccount.balance | number: '1.2-2' }}
} @else {
—
}
</div>
</div>
<div class="vc-cell">
<div class="vc-label">Account status</div>
<div class="vc-value">
@if (voucherAccount) {
{{ voucherAccount.canReceive ? 'Active' : 'Not active' }} · {{ voucherAccount.currencyCode }}
} @else {
—
}
</div>
</div>
</div>
<label class="vc-label" for="vc-reason">Reason for unpassing</label>
<textarea
id="vc-reason"
class="vc-reason"
rows="2"
placeholder="Why is this voucher wrong? e.g. deposited in the wrong account (required)"
[(ngModel)]="unpassReason"
></textarea>
<div class="vc-info">
<mat-icon>info</mat-icon>
A manager approves the unpass first. Nothing changes on any account until the corrected entry is approved: the
deposit stays on {{ selectedVoucher.accountNo }} till then.
</div>
</div>
<footer class="vc-dlg__foot">
<button type="button" class="pg-btn pg-btn--ghost" (click)="closeUnpass()">Cancel</button>
<button type="button" class="pg-btn pg-btn--primary" [disabled]="!canUnpass" (click)="unpass()">
<mat-icon>send</mat-icon> Send for approval
</button>
</footer>
</div>
}
</ng-template>
<!-- ─── Popup: enter the correct account ─────────────────────────────────── -->
<ng-template #correctionDialog>
@if (active) {
<div class="vc-dlg">
<header class="vc-dlg__head">
<mat-icon class="vc-dlg__ico">edit_note</mat-icon>
<div class="vc-dlg__titles">
<div class="vc-dlg__title">Correction entry</div>
<div class="vc-dlg__sub vc-mono">{{ active.original_voucher_no }}</div>
</div>
<button type="button" class="vc-dlg__x" aria-label="Close" (click)="closeActive()">
<mat-icon>close</mat-icon>
</button>
</header>
<div class="vc-dlg__body">
<div class="vc-dlg__amount">
<span>Amount to move</span>
<strong>₹{{ active.amount | number: '1.2-2' }}</strong>
</div>
<!-- Everything but the account number is fixed. -->
<div class="vc-grid">
<div class="vc-cell">
<div class="vc-label">Transaction date</div>
<div class="vc-value">{{ active.txn_date || '—' }}</div>
</div>
<div class="vc-cell">
<div class="vc-label">Mode</div>
<div class="vc-value">Cash</div>
</div>
<div class="vc-cell">
<div class="vc-label">Unpassed by</div>
<div class="vc-value">{{ active.unpass_by_name || '—' }}</div>
</div>
<div class="vc-cell">
<div class="vc-label">Approved by</div>
<div class="vc-value">{{ active.unpass_approved_by_name || '—' }}</div>
</div>
<div class="vc-cell vc-cell--wide">
<div class="vc-label">Reason</div>
<div class="vc-value">{{ active.reason }}</div>
</div>
</div>
<div class="vc-compare">
<!-- The account the deposit went to -->
<div class="vc-acct vc-acct--wrong">
<div class="vc-acct__tag">Posted to (wrong)</div>
<div class="vc-acct__no vc-mono">{{ active.wrong_account_no }}</div>
<div class="vc-acct__name">{{ active.wrong_client_name || '—' }}</div>
<div class="vc-acct__bal">
Balance:
@if (wrongAccount) {
<strong>₹{{ wrongAccount.balance | number: '1.2-2' }}</strong>
@if (wrongHasBalance) {
<span class="vc-ok"><mat-icon>check_circle</mat-icon> covers the deposit</span>
} @else {
<span class="vc-bad"
><mat-icon>cancel</mat-icon> does not cover ₹{{ active.amount | number: '1.2-2' }}</span
>
}
} @else {
—
}
</div>
</div>
<mat-icon class="vc-arrow">arrow_forward</mat-icon>
<!-- The account it should have gone to -->
<div class="vc-acct" [class.vc-acct--ok]="correctOk" [class.vc-acct--wrong]="!!lookupError">
<div class="vc-acct__tag">Correct account</div>
<input
id="vc-correct"
class="vc-acct__input vc-mono"
type="text"
placeholder="Type the account number"
[ngModel]="correctAccountNo"
(ngModelChange)="onAccountInput($event)"
(blur)="lookup()"
/>
@if (lookingUp) {
<div class="vc-acct__bal">Looking up…</div>
}
@if (correctAccount) {
<div class="vc-acct__name">{{ correctAccount.clientName || '—' }}</div>
<div class="vc-acct__bal">
Balance: <strong>₹{{ correctAccount.balance | number: '1.2-2' }}</strong>
@if (correctOk) {
<span class="vc-ok"><mat-icon>check_circle</mat-icon> active, can receive</span>
}
</div>
}
@if (lookupError) {
<div class="vc-bad vc-acct__err"><mat-icon>error</mat-icon> {{ lookupError }}</div>
}
</div>
</div>
@if (wrongAccount && !wrongHasBalance) {
<div class="vc-warn">
<mat-icon>warning</mat-icon>
<div>
<strong>The customer has already used this money,</strong> so it cannot be taken back from
{{ active.wrong_account_no }}. Deposit ₹{{ active.amount | number: '1.2-2' }} into the correct account
from your own pocket as a fresh deposit (Transaction Entry), then press <strong>Close request</strong>.
</div>
</div>
}
</div>
<footer class="vc-dlg__foot">
<button
type="button"
class="pg-btn pg-btn--ghost vc-dlg__close-req"
[disabled]="sending"
(click)="closeRequest()"
>
<mat-icon>block</mat-icon> Close request
</button>
<button type="button" class="pg-btn pg-btn--ghost" (click)="closeActive()">Cancel</button>
<button type="button" class="pg-btn pg-btn--primary" [disabled]="!canSave" (click)="save()">
<mat-icon>check</mat-icon> Save &amp; send for approval
</button>
</footer>
</div>
}
</ng-template>
@@ -0,0 +1,599 @@
/**
* 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/.
*/
// Compact sizing: the page has to fit a 100% browser window without scrolling for the common case.
.pg {
padding: 14px 20px;
gap: 12px;
}
.pg-title {
font-size: 20px;
margin: 2px 0;
}
.pg-sub {
font-size: 12px;
}
.pg-btn {
height: 30px;
padding: 0 12px;
font-size: 12px;
mat-icon {
font-size: 16px;
height: 16px;
width: 16px;
}
&:disabled {
opacity: 0.45;
cursor: not-allowed;
}
}
.pg-table {
font-size: 12px;
th {
padding: 8px 12px;
font-size: 10px;
}
td {
padding: 7px 12px;
}
}
// ── Steps ───────────────────────────────────────────────────────────────────
.vc-steps {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
gap: 8px;
margin: 0;
padding: 0;
list-style: none;
}
.vc-step {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
border: 1px solid #e2e8f0;
border-radius: 10px;
background: #fff;
font-size: 12px;
color: #64748b;
&__no {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 20px;
height: 20px;
border-radius: 50%;
background: #e2e8f0;
font-size: 11px;
font-weight: 700;
color: #475569;
}
&--on {
border-color: #1f7a4d;
background: #f0fdf4;
color: #0f4f3c;
.vc-step__no {
background: #1f7a4d;
color: #fff;
}
}
}
// ── Tabs ────────────────────────────────────────────────────────────────────
.vc-tabs {
display: flex;
gap: 2px;
border-bottom: 1px solid #e2e8f0;
flex-wrap: wrap;
}
.vc-tab {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 7px 12px;
margin-bottom: -1px;
border: 0;
border-bottom: 2px solid transparent;
background: transparent;
font: inherit;
font-size: 12px;
font-weight: 600;
color: #64748b;
cursor: pointer;
mat-icon {
font-size: 16px;
height: 16px;
width: 16px;
}
&:hover {
color: #0f4f3c;
}
&--on {
color: #0f4f3c;
border-bottom-color: #1f7a4d;
}
}
.vc-count {
padding: 0 7px;
border-radius: 999px;
background: #f59e0b;
color: #fff;
font-size: 10px;
font-weight: 700;
}
// ── Search ──────────────────────────────────────────────────────────────────
.vc-toolbar {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.vc-search {
display: flex;
align-items: center;
gap: 6px;
flex: 1 1 280px;
max-width: 420px;
height: 30px;
padding: 0 10px;
border: 1px solid #cbd5e1;
border-radius: 999px;
background: #fff;
&:focus-within {
border-color: #1f7a4d;
box-shadow: 0 0 0 3px rgb(31 122 77 / 12%);
}
mat-icon {
font-size: 16px;
height: 16px;
width: 16px;
color: #94a3b8;
}
input {
flex: 1;
min-width: 0;
border: 0;
outline: 0;
background: transparent;
font: inherit;
font-size: 12px;
}
}
// ── Table ───────────────────────────────────────────────────────────────────
.vc-table {
tbody tr {
cursor: pointer;
}
tr.vc-row--on td {
background: #f0fdf4;
&:first-child {
box-shadow: inset 3px 0 0 #1f7a4d;
}
}
.vc-col-action {
width: 1%;
text-align: right;
white-space: nowrap;
}
.vc-clip {
max-width: 240px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pg-chip {
font-size: 11px;
padding: 2px 8px;
}
}
.vc-btn-sm {
height: 26px;
padding: 0 10px;
font-size: 11px;
}
.vc-mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
letter-spacing: 0.02em;
}
.vc-empty {
padding: 24px 12px;
text-align: center;
color: #94a3b8;
font-size: 12px;
mat-icon {
display: block;
margin: 0 auto 4px;
font-size: 28px;
height: 28px;
width: 28px;
opacity: 0.6;
}
}
// ── Popups ──────────────────────────────────────────────────────────────────
.vc-dlg {
display: flex;
flex-direction: column;
max-height: 86vh;
font-size: 12px;
&__head {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
border-bottom: 1px solid #e2e8f0;
background: #f8fafc;
}
&__ico {
flex: 0 0 32px;
height: 32px;
width: 32px;
border-radius: 9px;
background: #f0fdf4;
color: #1f7a4d;
font-size: 18px;
line-height: 32px;
text-align: center;
}
&__titles {
flex: 1;
min-width: 0;
}
&__title {
font-size: 14px;
font-weight: 700;
color: #0f172a;
}
&__sub {
font-size: 11px;
color: #64748b;
}
&__x {
display: inline-flex;
align-items: center;
justify-content: center;
height: 28px;
width: 28px;
border: 0;
border-radius: 50%;
background: transparent;
color: #64748b;
cursor: pointer;
mat-icon {
font-size: 18px;
height: 18px;
width: 18px;
}
&:hover {
background: #e2e8f0;
}
}
&__body {
padding: 12px 14px;
overflow-y: auto;
}
&__amount {
display: flex;
align-items: baseline;
justify-content: space-between;
margin-bottom: 10px;
padding: 8px 12px;
border-radius: 10px;
background: #ecfdf5;
border: 1px solid #a7f3d0;
color: #065f46;
font-size: 12px;
font-weight: 600;
strong {
font-size: 18px;
font-weight: 800;
font-variant-numeric: tabular-nums;
}
}
&__foot {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 10px 14px;
border-top: 1px solid #e2e8f0;
background: #f8fafc;
}
}
.vc-warn {
display: flex;
align-items: flex-start;
gap: 8px;
margin-top: 10px;
padding: 8px 12px;
border-radius: 8px;
background: #fffbeb;
border: 1px solid #fde68a;
color: #92400e;
font-size: 11px;
mat-icon {
flex: 0 0 18px;
height: 18px;
width: 18px;
font-size: 18px;
}
}
.vc-dlg__close-req {
margin-right: auto;
color: #b45309;
}
.vc-label {
display: block;
margin-bottom: 3px;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
color: #94a3b8;
}
.vc-reason {
width: 100%;
padding: 7px 10px;
border: 1px solid #cbd5e1;
border-radius: 8px;
font: inherit;
font-size: 12px;
resize: vertical;
&:focus {
outline: 0;
border-color: #1f7a4d;
box-shadow: 0 0 0 3px rgb(31 122 77 / 12%);
}
}
.vc-info {
display: flex;
align-items: flex-start;
gap: 6px;
margin-top: 8px;
padding: 7px 10px;
border-radius: 8px;
background: #eff6ff;
color: #1e40af;
font-size: 11px;
mat-icon {
flex: 0 0 16px;
height: 16px;
width: 16px;
font-size: 16px;
}
}
.vc-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
gap: 6px;
margin-bottom: 10px;
}
.vc-cell {
padding: 6px 10px;
border-radius: 8px;
background: #f8fafc;
border: 1px solid #eef2f7;
}
.vc-cell--wide {
grid-column: 1 / -1;
}
.vc-value {
font-size: 12px;
font-weight: 600;
color: #0f172a;
word-break: break-word;
}
// ── Wrong → Correct ─────────────────────────────────────────────────────────
.vc-compare {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: stretch;
gap: 8px;
@media (width <= 640px) {
grid-template-columns: 1fr;
}
}
.vc-arrow {
align-self: center;
color: #94a3b8;
@media (width <= 640px) {
transform: rotate(90deg);
justify-self: center;
}
}
.vc-acct {
padding: 10px 12px;
border: 1px solid #e2e8f0;
border-radius: 10px;
background: #fff;
&--wrong {
border-color: #fecaca;
background: #fef2f2;
}
&--ok {
border-color: #a7f3d0;
background: #ecfdf5;
}
&__tag {
margin-bottom: 4px;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
color: #64748b;
}
&__no {
font-size: 14px;
font-weight: 700;
color: #0f172a;
}
&__input {
width: 100%;
height: 32px;
padding: 0 10px;
border: 1px solid #cbd5e1;
border-radius: 8px;
background: #fff;
font-size: 13px;
font-weight: 600;
&:focus {
outline: 0;
border-color: #1f7a4d;
box-shadow: 0 0 0 3px rgb(31 122 77 / 12%);
}
}
&__name {
margin-top: 4px;
font-size: 12px;
font-weight: 600;
color: #1e293b;
}
&__bal {
margin-top: 3px;
font-size: 11px;
color: #475569;
}
&__err {
margin-top: 6px;
}
}
.vc-ok,
.vc-bad {
display: inline-flex;
align-items: center;
gap: 3px;
margin-left: 6px;
font-weight: 700;
mat-icon {
font-size: 14px;
height: 14px;
width: 14px;
}
}
.vc-ok {
color: #059669;
}
.vc-bad {
color: #dc2626;
}
.vc-note {
margin-top: 3px;
font-size: 11px;
color: #64748b;
}
// ── Status ──────────────────────────────────────────────────────────────────
.vc-status {
display: inline-block;
padding: 2px 8px;
border-radius: 999px;
background: #e2e8f0;
font-size: 11px;
font-weight: 700;
&--UNPASS_PENDING,
&--CORRECTION_PENDING {
background: #fef3c7;
color: #92400e;
}
&--UNPASSED {
background: #dbeafe;
color: #1e40af;
}
&--COMPLETED {
background: #d1fae5;
color: #065f46;
}
&--CLOSED {
background: #f1f5f9;
color: #475569;
}
&--UNPASS_REJECTED {
background: #fee2e2;
color: #991b1b;
}
}
@@ -0,0 +1,307 @@
/**
* 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/.
*/
import { Component, OnInit, TemplateRef, ViewChild, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MatDialog, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
import { MatIconModule } from '@angular/material/icon';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { AuthenticationService } from '../../core/authentication/authentication.service';
import {
AccountLookup,
EligibleVoucher,
VoucherCorrection,
VoucherCorrectionService
} from '../voucher-correction.service';
type Tab = 'today' | 'unpassed' | 'mine';
/**
* Cashier side of correcting a cash deposit that went to the wrong account.
*
* Today vouchers: pick the voucher and unpass it (needs a manager approval; nothing moves).
* Unpassed: open the approved voucher, whose details are fixed, change only the account number and save
* (needs a manager approval again; only then do the accounts change).
*/
@Component({
selector: 'mifosx-voucher-correction',
templateUrl: './voucher-correction.component.html',
styleUrls: [
'../cashier-shared.scss',
'./voucher-correction.component.scss'
],
standalone: true,
imports: [
CommonModule,
FormsModule,
MatIconModule,
MatDialogModule,
MatSnackBarModule
]
})
export class VoucherCorrectionComponent implements OnInit {
private service = inject(VoucherCorrectionService);
private snackBar = inject(MatSnackBar);
private dialog = inject(MatDialog);
private auth = inject(AuthenticationService);
/** Unpassing is for the back office (an administrator or a clerk); re-entering the account is the cashier only. */
readonly mayUnpass = this.auth.isClerkUser() || !!this.auth.getCredentials()?.permissions?.includes('ALL_FUNCTIONS');
readonly mayCorrect = this.auth.isCashierUser();
@ViewChild('unpassDialog') unpassDialog!: TemplateRef<unknown>;
@ViewChild('correctionDialog') correctionDialog!: TemplateRef<unknown>;
private dialogRef: MatDialogRef<unknown> | null = null;
tab: Tab = this.mayUnpass ? 'today' : this.mayCorrect ? 'unpassed' : 'mine';
// Today vouchers
search = '';
vouchers: EligibleVoucher[] = [];
loadingVouchers = false;
selectedVoucher: EligibleVoucher | null = null;
/** The account the voucher was posted to, for the balance and status shown in the popup. */
voucherAccount: AccountLookup | null = null;
unpassReason = '';
sending = false;
// Unpassed vouchers waiting for the correct account
unpassed: VoucherCorrection[] = [];
loadingUnpassed = false;
active: VoucherCorrection | null = null;
wrongAccount: AccountLookup | null = null;
correctAccountNo = '';
correctAccount: AccountLookup | null = null;
lookupError = '';
lookingUp = false;
// The cashier own requests
mine: VoucherCorrection[] = [];
loadingMine = false;
readonly statusLabel: Record<string, string> = {
UNPASS_PENDING: 'Unpass awaiting approval',
UNPASSED: 'Unpassed — enter correct account',
UNPASS_REJECTED: 'Unpass rejected',
CORRECTION_PENDING: 'Correction awaiting approval',
COMPLETED: 'Corrected',
CLOSED: 'Closed — cashier deposits separately'
};
ngOnInit(): void {
if (this.mayUnpass) this.loadVouchers();
if (this.mayCorrect) this.loadUnpassed();
if (this.tab === 'mine') this.loadMine();
}
setTab(tab: Tab): void {
this.tab = tab;
if (tab === 'today') this.loadVouchers();
if (tab === 'unpassed') this.loadUnpassed();
if (tab === 'mine') this.loadMine();
}
// ── Today vouchers ─────────────────────────────────────────────────────────
loadVouchers(): void {
this.loadingVouchers = true;
this.service
.eligible(this.search.trim())
.pipe(catchError(() => of([] as EligibleVoucher[])))
.subscribe((rows) => {
this.vouchers = rows;
this.loadingVouchers = false;
if (this.selectedVoucher && !rows.some((r) => r.voucherNo === this.selectedVoucher?.voucherNo)) {
this.selectedVoucher = null;
}
});
}
pick(v: EligibleVoucher): void {
this.selectedVoucher = v;
this.unpassReason = '';
this.voucherAccount = null;
this.service
.accountLookup(v.accountNo)
.pipe(catchError(() => of(null)))
.subscribe((a) => (this.voucherAccount = a));
this.showDialog(this.unpassDialog, () => (this.selectedVoucher = null));
}
closeUnpass(): void {
this.dialogRef?.close();
}
get canUnpass(): boolean {
return !!this.selectedVoucher && this.unpassReason.trim().length > 0 && !this.sending;
}
unpass(): void {
if (!this.selectedVoucher || !this.canUnpass) return;
this.sending = true;
this.service.unpass(this.selectedVoucher.voucherNo, this.unpassReason.trim()).subscribe({
next: () => {
this.sending = false;
this.snackBar.open(`Voucher ${this.selectedVoucher?.voucherNo} sent for unpass approval`, 'Close', {
duration: 4000
});
this.dialogRef?.close();
this.unpassReason = '';
this.loadVouchers();
},
error: (err) => {
this.sending = false;
this.fail(err, 'Could not unpass the voucher');
}
});
}
// ── Unpassed vouchers: enter the correct account ───────────────────────────
loadUnpassed(): void {
this.loadingUnpassed = true;
this.service
.list('UNPASSED')
.pipe(catchError(() => of([] as VoucherCorrection[])))
.subscribe((rows) => {
this.unpassed = rows;
this.loadingUnpassed = false;
});
}
open(row: VoucherCorrection): void {
this.active = row;
this.correctAccountNo = '';
this.correctAccount = null;
this.lookupError = '';
this.wrongAccount = null;
// The account the deposit went to: its balance is what the "can this be taken back" check reads.
this.service
.accountLookup(row.wrong_account_no)
.pipe(catchError(() => of(null)))
.subscribe((a) => (this.wrongAccount = a));
this.showDialog(this.correctionDialog, () => (this.active = null));
}
closeActive(): void {
this.dialogRef?.close();
}
onAccountInput(value: string): void {
this.correctAccountNo = value;
this.correctAccount = null;
this.lookupError = '';
// Same rule as the Transaction Entry screen: searches on its own once six characters are typed.
if (value.trim().length >= 6) this.lookup();
}
lookup(): void {
const accountNo = this.correctAccountNo.trim();
if (!accountNo || this.lookingUp) return;
this.lookingUp = true;
this.service.accountLookup(accountNo).subscribe({
next: (a) => {
this.lookingUp = false;
// A late answer for a number no longer typed is dropped.
if (accountNo !== this.correctAccountNo.trim()) return;
this.correctAccount = a;
if (this.active && a.id === this.active.wrong_savings_account_id) {
this.lookupError = 'This is the account the deposit was already posted to.';
} else if (!a.canReceive) {
this.lookupError = 'This account is not active, so it cannot receive the deposit.';
}
},
error: (err) => {
this.lookingUp = false;
this.lookupError = err?.error?.errors?.[0]?.defaultUserMessage ?? 'Account not found.';
}
});
}
/** The wrong account still holds the deposit, so it can be taken back without overdrawing it. */
get wrongHasBalance(): boolean {
return !!this.wrongAccount && !!this.active && this.wrongAccount.balance >= this.active.amount;
}
get correctOk(): boolean {
return !!this.correctAccount && this.correctAccount.canReceive && !this.lookupError;
}
get canSave(): boolean {
return !!this.active && this.correctOk && this.wrongHasBalance && !this.sending;
}
save(): void {
if (!this.active || !this.canSave) return;
this.sending = true;
this.service.submitCorrection(this.active.id, this.correctAccountNo.trim()).subscribe({
next: () => {
this.sending = false;
this.snackBar.open('Correction sent for approval', 'Close', { duration: 4000 });
this.dialogRef?.close();
this.loadUnpassed();
},
error: (err) => {
this.sending = false;
this.fail(err, 'Could not save the correction');
}
});
}
/**
* Gives up this correction. The usual reason is that the wrong account no longer holds the money, so the cashier
* puts the amount into the right account from their own pocket as a fresh deposit instead.
*/
closeRequest(): void {
if (!this.active || this.sending) return;
const reason = this.wrongHasBalance
? 'Closed by the cashier'
: 'Wrong account no longer holds the amount; cashier deposits it into the correct account separately';
this.sending = true;
this.service.close(this.active.id, reason).subscribe({
next: () => {
this.sending = false;
this.snackBar.open('Request closed', 'Close', { duration: 4000 });
this.dialogRef?.close();
this.loadUnpassed();
},
error: (err) => {
this.sending = false;
this.fail(err, 'Could not close the request');
}
});
}
// ── My requests ────────────────────────────────────────────────────────────
loadMine(): void {
this.loadingMine = true;
this.service
.list(undefined, true)
.pipe(catchError(() => of([] as VoucherCorrection[])))
.subscribe((rows) => {
this.mine = rows;
this.loadingMine = false;
});
}
/** Opens one of the two popups; onClose runs however it is dismissed (button, backdrop or Esc). */
private showDialog(template: TemplateRef<unknown>, onClose: () => void): void {
this.dialogRef?.close();
this.dialogRef = this.dialog.open(template, { width: '600px', maxWidth: '95vw', autoFocus: false });
this.dialogRef.afterClosed().subscribe(onClose);
}
private fail(err: any, fallback: string): void {
const msg = err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.defaultUserMessage ?? fallback;
this.snackBar.open(msg, 'Close', { duration: 6000 });
}
}
@@ -169,6 +169,7 @@
<th>Debit</th>
<th>Credit</th>
<th class="num">Amount</th>
<th>Correction</th>
<th>User</th>
<!-- Two dates, not one, on the system register.
A voucher's timestamp is the minute it was written; the posting behind it carries
@@ -217,6 +218,18 @@
</td>
}
<td class="num">{{ row.amount | number: '1.2-2' }}</td>
<!-- Where the voucher stands after a correction: unpassed, revoked, closed or corrected. -->
<td>
<mifosx-voucher-status
[status]="row.voucherStatus"
[revokedBy]="row.revokedByVoucherNo"
[replaces]="row.replacesVoucherNo"
[note]="row.correctionNote"
></mifosx-voucher-status>
@if (!row.voucherStatus || row.voucherStatus === 'ACTIVE') {
<span class="vr-active">—</span>
}
</td>
<td>{{ row.username || '—' }}</td>
@if (systemOnly) {
<td class="vr-ledgerdate" [class.vr-ledgerdate--prior]="row.postedAfterClose">
@@ -92,3 +92,8 @@
text-transform: uppercase;
color: #7c3aed;
}
.vr-active {
font-size: 11px;
color: #94a3b8;
}
@@ -14,6 +14,7 @@ import { MatIconModule } from '@angular/material/icon';
import { catchError, of } from 'rxjs';
import { CashierService } from '../cashier.service';
import { businessToday } from 'app/core/utils/business-today';
import { VoucherStatusComponent, VoucherStatus } from '../voucher-status/voucher-status.component';
/**
* Which side of the register a screen shows.
@@ -26,6 +27,11 @@ export type VoucherSide = 'CR' | 'DR' | 'ALL';
/** One voucher as the register shows it: the number, the two sides, and who raised it. */
interface VoucherRow {
voucherNo: string;
/** Where the voucher stands after a correction: active, revoked, under correction, or corrected. */
voucherStatus?: VoucherStatus;
revokedByVoucherNo?: string | null;
replacesVoucherNo?: string | null;
correctionNote?: string | null;
voucherType: string | null;
amount: number | null;
createdAt: string | null;
@@ -82,7 +88,8 @@ interface VoucherReport {
imports: [
CommonModule,
FormsModule,
MatIconModule
MatIconModule,
VoucherStatusComponent
]
})
export class VoucherReportComponent implements OnInit {
@@ -0,0 +1,137 @@
/**
* 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/.
*/
import { Component, Input } from '@angular/core';
import { CommonModule } from '@angular/common';
/** Where a voucher stands after a voucher correction. */
export type VoucherStatus =
| 'ACTIVE'
| 'REVOKED'
| 'UNPASS_PENDING'
| 'UNPASSED'
| 'CORRECTION_PENDING'
| 'CORRECTED'
| 'CLOSED'
| null
| undefined;
/**
* A small badge saying whether a voucher was revoked, is under correction, or replaced another one.
* Nothing is shown for an ordinary (active) voucher, so the registers read as before.
*/
@Component({
selector: 'mifosx-voucher-status',
standalone: true,
imports: [CommonModule],
template: `
@if (label) {
<span class="vs" [class]="'vs vs--' + status" [title]="hint">{{ label }}</span>
@if (note) {
<div class="vs-note">{{ note }}</div>
}
}
`,
styles: [
`
.vs {
display: inline-block;
margin-left: 6px;
padding: 1px 8px;
border-radius: 999px;
background: #e2e8f0;
color: #334155;
font-size: 10px;
font-weight: 700;
white-space: nowrap;
vertical-align: middle;
}
.vs-note {
margin-top: 2px;
max-width: 280px;
font-size: 11px;
line-height: 1.3;
color: #64748b;
}
.vs--REVOKED {
background: #fee2e2;
color: #991b1b;
}
.vs--UNPASS_PENDING,
.vs--CORRECTION_PENDING {
background: #fef3c7;
color: #92400e;
}
.vs--UNPASSED {
background: #dbeafe;
color: #1e40af;
}
.vs--CLOSED {
background: #f1f5f9;
color: #475569;
}
.vs--CORRECTED {
background: #d1fae5;
color: #065f46;
}
`
]
})
export class VoucherStatusComponent {
@Input() status: VoucherStatus;
/** The voucher that replaced this one, when it is revoked. */
@Input() revokedBy: string | null | undefined;
/** The voucher this one replaced, when it is the corrected one. */
@Input() replaces: string | null | undefined;
/** Why: the reason the voucher was unpassed, or why the correction was closed. */
@Input() note: string | null | undefined;
get label(): string {
switch (this.status) {
case 'REVOKED':
return this.revokedBy ? `Revoked → ${this.revokedBy}` : 'Revoked';
case 'UNPASS_PENDING':
return 'Unpass pending';
case 'UNPASSED':
return 'Unpassed';
case 'CORRECTION_PENDING':
return 'Correction pending';
case 'CLOSED':
return 'Closed — cashier deposits separately';
case 'CORRECTED':
return this.replaces ? `Corrected (replaces ${this.replaces})` : 'Corrected';
default:
return '';
}
}
get hint(): string {
switch (this.status) {
case 'REVOKED':
return 'This voucher was revoked: its deposit was moved to another account and a new voucher replaced it.';
case 'UNPASS_PENDING':
return 'A manager has not yet approved unpassing this voucher.';
case 'UNPASSED':
return 'Unpassed: waiting for the cashier to enter the correct account.';
case 'CORRECTION_PENDING':
return 'The correct account is entered; waiting for a manager to approve.';
case 'CLOSED':
return 'The correction was closed by the cashier; the voucher stays as it was.';
case 'CORRECTED':
return 'This voucher was raised by a correction and replaces the earlier one.';
default:
return '';
}
}
}
+31 -33
View File
@@ -131,6 +131,14 @@
</svg>
My Closure Requests
</a>
<a
class="cashier-nav__item cashier-nav__item--sec"
[class.cashier-nav__item--active]="currentUrl.includes('/clerk/voucher-correction')"
[routerLink]="['/clerk/voucher-correction']"
>
<fa-icon icon="undo"></fa-icon>
Voucher Correction
</a>
@if (clerkAccountLoaded) {
<div class="cashier-nav__item-wrap">
<a
@@ -256,13 +264,6 @@
>
<fa-icon icon="exchange"></fa-icon> Cash Exchange
</a>
<a
class="cashier-nav__item"
[routerLink]="['/cashier/exchange-detail']"
routerLinkActive="cashier-nav__item--active"
>
<fa-icon icon="globe"></fa-icon> Exchange Detail
</a>
<a
class="cashier-nav__item"
[routerLink]="['/cashier/inter-cashier']"
@@ -270,13 +271,6 @@
>
<fa-icon icon="exchange-alt"></fa-icon> Inter-Cashier
</a>
<a
class="cashier-nav__item"
[routerLink]="['/cashier/excess-report']"
routerLinkActive="cashier-nav__item--active"
>
<fa-icon icon="exclamation-circle"></fa-icon> Excess Report
</a>
<a
class="cashier-nav__item"
[routerLink]="['/cashier/cashier-position']"
@@ -284,6 +278,13 @@
>
<fa-icon icon="wallet"></fa-icon> Position
</a>
<a
class="cashier-nav__item"
[routerLink]="['/cashier/reconciliation']"
routerLinkActive="cashier-nav__item--active"
>
<fa-icon icon="balance-scale"></fa-icon> Reconciliation
</a>
<a
class="cashier-nav__item"
[routerLink]="['/cashier/cash-history']"
@@ -294,6 +295,13 @@
<a class="cashier-nav__item" [routerLink]="['/cashier/my-work']" routerLinkActive="cashier-nav__item--active">
<fa-icon icon="user-check"></fa-icon> My Vouchers
</a>
<a
class="cashier-nav__item"
[routerLink]="['/cashier/voucher-correction']"
routerLinkActive="cashier-nav__item--active"
>
<fa-icon icon="undo"></fa-icon> Voucher Correction
</a>
<a
class="cashier-nav__item"
[routerLink]="['/cashier/received-report']"
@@ -308,13 +316,6 @@
>
<fa-icon icon="list"></fa-icon> Payment
</a>
<a
class="cashier-nav__item"
[routerLink]="['/cashier/closing-report']"
routerLinkActive="cashier-nav__item--active"
>
<fa-icon icon="file-alt"></fa-icon> Closing Report
</a>
</nav>
}
}
@@ -1094,6 +1095,9 @@
<button mat-menu-item tabindex="0" [routerLink]="['/branch-ops/vault-position']">
<fa-icon icon="lock" class="menu-item-icon"></fa-icon> Vault Position
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/branch-ops/branch-position']">
<fa-icon icon="coins" class="menu-item-icon"></fa-icon> Branch Cash Position
</button>
</div>
<div class="deposit-menu-group">
<div class="deposit-group-title">Verification &amp; Reports</div>
@@ -1166,18 +1170,18 @@
<!-- Cash Entries & Exchange -->
<div class="deposit-menu-group">
<div class="deposit-group-title">Cash &amp; Counter</div>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/cash-exchange']">
<fa-icon icon="exchange" class="menu-item-icon"></fa-icon> Cash Exchange Entry
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/exchange-detail']">
<fa-icon icon="globe" class="menu-item-icon"></fa-icon> Currency Exchange Detail
</button>
<!-- Cash Exchange, Currency Exchange Detail, Inter-Cashier Transfer and Reconciliation are not
here: they move or count notes in one cashier's own drawer, so only the cashier who is logged
in does them, from the cashier screen. -->
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/transaction-entry']">
<fa-icon icon="keyboard" class="menu-item-icon"></fa-icon> Transaction Entry
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/system/denomination-master']">
<fa-icon icon="coins" class="menu-item-icon"></fa-icon> Cash Denomination
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/voucher-correction']">
<fa-icon icon="undo" class="menu-item-icon"></fa-icon> Voucher Correction
</button>
</div>
<!-- Transfers & Settlement -->
@@ -1186,9 +1190,6 @@
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/cash-transfer']">
<fa-icon icon="random" class="menu-item-icon"></fa-icon> Cash Transfer
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/inter-cashier']">
<fa-icon icon="exchange-alt" class="menu-item-icon"></fa-icon> Inter-Cashier Transfer
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/cash-settlement']">
<fa-icon icon="check-circle" class="menu-item-icon"></fa-icon> Cash Settlement
</button>
@@ -1209,9 +1210,6 @@
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/excess-report']">
<fa-icon icon="exclamation-circle" class="menu-item-icon"></fa-icon> Excess Transaction Report
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/reconciliation']">
<fa-icon icon="balance-scale" class="menu-item-icon"></fa-icon> Reconciliation
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/cash-history']">
<fa-icon icon="history" class="menu-item-icon"></fa-icon> Cash History
</button>
+38
View File
@@ -244,6 +244,44 @@ $color: #e2e4ec;
}
}
/*
* The cashier's bar is meant to be one row at a normal window size: it carries about eleven items, and at
* the usual padding the last one wrapped onto a second row. The items are tightened so they fit, and spread
* across the row. As the window narrows they tighten further, then drop their icons, and only wrap in a very
* narrow window. The clerk's bar (cashier-nav--clerk) has its own dropdowns and is left as it was.
*/
.cashier-nav:not(.cashier-nav--clerk) {
flex-wrap: nowrap;
justify-content: space-between;
gap: 0;
padding: 4px 8px;
.cashier-nav__item {
flex: 0 0 auto;
gap: 4px;
padding: 5px 8px;
font-size: 11.5px;
}
@media (width <= 1240px) {
.cashier-nav__item {
padding: 5px 6px;
font-size: 11px;
}
}
@media (width <= 1120px) {
.cashier-nav__item fa-icon {
display: none;
}
}
@media (width <= 900px) {
flex-wrap: wrap;
justify-content: flex-start;
}
}
.sidenav {
overflow-x: hidden;
position: relative;
@@ -135,18 +135,6 @@
<mat-icon><fa-icon icon="eye" size="sm"></fa-icon></mat-icon>
<span>{{ 'labels.text.View Transaction' | translate }}</span>
</button>
@if (!transaction.reversed) {
<span>
<button
mat-menu-item
*mifosxHasPermission="'ADJUSTTRANSACTION_CURRENTACCOUNT'"
(click)="undoTransaction(transaction, $event)"
>
<mat-icon><fa-icon icon="undo" size="sm"></fa-icon></mat-icon>
<span>{{ 'tooltips.Undo Transaction' | translate }}</span>
</button>
</span>
}
<button
mat-menu-item
class="account-action-button"
@@ -40,18 +40,6 @@
</button>
</span>
}
@if (allowUndo()) {
<span>
<button
mat-raised-button
color="warn"
*mifosxHasPermission="'UNDOTRANSACTION_CURRENTACCOUNT'"
(click)="undoTransaction()"
>
<fa-icon icon="undo" class="m-r-10"></fa-icon>{{ 'labels.buttons.Undo' | translate }}
</button>
</span>
}
</div>
<div class="container">
@@ -130,12 +130,6 @@
<mat-icon><fa-icon icon="eye" size="sm"></fa-icon></mat-icon>
<span>{{ 'labels.text.View Transaction' | translate }}</span>
</button>
@if (!isFirstTransaction(idx)) {
<button mat-menu-item (click)="undoTransaction(transaction)">
<mat-icon><fa-icon icon="undo" size="sm"></fa-icon></mat-icon>
<span>{{ 'tooltips.Undo Transaction' | translate }}</span>
</button>
}
<button
mat-menu-item
(click)="routeEdit($event)"
@@ -6,20 +6,7 @@
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="container m-b-20 align-end-center gap-2percent">
@if (allowUndo()) {
<span>
<button
mat-raised-button
color="warn"
*mifosxHasPermission="'UNDOTRANSACTION_SAVINGSACCOUNT'"
(click)="undoTransaction()"
>
<fa-icon icon="undo" class="m-r-10"></fa-icon>Undo
</button>
</span>
}
</div>
<div class="container m-b-20 align-end-center gap-2percent"></div>
<div class="container">
<mat-card>
<mat-card-content>
@@ -15,14 +15,6 @@
</button>
}
</span>
<button
mat-raised-button
color="warn"
*mifosxHasPermission="'UNDOTRANSACTION_SAVINGSACCOUNT'"
(click)="undoTransaction()"
>
<fa-icon icon="undo" class="m-r-10"></fa-icon>{{ 'labels.buttons.Undo' | translate }}
</button>
</div>
}
@@ -177,10 +177,6 @@
}
@if (allowUndoTransaction(transaction)) {
<span>
<button mat-menu-item *mifosxHasPermission="'ADJUST_LOAN'" (click)="undoTransaction(transaction, $event)">
<mat-icon><fa-icon icon="undo" size="sm"></fa-icon></mat-icon>
<span>{{ 'tooltips.Undo Transaction' | translate }}</span>
</button>
<button
mat-menu-item
class="account-action-button"
@@ -12,9 +12,6 @@
<button mat-raised-button color="primary" [routerLink]="'edit'" [disabled]="!allowEdition">
<fa-icon icon="edit" class="m-r-10"></fa-icon>{{ 'Edit' | translate }}
</button>
<button mat-raised-button color="warn" (click)="undoTransaction()" [disabled]="!allowUndo">
<fa-icon icon="undo" class="m-r-10"></fa-icon>{{ 'Undo' | translate }}
</button>
</span>
<span *mifosxHasPermission="'CHARGEBACK_LOAN'">
<button mat-raised-button color="warn" [disabled]="!allowChargeback" (click)="chargebackTransaction()">
@@ -11,10 +11,12 @@ import { Injectable, inject } from '@angular/core';
import { ActivatedRouteSnapshot } from '@angular/router';
/** rxjs Imports */
import { Observable } from 'rxjs';
import { Observable, of } from 'rxjs';
import { catchError, map, switchMap } from 'rxjs/operators';
/** Custom Services */
import { SavingsService } from '../savings.service';
import { VoucherCorrectionService } from '../../cashier/voucher-correction.service';
/**
* Savings Account data resolver.
@@ -22,14 +24,29 @@ import { SavingsService } from '../savings.service';
@Injectable()
export class SavingsAccountViewResolver {
private savingsService = inject(SavingsService);
private voucherCorrectionService = inject(VoucherCorrectionService);
/**
* Returns the Savings Account data.
* Returns the Savings Account data, without the deposits a completed voucher correction took off this account:
* the passbook and the transaction list read their rows from here, and a deposit that was posted to the wrong
* account and moved should not appear on it at all.
* @param {ActivatedRouteSnapshot} route Route Snapshot
* @returns {Observable<any>}
*/
resolve(route: ActivatedRouteSnapshot): Observable<any> {
const savingAccountId = route.paramMap.get('savingAccountId');
return this.savingsService.getSavingsAccountData(savingAccountId);
return this.savingsService.getSavingsAccountData(savingAccountId).pipe(
switchMap((account: any) =>
this.voucherCorrectionService.voidedTransactions(Number(savingAccountId)).pipe(
catchError(() => of([] as number[])),
map((voided: number[]) => {
if (voided.length && Array.isArray(account?.transactions)) {
account.transactions = account.transactions.filter((txn: any) => !voided.includes(txn.id));
}
return account;
})
)
)
);
}
}
@@ -128,18 +128,6 @@
<mat-icon><fa-icon icon="eye" size="sm"></fa-icon></mat-icon>
<span>{{ 'labels.text.View Transaction' | translate }}</span>
</button>
@if (!transaction.reversed) {
<span>
<button
mat-menu-item
*mifosxHasPermission="'ADJUSTTRANSACTION_SAVINGSACCOUNT'"
(click)="undoTransaction(transaction, $event)"
>
<mat-icon><fa-icon icon="undo" size="sm"></fa-icon></mat-icon>
<span>{{ 'tooltips.Undo Transaction' | translate }}</span>
</button>
</span>
}
<button
mat-menu-item
class="account-action-button"
@@ -66,16 +66,6 @@
<fa-icon icon="lock-open" class="m-r-6"></fa-icon>{{ 'labels.buttons.Release Amount' | translate }}
</button>
}
@if (allowUndo()) {
<button
mat-flat-button
class="txn__btn txn__btn--undo"
*mifosxHasPermission="'UNDOTRANSACTION_SAVINGSACCOUNT'"
(click)="undoTransaction()"
>
<fa-icon icon="undo" class="m-r-6"></fa-icon>{{ 'labels.buttons.Undo' | translate }}
</button>
}
</div>
</div>
@@ -24,24 +24,6 @@
}
</div>
</div>
<div class="txn-header-bar__actions">
@if (dataSource?.data[0].manualEntry) {
<button
mat-raised-button
color="warn"
(click)="revertTransaction()"
*mifosxHasPermission="'REVERSE_JOURNALENTRY'"
[disabled]="dataSource?.data[0].reversed"
class="txn-revert-btn"
>
@if (!dataSource.data[0].reversed) {
<span><fa-icon icon="undo" class="m-r-6"></fa-icon>Revert Transaction</span>
} @else {
<span><fa-icon icon="check-circle" class="m-r-6"></fa-icon>Reverted</span>
}
</button>
}
</div>
</div>
<!-- Transaction Info Cards -->
@@ -8,10 +8,8 @@
import { Component, OnInit, ViewChild, inject } from '@angular/core';
import { ViewJournalEntryComponent } from '../view-journal-entry/view-journal-entry.component';
import { RevertTransactionComponent } from 'app/accounting/revert-transaction/revert-transaction.component';
import { AccountingService } from 'app/accounting/accounting.service';
import { MatDialog } from '@angular/material/dialog';
import { ActivatedRoute, Router } from '@angular/router';
import { ActivatedRoute } from '@angular/router';
import { MatSort, MatSortHeader } from '@angular/material/sort';
import { MatPaginator } from '@angular/material/paginator';
import {
@@ -65,9 +63,7 @@ import { MatTooltipModule } from '@angular/material/tooltip';
]
})
export class ViewJournalEntryTransactionComponent implements OnInit {
private accountingService = inject(AccountingService);
private route = inject(ActivatedRoute);
private router = inject(Router);
dialog = inject(MatDialog);
private location = inject(Location);
@@ -147,30 +143,6 @@ export class ViewJournalEntryTransactionComponent implements OnInit {
this.dialog.open(ViewJournalEntryComponent, { data: { journalEntry } });
}
revertTransaction(transactionId?: string) {
const revertTransactionDialogRef = this.dialog.open(RevertTransactionComponent, {
data: { reverted: this.dataSource.data[0].reversed, transactionId }
});
revertTransactionDialogRef.afterClosed().subscribe((response: any) => {
if (response.revert) {
this.accountingService
.revertTransaction(this.transactionId, response.comments)
.subscribe((reversedTransaction: any) => {
this.dataSource.data[0].reversed = true;
this.revertTransaction(reversedTransaction.transactionId);
});
} else if (response.redirect) {
this.router.navigate(
[
'../',
transactionId
],
{ relativeTo: this.route }
);
}
});
}
goBack(): void {
this.location.back();
}
@@ -10,12 +10,12 @@ import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { HttpClient, HttpParams } from '@angular/common/http';
import { FormsModule, ReactiveFormsModule, FormBuilder, FormGroup } from '@angular/forms';
import { MatDialogModule, MatDialogRef } from '@angular/material/dialog';
import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatButtonModule } from '@angular/material/button';
import { MatTableModule } from '@angular/material/table';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatIconModule } from '@angular/material/icon';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
export interface CustomerSearchResult {
@@ -30,6 +30,17 @@ export interface CustomerSearchResult {
address: string | null;
}
/** Optional data for the dialog. */
export interface CustomerSearchDialogData {
/**
* Whether a customer must have an account to be picked. True by default, because the account screens
* need one; a screen that only needs the customer (cash exchange) passes false.
*/
requireAccount?: boolean;
}
type AccountTypeKey = 'savings' | 'current' | 'fixedDeposit' | 'recurringDeposit';
@Component({
selector: 'mifosx-customer-search-dialog',
standalone: true,
@@ -41,173 +52,479 @@ export interface CustomerSearchResult {
MatFormFieldModule,
MatInputModule,
MatButtonModule,
MatTableModule,
MatCheckboxModule,
MatIconModule,
MatProgressBarModule,
MatSnackBarModule
],
template: `
<h2 mat-dialog-title>Search Customer</h2>
<mat-dialog-content>
<p class="hint-text">
Fill in any one field, or combine several to narrow the search (e.g. name + account number).
</p>
<form [formGroup]="form" class="search-form">
<mat-form-field appearance="outline" class="field">
<mat-label>Name</mat-label>
<input matInput formControlName="name" placeholder="Customer name" />
</mat-form-field>
<div class="csd-head">
<div class="csd-head__icon"><mat-icon>person_search</mat-icon></div>
<div class="csd-head__text">
<h2 class="csd-head__title">Search Customer</h2>
<p class="csd-head__sub">
Fill in any one field, or combine several to narrow the search (e.g. name + account number).
</p>
</div>
<button mat-icon-button type="button" class="csd-head__close" (click)="onClose()" aria-label="Close">
<mat-icon>close</mat-icon>
</button>
</div>
<mat-form-field appearance="outline" class="field">
<mat-label>Account Number</mat-label>
<input matInput formControlName="accountNumber" placeholder="Savings / Current / FD / RD" />
</mat-form-field>
@if (loading) {
<mat-progress-bar mode="indeterminate"></mat-progress-bar>
}
<mat-form-field appearance="outline" class="field">
<mat-label>Aadhaar Number</mat-label>
<input matInput formControlName="aadhaarNumber" placeholder="12-digit Aadhaar" maxlength="12" />
</mat-form-field>
<mat-dialog-content class="csd-content">
<form [formGroup]="form" class="csd-form" (keydown.enter)="onEnter($event)">
<div class="csd-grid">
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="csd-field csd-field--name">
<mat-label>Name</mat-label>
<mat-icon matPrefix>person</mat-icon>
<input matInput formControlName="name" placeholder="Customer name" autocomplete="off" />
</mat-form-field>
<mat-form-field appearance="outline" class="field">
<mat-label>PAN Number</mat-label>
<input
matInput
formControlName="panNumber"
placeholder="e.g. ABCDE1234F"
maxlength="10"
style="text-transform: uppercase;"
/>
</mat-form-field>
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="csd-field">
<mat-label>Account Number</mat-label>
<mat-icon matPrefix>account_balance_wallet</mat-icon>
<input matInput formControlName="accountNumber" placeholder="Any account" autocomplete="off" />
</mat-form-field>
<mat-form-field appearance="outline" class="field field--wide">
<mat-label>Address</mat-label>
<input matInput formControlName="address" placeholder="Street, town, city, or postal code" />
</mat-form-field>
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="csd-field">
<mat-label>Aadhaar Number</mat-label>
<mat-icon matPrefix>fingerprint</mat-icon>
<input
matInput
formControlName="aadhaarNumber"
placeholder="12-digit Aadhaar"
maxlength="12"
inputmode="numeric"
autocomplete="off"
/>
</mat-form-field>
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="csd-field">
<mat-label>PAN Number</mat-label>
<mat-icon matPrefix>credit_card</mat-icon>
<input
matInput
formControlName="panNumber"
placeholder="ABCDE1234F"
maxlength="10"
autocomplete="off"
spellcheck="false"
(input)="upperCase('panNumber')"
class="csd-upper"
/>
</mat-form-field>
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="csd-field csd-field--address">
<mat-label>Address</mat-label>
<mat-icon matPrefix>place</mat-icon>
<input
matInput
formControlName="address"
placeholder="Street, town, city, or postal code"
autocomplete="off"
/>
</mat-form-field>
</div>
<div class="csd-types">
<span class="csd-types__label">Account Type</span>
<div class="csd-types__pills">
@for (t of accountTypeOptions; track t.key) {
<button
type="button"
class="csd-pill"
[class.csd-pill--on]="isTypeOn(t.key)"
[attr.aria-pressed]="isTypeOn(t.key)"
(click)="toggleType(t.key)"
>
<mat-icon>{{ isTypeOn(t.key) ? 'check' : t.icon }}</mat-icon
>{{ t.label }}
</button>
}
</div>
<span class="csd-types__hint">Leave all unselected to search every account type</span>
</div>
</form>
<div class="account-type-filter" [formGroup]="form">
<span class="account-type-filter__label">Account Type</span>
<mat-checkbox formControlName="savings">Savings</mat-checkbox>
<mat-checkbox formControlName="current">Current</mat-checkbox>
<mat-checkbox formControlName="fixedDeposit">Fixed Deposit</mat-checkbox>
<mat-checkbox formControlName="recurringDeposit">Recurring Deposit</mat-checkbox>
<span class="account-type-filter__hint">(leave all unchecked to search every account type)</span>
</div>
@if (searched) {
<div class="csd-results">
<div class="csd-results__head">{{ results.length }} {{ results.length === 1 ? 'result' : 'results' }}</div>
<div class="results" *ngIf="searched">
<p *ngIf="!loading && results.length === 0" class="no-results">No matching customer found.</p>
@if (!loading && results.length === 0) {
<div class="csd-empty">
<mat-icon>search_off</mat-icon>
<div class="csd-empty__title">No matching customer found</div>
<div class="csd-empty__sub">Check the spelling, or try fewer fields.</div>
</div>
}
<table mat-table [dataSource]="results" *ngIf="results.length > 0">
<ng-container matColumnDef="displayName">
<th mat-header-cell *matHeaderCellDef>Name</th>
<td mat-cell *matCellDef="let r">{{ r.displayName }}</td>
</ng-container>
<ng-container matColumnDef="account">
<th mat-header-cell *matHeaderCellDef>Account</th>
<td mat-cell *matCellDef="let r">
<span *ngIf="r.accountNo">{{ r.accountNo }} ({{ accountTypeLabel(r.accountType) }})</span>
<span *ngIf="!r.accountNo" class="muted">No account</span>
</td>
</ng-container>
<ng-container matColumnDef="mobileNo">
<th mat-header-cell *matHeaderCellDef>Mobile</th>
<td mat-cell *matCellDef="let r">{{ r.mobileNo || '-' }}</td>
</ng-container>
<ng-container matColumnDef="officeName">
<th mat-header-cell *matHeaderCellDef>Branch</th>
<td mat-cell *matCellDef="let r">{{ r.officeName || '-' }}</td>
</ng-container>
<ng-container matColumnDef="panNumber">
<th mat-header-cell *matHeaderCellDef>PAN</th>
<td mat-cell *matCellDef="let r">{{ r.panNumber || '-' }}</td>
</ng-container>
<ng-container matColumnDef="aadhaarNumber">
<th mat-header-cell *matHeaderCellDef>Aadhaar</th>
<td mat-cell *matCellDef="let r">{{ r.aadhaarNumber || '-' }}</td>
</ng-container>
<ng-container matColumnDef="address">
<th mat-header-cell *matHeaderCellDef>Address</th>
<td mat-cell *matCellDef="let r">{{ r.address || '-' }}</td>
</ng-container>
<ng-container matColumnDef="select">
<th mat-header-cell *matHeaderCellDef></th>
<td mat-cell *matCellDef="let r">
<button mat-flat-button color="primary" [disabled]="!r.accountNo" (click)="onSelect(r)">Select</button>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns"></tr>
</table>
</div>
@for (r of results; track $index) {
<div class="csd-result" [class.csd-result--noacc]="!r.accountNo">
<div class="csd-avatar">{{ initials(r.displayName) }}</div>
<div class="csd-result__main">
<div class="csd-result__name">
{{ r.displayName }}
@if (r.accountNo) {
<span class="csd-badge csd-badge--type">{{ accountTypeLabel(r.accountType) }}</span>
<span class="csd-result__acc">{{ r.accountNo }}</span>
} @else {
<span class="csd-badge csd-badge--muted">No account</span>
}
</div>
<div class="csd-result__meta">
<span><mat-icon>call</mat-icon>{{ r.mobileNo || '—' }}</span>
<span><mat-icon>account_balance</mat-icon>{{ r.officeName || '—' }}</span>
<span><mat-icon>credit_card</mat-icon>PAN {{ r.panNumber || '—' }}</span>
<span><mat-icon>fingerprint</mat-icon>Aadhaar {{ r.aadhaarNumber || '—' }}</span>
</div>
@if (r.address) {
<div class="csd-result__addr"><mat-icon>place</mat-icon>{{ r.address }}</div>
}
</div>
<button mat-flat-button color="primary" [disabled]="!canSelect(r)" (click)="onSelect(r)">Select</button>
</div>
}
</div>
}
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-button (click)="onClose()">Close</button>
<button mat-flat-button color="primary" [disabled]="loading" (click)="onSearch()">Search</button>
<mat-dialog-actions align="end" class="csd-actions">
<button mat-button type="button" (click)="onClose()">Close</button>
<button mat-stroked-button type="button" [disabled]="loading" (click)="onClear()">Clear</button>
<button mat-flat-button color="primary" type="button" [disabled]="loading" (click)="onSearch()">
<mat-icon>search</mat-icon> Search
</button>
</mat-dialog-actions>
`,
styles: [
`
.hint-text {
color: #64748b;
font-size: 13px;
margin: 0 0 12px 0;
:host {
display: block;
}
.search-form {
.csd-head {
display: flex;
flex-wrap: wrap;
gap: 12px 16px;
align-items: center;
gap: 14px;
padding: 20px 16px 12px 24px;
}
.field {
flex: 1 1 220px;
.csd-head__icon {
display: grid;
place-items: center;
flex: 0 0 auto;
width: 44px;
height: 44px;
border-radius: 12px;
background: #e6f4ee;
color: #0f4f3c;
}
.field--wide {
flex-basis: 100%;
.csd-head__icon mat-icon {
font-size: 26px;
width: 26px;
height: 26px;
}
.account-type-filter {
.csd-head__text {
flex: 1 1 auto;
min-width: 0;
}
.csd-head__title {
margin: 0;
font-size: 20px;
font-weight: 700;
color: #0f172a;
}
.csd-head__sub {
margin: 2px 0 0;
font-size: 13px;
color: #64748b;
}
.csd-head__close {
flex: 0 0 auto;
color: #64748b;
}
.csd-content {
padding-top: 12px;
}
.csd-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px 14px;
}
.csd-field {
width: 100%;
}
.csd-field--name {
grid-column: span 2;
}
.csd-field--address {
grid-column: span 3;
}
.csd-upper {
text-transform: uppercase;
}
.csd-types {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px 16px;
margin: 4px 0 16px 0;
gap: 8px 12px;
margin-top: 14px;
}
.account-type-filter__label {
.csd-types__label {
font-size: 13px;
font-weight: 600;
color: #475569;
}
.account-type-filter__hint {
.csd-types__pills {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.csd-types__hint {
font-size: 12px;
color: #94a3b8;
}
.results {
margin-top: 16px;
overflow-x: auto;
}
table {
width: 100%;
}
th {
font-weight: 600;
.csd-pill {
display: inline-flex;
align-items: center;
gap: 6px;
height: 32px;
padding: 0 14px 0 10px;
border: 1px solid #cbd5e1;
border-radius: 999px;
background: #fff;
font: inherit;
font-size: 13px;
font-weight: 500;
color: #475569;
background-color: #f8fafc;
cursor: pointer;
transition:
background 0.15s ease,
border-color 0.15s ease,
color 0.15s ease;
}
td {
color: #334155;
}
.muted {
.csd-pill mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
color: #94a3b8;
}
.no-results {
text-align: center;
.csd-pill:hover {
border-color: #94a3b8;
background: #f8fafc;
}
.csd-pill--on,
.csd-pill--on:hover {
border-color: #1f7a4d;
background: #e6f4ee;
font-weight: 600;
color: #0f4f3c;
}
.csd-pill--on mat-icon {
color: #1f7a4d;
}
.csd-results {
margin-top: 20px;
}
.csd-results__head {
margin-bottom: 8px;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
color: #64748b;
padding: 16px 0;
}
.csd-result {
display: flex;
align-items: center;
gap: 14px;
margin-bottom: 8px;
padding: 12px 14px;
border: 1px solid #e2e8f0;
border-radius: 12px;
background: #fff;
transition:
border-color 0.15s ease,
box-shadow 0.15s ease;
}
.csd-result:hover {
border-color: #1f7a4d;
box-shadow: 0 2px 8px rgb(15 23 42 / 8%);
}
.csd-result--noacc {
background: #f8fafc;
}
.csd-avatar {
display: grid;
place-items: center;
flex: 0 0 auto;
width: 42px;
height: 42px;
border-radius: 50%;
background: linear-gradient(135deg, #1f7a4d, #0f4f3c);
font-size: 14px;
font-weight: 700;
color: #fff;
}
.csd-result__main {
flex: 1 1 auto;
min-width: 0;
}
.csd-result__name {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
font-size: 15px;
font-weight: 700;
color: #0f172a;
}
.csd-result__acc {
font-family: ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace;
font-size: 12px;
font-weight: 600;
color: #475569;
}
.csd-badge {
padding: 2px 8px;
border-radius: 6px;
font-size: 11px;
font-weight: 600;
}
.csd-badge--type {
background: #eef2ff;
color: #3730a3;
}
.csd-badge--muted {
background: #f1f5f9;
color: #64748b;
}
.csd-result__meta {
display: flex;
flex-wrap: wrap;
gap: 4px 16px;
margin-top: 4px;
font-size: 12px;
color: #475569;
}
.csd-result__meta span,
.csd-result__addr {
display: inline-flex;
align-items: center;
gap: 4px;
}
.csd-result__meta mat-icon,
.csd-result__addr mat-icon {
font-size: 14px;
width: 14px;
height: 14px;
color: #94a3b8;
}
.csd-result__addr {
display: flex;
align-items: flex-start;
margin-top: 4px;
font-size: 12px;
color: #64748b;
}
.csd-empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
padding: 28px 16px;
border: 1.5px dashed #cbd5e1;
border-radius: 12px;
background: #f8fafc;
text-align: center;
}
.csd-empty mat-icon {
font-size: 36px;
width: 36px;
height: 36px;
color: #94a3b8;
}
.csd-empty__title {
font-size: 14px;
font-weight: 600;
color: #334155;
}
.csd-empty__sub {
font-size: 12px;
color: #64748b;
}
.csd-actions {
gap: 8px;
padding: 12px 24px 20px;
}
@media (max-width: 720px) {
.csd-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.csd-field--address {
grid-column: span 2;
}
.csd-result {
flex-wrap: wrap;
}
}
@media (max-width: 480px) {
.csd-grid {
grid-template-columns: 1fr;
}
.csd-field--name,
.csd-field--address {
grid-column: span 1;
}
}
`
]
@@ -217,20 +534,18 @@ export class CustomerSearchDialogComponent implements OnInit {
private http = inject(HttpClient);
private dialogRef = inject(MatDialogRef<CustomerSearchDialogComponent>);
private snackBar = inject(MatSnackBar);
private data = inject<CustomerSearchDialogData | null>(MAT_DIALOG_DATA, { optional: true });
form!: FormGroup;
loading = false;
searched = false;
results: CustomerSearchResult[] = [];
displayedColumns = [
'displayName',
'account',
'mobileNo',
'officeName',
'panNumber',
'aadhaarNumber',
'address',
'select'
readonly accountTypeOptions: Array<{ key: AccountTypeKey; label: string; icon: string }> = [
{ key: 'savings', label: 'Savings', icon: 'savings' },
{ key: 'current', label: 'Current', icon: 'account_balance_wallet' },
{ key: 'fixedDeposit', label: 'Fixed Deposit', icon: 'lock' },
{ key: 'recurringDeposit', label: 'Recurring Deposit', icon: 'autorenew' }
];
ngOnInit(): void {
@@ -247,6 +562,48 @@ export class CustomerSearchDialogComponent implements OnInit {
});
}
/** The account screens need an account to open; a caller that only needs the customer opts out. */
private get requireAccount(): boolean {
return this.data?.requireAccount !== false;
}
canSelect(r: CustomerSearchResult): boolean {
return !this.requireAccount || !!r.accountNo;
}
isTypeOn(key: AccountTypeKey): boolean {
return !!this.form?.get(key)?.value;
}
toggleType(key: AccountTypeKey): void {
const control = this.form.get(key);
control?.setValue(!control.value);
}
upperCase(name: string): void {
const control = this.form.get(name);
const value = String(control?.value ?? '');
const upper = value.toUpperCase();
if (value !== upper) {
control?.setValue(upper, { emitEvent: false });
}
}
/** Enter in a text box runs the search; on a button it keeps its own meaning. */
onEnter(event: Event): void {
if ((event.target as HTMLElement)?.tagName !== 'INPUT') return;
event.preventDefault();
if (!this.loading) this.onSearch();
}
initials(name: string | null): string {
const words = (name || '').trim().split(/\s+/).filter(Boolean);
if (words.length === 0) return '?';
const first = words[0].charAt(0);
const last = words.length > 1 ? words[words.length - 1].charAt(0) : '';
return (first + last).toUpperCase();
}
accountTypeLabel(type: string | null): string {
switch (type) {
case 'SAVINGS':
@@ -297,6 +654,22 @@ export class CustomerSearchDialogComponent implements OnInit {
});
}
onClear(): void {
this.form.reset({
name: '',
accountNumber: '',
aadhaarNumber: '',
panNumber: '',
address: '',
savings: false,
current: false,
fixedDeposit: false,
recurringDeposit: false
});
this.results = [];
this.searched = false;
}
onSelect(result: CustomerSearchResult): void {
this.dialogRef.close(result);
}
@@ -144,6 +144,24 @@
<fa-icon icon="money-bill-wave" class="ac-tab__ic"></fa-icon>
<span>Withdrawal Requests</span>
</a>
<a
class="ac-tab"
routerLink="./cash-transfers"
routerLinkActive="ac-tab--active"
*mifosxHasPermission="'ALL_FUNCTIONS_READ'"
>
<fa-icon icon="exchange" class="ac-tab__ic"></fa-icon>
<span>Cash Transfers</span>
</a>
<a
class="ac-tab"
routerLink="./voucher-corrections"
routerLinkActive="ac-tab--active"
*mifosxHasPermission="'ALL_FUNCTIONS_READ'"
>
<fa-icon icon="undo" class="ac-tab__ic"></fa-icon>
<span>Voucher Corrections</span>
</a>
<a
class="ac-tab"
routerLink="./share-withdrawal-requests"
@@ -16,7 +16,7 @@ $green: #10b981;
$amber: #f59e0b;
.ac-container {
padding: 20px 24px;
padding: 12px 20px;
max-width: 1440px;
margin: 0 auto;
}
@@ -25,40 +25,40 @@ $amber: #f59e0b;
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
margin-bottom: 10px;
gap: 12px;
flex-wrap: wrap;
&__left {
display: flex;
align-items: center;
gap: 12px;
gap: 10px;
}
&__icon {
width: 44px;
height: 44px;
width: 32px;
height: 32px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 12px;
border-radius: 9px;
background: linear-gradient(135deg, $blue 0%, $purple 100%);
color: #fff;
font-size: 18px;
font-size: 14px;
box-shadow: 0 2px 8px rgb(29 78 216 / 25%);
}
&__title {
margin: 0;
font-size: 20px;
font-size: 16px;
font-weight: 800;
color: $navy;
line-height: 1.1;
}
&__sub {
margin-top: 2px;
font-size: 12.5px;
margin-top: 1px;
font-size: 11px;
color: #64748b;
}
}
@@ -68,26 +68,26 @@ $amber: #f59e0b;
hides most of them behind prev/next pagination arrows. Wrapping keeps every tab visible and one
click away instead of a scroll-and-hunt. */
.ac-tabs {
margin-bottom: 20px;
margin-bottom: 12px;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 14px;
padding: 6px;
border-radius: 10px;
padding: 4px;
box-shadow: 0 1px 3px rgb(15 23 42 / 4%);
display: flex;
flex-wrap: wrap;
gap: 4px;
gap: 2px;
}
.ac-tab {
display: inline-flex;
align-items: center;
gap: 8px;
height: 38px;
padding: 0 18px;
border-radius: 10px;
gap: 6px;
height: 28px;
padding: 0 11px;
border-radius: 8px;
font-family: Inter, 'DM Sans', Roboto, sans-serif;
font-size: 13px;
font-size: 11.5px;
font-weight: 600;
letter-spacing: 0.01em;
color: #64748b;
@@ -99,7 +99,7 @@ $amber: #f59e0b;
cursor: pointer;
&__ic {
font-size: 13px;
font-size: 11px;
color: inherit;
}
@@ -118,7 +118,7 @@ $amber: #f59e0b;
@media (width <= 768px) {
.ac-tab {
padding: 0 14px;
font-size: 12px;
padding: 0 10px;
font-size: 11px;
}
}
+16
View File
@@ -194,6 +194,22 @@ const routes: Routes = [
component: ShareWithdrawalRequestsComponent,
data: { title: 'Share Withdrawal Requests' }
},
{
path: 'cash-transfers',
loadComponent: () =>
import('../cashier/cash-transfer-approvals/cash-transfer-approvals.component').then(
(m) => m.CashTransferApprovalsComponent
),
data: { title: 'Cash Transfers' }
},
{
path: 'voucher-corrections',
loadComponent: () =>
import('../cashier/voucher-correction-approvals/voucher-correction-approvals.component').then(
(m) => m.VoucherCorrectionApprovalsComponent
),
data: { title: 'Voucher Corrections' }
},
{
path: 'dividend-requests',
component: DividendRequestsComponent,