feat: cash-only registers, day book sections and DD amendments
This commit is contained in:
@@ -20,8 +20,6 @@ import { CashierPositionComponent } from './cashier-position/cashier-position.co
|
||||
import { ReconciliationComponent } from './reconciliation/reconciliation.component';
|
||||
import { CashHistoryComponent } from './cash-history/cash-history.component';
|
||||
import { DayBookComponent } from './day-book/day-book.component';
|
||||
import { VoucherExceptionsComponent } from './voucher-exceptions/voucher-exceptions.component';
|
||||
import { VoucherViewComponent } from './voucher-view/voucher-view.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';
|
||||
@@ -226,20 +224,23 @@ const routes: Routes = [
|
||||
component: DayBookComponent,
|
||||
data: { title: 'Day Book', breadcrumb: 'Day Book' }
|
||||
},
|
||||
// The register is read one side at a time: Received is the CR side, Payment the DR side.
|
||||
// Same component both times — the side it shows comes from `data.side`.
|
||||
//
|
||||
// `cashOnly` is what makes these the cashier's own registers rather than the whole voucher
|
||||
// book: a cashier deals in notes and coins, so only vouchers that actually moved cash
|
||||
// belong here, and the two reports then reconcile against the drawer and the closing
|
||||
// report. A back-office screen wanting every voucher points at the same component without
|
||||
// the flag.
|
||||
{
|
||||
path: 'voucher-exceptions',
|
||||
component: VoucherExceptionsComponent,
|
||||
data: { title: 'Voucher Exceptions', breadcrumb: 'Voucher Exceptions' }
|
||||
},
|
||||
{
|
||||
path: 'voucher',
|
||||
component: VoucherViewComponent,
|
||||
data: { title: 'Voucher', breadcrumb: 'Voucher' }
|
||||
},
|
||||
{
|
||||
path: 'voucher-report',
|
||||
path: 'received-report',
|
||||
component: VoucherReportComponent,
|
||||
data: { title: 'Voucher Report', breadcrumb: 'Voucher Report' }
|
||||
data: { title: 'Received Report', breadcrumb: 'Received Report', side: 'CR', cashOnly: true }
|
||||
},
|
||||
{
|
||||
path: 'payment-report',
|
||||
component: VoucherReportComponent,
|
||||
data: { title: 'Payment Report', breadcrumb: 'Payment Report', side: 'DR', cashOnly: true }
|
||||
},
|
||||
{
|
||||
path: 'closing-report',
|
||||
|
||||
@@ -312,6 +312,47 @@
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
// Names the part of a total that has no row in the table below, so the figures do not read as
|
||||
// failing to add up.
|
||||
&__note {
|
||||
margin-top: 2px;
|
||||
font-size: 10px;
|
||||
line-height: 1.4;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
// The drawer strip sits under the counter totals and is the figure a physical count is checked
|
||||
// against, so it is marked out as the one to read rather than a second row of the same thing.
|
||||
&--balance {
|
||||
margin-top: 10px;
|
||||
border-color: #cbd5e1;
|
||||
box-shadow: 0 1px 3px rgb(15 23 42 / 8%);
|
||||
}
|
||||
}
|
||||
|
||||
.closing-note {
|
||||
margin: 8px 0 0;
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
// Says what a row without a denomination is, next to its chip.
|
||||
.closing-row__hint {
|
||||
margin-left: 6px;
|
||||
font-size: 11px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
// A totals row, marked off from the rows it sums. Defined here rather than only in the day book's
|
||||
// own stylesheet, which the other cashier screens do not load.
|
||||
.pg-table tfoot td {
|
||||
background: #f8fafc;
|
||||
font-weight: 800;
|
||||
font-size: 12px;
|
||||
color: #334155;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.pg-step {
|
||||
|
||||
@@ -126,66 +126,6 @@ export class CashierService {
|
||||
return this.http.get('/cashreports/day-book', { params });
|
||||
}
|
||||
|
||||
// ── Voucher physical verification & attachments ──
|
||||
// The voucher number always travels in the body or a query parameter, never as a path segment:
|
||||
// numbers are formatted CR/001/2108/0001 and Spring Security's strict firewall rejects the
|
||||
// encoded slashes with a 400 before the request reaches the resource.
|
||||
|
||||
/** Records the outcome of checking a voucher against the paper slip. */
|
||||
verifyVoucher(
|
||||
voucherNo: string,
|
||||
status: 'VERIFIED' | 'EXCEPTION',
|
||||
slipAmount?: number,
|
||||
remarks?: string
|
||||
): Observable<any> {
|
||||
return this.http.post('/vouchers/verify', {
|
||||
voucherNo,
|
||||
status,
|
||||
slipAmount: slipAmount != null ? String(slipAmount) : null,
|
||||
remarks: remarks ?? null,
|
||||
locale: 'en'
|
||||
});
|
||||
}
|
||||
|
||||
uploadVoucherAttachment(
|
||||
voucherNo: string,
|
||||
fileName: string,
|
||||
contentType: string,
|
||||
fileData: string,
|
||||
description?: string
|
||||
): Observable<any> {
|
||||
return this.http.post('/vouchers/attachments', {
|
||||
voucherNo,
|
||||
fileName,
|
||||
contentType,
|
||||
fileData,
|
||||
description: description ?? null
|
||||
});
|
||||
}
|
||||
|
||||
listVoucherAttachments(voucherNo: string): Observable<any> {
|
||||
const params = new HttpParams().set('voucherNo', voucherNo);
|
||||
return this.http.get('/vouchers/attachments', { params });
|
||||
}
|
||||
|
||||
deleteVoucherAttachment(id: number): Observable<any> {
|
||||
return this.http.delete(`/vouchers/attachments/${id}`);
|
||||
}
|
||||
|
||||
/** Fetched as a blob through HttpClient so the interceptor's auth header travels with it. */
|
||||
downloadVoucherAttachment(id: number): Observable<Blob> {
|
||||
return this.http.get(`/vouchers/attachments/${id}/download`, { responseType: 'blob' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Flagged vouchers awaiting (or carrying) a conclusion. Not scoped to a date — an exception
|
||||
* raised on Friday still needs answering on Monday.
|
||||
*/
|
||||
voucherExceptions(status: 'OPEN' | 'RESOLVED' | 'ALL' = 'OPEN'): Observable<any> {
|
||||
const params = new HttpParams().set('status', status);
|
||||
return this.http.get('/vouchers/exceptions', { params });
|
||||
}
|
||||
|
||||
/**
|
||||
* The vouchers raised over a period, one row each, with what was debited and what credited.
|
||||
*
|
||||
@@ -193,7 +133,13 @@ export class CashierService {
|
||||
* own work whatever they ask for — so `username` is a filter for those allowed to use it, not a
|
||||
* way to choose whose report this is.
|
||||
*/
|
||||
voucherReport(from: string, to: string, username?: string, voucherType?: string): Observable<any> {
|
||||
voucherReport(
|
||||
from: string,
|
||||
to: string,
|
||||
username?: string,
|
||||
voucherType?: string,
|
||||
cashOnly?: boolean
|
||||
): Observable<any> {
|
||||
let params = new HttpParams().set('from', from).set('to', to);
|
||||
if (username) {
|
||||
params = params.set('username', username);
|
||||
@@ -201,25 +147,14 @@ export class CashierService {
|
||||
if (voucherType) {
|
||||
params = params.set('voucherType', voucherType);
|
||||
}
|
||||
// Only sent when asked for: the server treats a missing flag as the full register, so the
|
||||
// cashier screens opt in to cash and the back-office ones keep seeing everything.
|
||||
if (cashOnly) {
|
||||
params = params.set('cashOnly', 'true');
|
||||
}
|
||||
return this.http.get('/vouchers/report', { params });
|
||||
}
|
||||
|
||||
/**
|
||||
* One voucher and the debit and credit sides it is made of — what a printed voucher shows.
|
||||
*
|
||||
* The voucher number travels as a query parameter, not a path segment: numbers are formatted
|
||||
* CR/2708/0001 and the server rejects the encoded slashes in a path.
|
||||
*/
|
||||
voucherDetail(voucherNo: string): Observable<any> {
|
||||
const params = new HttpParams().set('voucherNo', voucherNo);
|
||||
return this.http.get('/vouchers/detail', { params });
|
||||
}
|
||||
|
||||
/** Closes an exception. Manager / head cashier only, and a supporting document must exist. */
|
||||
resolveVoucherException(voucherNo: string, resolutionStatus: string, resolutionNotes: string): Observable<any> {
|
||||
return this.http.post('/vouchers/exceptions/resolve', { voucherNo, resolutionStatus, resolutionNotes });
|
||||
}
|
||||
|
||||
/**
|
||||
* Account register for one date: what opened and closed that day, each account's balance as it
|
||||
* stood then, and what it holds now.
|
||||
|
||||
@@ -27,12 +27,23 @@
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@if (!showGl && rows.length > 0) {
|
||||
<!-- Shown whenever the day has any cash at all, not only when a denomination breakdown exists:
|
||||
a cash Demand Draft is recorded without one, and gating on rows hid its money entirely. -->
|
||||
@if (!showGl && (rows.length > 0 || ddCashTotal > 0)) {
|
||||
<!-- Summary totals bar -->
|
||||
<div class="closing-summary">
|
||||
<div class="closing-summary__item closing-summary__item--in">
|
||||
<span class="closing-summary__label">Total Deposits (IN)</span>
|
||||
<span class="closing-summary__value">₹{{ depositTotal | number: '1.2-2' }}</span>
|
||||
<span class="closing-summary__value">₹{{ depositTotalWithDd | number: '1.2-2' }}</span>
|
||||
@if (ddCashTotal > 0) {
|
||||
<!-- Named on the tile as well as in the table: this part of the total carries no
|
||||
denomination, so without saying so the figures read as failing to add up. -->
|
||||
<span class="closing-summary__note">
|
||||
incl. ₹{{ ddCashTotal | number: '1.2-2' }} not itemised ({{ ddCashCount }} cash DD{{
|
||||
ddCashCount === 1 ? '' : 's'
|
||||
}})
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
<div class="closing-summary__sep">−</div>
|
||||
<div class="closing-summary__item closing-summary__item--out">
|
||||
@@ -45,15 +56,57 @@
|
||||
[class.closing-summary__item--net-pos]="netTotal >= 0"
|
||||
[class.closing-summary__item--net-neg]="netTotal < 0"
|
||||
>
|
||||
<span class="closing-summary__label">Net Closing Cash</span>
|
||||
<!-- Named for what it is: the counter's own movement. It was labelled "Net Closing Cash",
|
||||
which it is not -- it carries nothing forward from yesterday and leaves out vault and
|
||||
inter-cashier legs, so it can differ from the drawer by a large amount. The real
|
||||
closing figure is in the balance strip below. -->
|
||||
<span class="closing-summary__label">Net Counter Movement</span>
|
||||
<span class="closing-summary__value">₹{{ netTotal | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Opening → closing, the figure a physical count is checked against. Shown only for a
|
||||
cashier-scoped report; a branch-wide one has no single drawer to open and close. -->
|
||||
@if (!showGl && scopedToCashier && closingBalance !== null) {
|
||||
<div class="closing-summary closing-summary--balance">
|
||||
<div class="closing-summary__item">
|
||||
<span class="closing-summary__label">Opening Balance</span>
|
||||
<span class="closing-summary__value">₹{{ openingBalance | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
<div class="closing-summary__sep">+</div>
|
||||
<div
|
||||
class="closing-summary__item"
|
||||
[class.closing-summary__item--in]="(cashMovement ?? 0) >= 0"
|
||||
[class.closing-summary__item--out]="(cashMovement ?? 0) < 0"
|
||||
>
|
||||
<span class="closing-summary__label">Cash Movement</span>
|
||||
<span class="closing-summary__value">₹{{ cashMovement | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
<div class="closing-summary__sep">=</div>
|
||||
<div
|
||||
class="closing-summary__item"
|
||||
[class.closing-summary__item--net-pos]="(closingBalance ?? 0) >= 0"
|
||||
[class.closing-summary__item--net-neg]="(closingBalance ?? 0) < 0"
|
||||
>
|
||||
<span class="closing-summary__label">Closing Balance</span>
|
||||
<span class="closing-summary__value">₹{{ closingBalance | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@if (cashMovement !== null && netTotal !== cashMovement) {
|
||||
<!-- The gap between the two is not an error: it is the vault, inter-cashier and exchange
|
||||
traffic that never crossed the counter. Said plainly, because two different "net"
|
||||
figures on one screen otherwise read as a bug. -->
|
||||
<p class="closing-note">
|
||||
Cash movement differs from counter movement by ₹{{ cashMovement - netTotal | number: '1.2-2' }} — vault,
|
||||
inter-cashier and exchange legs that did not pass over the counter.
|
||||
</p>
|
||||
}
|
||||
}
|
||||
|
||||
@if (!showGl) {
|
||||
<div class="pg-card">
|
||||
@if (noteRows.length > 0) {
|
||||
@if (noteRows.length > 0 || ddCashTotal > 0) {
|
||||
<div class="pos-section-label">Notes</div>
|
||||
<table class="pg-table">
|
||||
<thead>
|
||||
@@ -93,7 +146,42 @@
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
|
||||
<!-- Cash taken for a Demand Draft, on the deposit side where it belongs. It has no
|
||||
denomination to sit against -- the notes were never itemised -- so the piece
|
||||
counts show a dash rather than a zero, which would read as "no notes". -->
|
||||
@if (ddCashTotal > 0) {
|
||||
<tr>
|
||||
<td>
|
||||
<span class="pg-chip">Not itemised</span>
|
||||
<span class="closing-row__hint"> {{ ddCashCount }} cash DD{{ ddCashCount === 1 ? '' : 's' }} </span>
|
||||
</td>
|
||||
<td class="num col-in">—</td>
|
||||
<td class="num col-in">₹{{ ddCashTotal | number: '1.2-2' }}</td>
|
||||
<td class="num col-out">—</td>
|
||||
<td class="num col-out">₹0.00</td>
|
||||
<td class="num col-net">—</td>
|
||||
<td class="num col-net" style="color: #16a34a">+₹{{ ddCashTotal | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
<!-- Totals, so the table reconciles against the summary above rather than having to be
|
||||
added up by hand — and so the deposit column visibly accounts for the DD row. -->
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td>Total</td>
|
||||
<td class="num col-in">{{ noteDepositCount }}</td>
|
||||
<td class="num col-in">₹{{ noteDepositAmount | number: '1.2-2' }}</td>
|
||||
<td class="num col-out">{{ noteWithdrawalCount }}</td>
|
||||
<td class="num col-out">₹{{ noteWithdrawalAmount | number: '1.2-2' }}</td>
|
||||
<td class="num col-net"></td>
|
||||
<td class="num col-net">
|
||||
{{ noteNetAmount >= 0 ? '+' : '−' }}₹{{
|
||||
(noteNetAmount < 0 ? -noteNetAmount : noteNetAmount) | number: '1.2-2'
|
||||
}}
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
}
|
||||
|
||||
@@ -141,8 +229,20 @@
|
||||
</table>
|
||||
}
|
||||
|
||||
@if (loaded && rows.length === 0 && exchangeRows.length === 0) {
|
||||
<div class="pg-empty">No cash denomination data for this date.</div>
|
||||
<!-- Not shown when a Demand Draft row is on the table: there is something to read then, and
|
||||
an "empty" notice under it contradicts it. -->
|
||||
@if (loaded && rows.length === 0 && exchangeRows.length === 0 && ddCashTotal === 0) {
|
||||
<!-- A day with no counter work is not an empty report: the cashier is still holding
|
||||
whatever they held yesterday, and that balance is shown in the strip above. Saying
|
||||
only "no data" here read as though the drawer were empty. -->
|
||||
<div class="pg-empty">
|
||||
@if (scopedToCashier && closingBalance !== null) {
|
||||
No cash crossed the counter on this date. The drawer still holds ₹{{ closingBalance | number: '1.2-2' }},
|
||||
carried forward.
|
||||
} @else {
|
||||
No cash denomination data for this date.
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -38,6 +38,38 @@ export class ClosingCashReportComponent implements OnInit {
|
||||
loading = false;
|
||||
loaded = false;
|
||||
|
||||
/**
|
||||
* Cash taken at the counter for a Demand Draft.
|
||||
*
|
||||
* A cash DD asks for a denomination breakdown but does not require one, and this report is built
|
||||
* entirely from breakdowns — so that cash was missing from the drawer figure even though the
|
||||
* ledger held it and the cashier held the notes. It is counted from the request itself and shown
|
||||
* on its own line, because the denomination rows below cannot account for it.
|
||||
*/
|
||||
ddCashTotal = 0;
|
||||
ddCashCount = 0;
|
||||
/** Deposits including the DD cash — the figure the drawer is actually checked against. */
|
||||
depositTotalWithDd = 0;
|
||||
|
||||
/**
|
||||
* The drawer, opening to closing.
|
||||
*
|
||||
* `netTotal` above is only the day's counter movement — deposits less withdrawals. It is not what
|
||||
* the cashier is holding, because it carries nothing forward from yesterday and ignores vault
|
||||
* allocations and inter-cashier legs. The closing balance is the figure that has to match a
|
||||
* physical count at the end of the day, so it is reported separately rather than inferred from
|
||||
* the counter totals.
|
||||
*
|
||||
* Null when the server did not scope the report to one cashier: a branch-wide opening balance is
|
||||
* the branch day register's business, not this report's.
|
||||
*/
|
||||
openingBalance: number | null = null;
|
||||
closingBalance: number | null = null;
|
||||
/** Every leg of the day's cash movement, which is wider than deposits less withdrawals. */
|
||||
cashMovement: number | null = null;
|
||||
/** Whether the figures above are one cashier's drawer or the whole branch's counter work. */
|
||||
scopedToCashier = false;
|
||||
|
||||
// GL View
|
||||
showGl = false;
|
||||
glRows: any[] = [];
|
||||
@@ -66,6 +98,33 @@ export class ClosingCashReportComponent implements OnInit {
|
||||
get noteRows(): any[] {
|
||||
return this.rows.filter((r) => r.kind !== 'coin');
|
||||
}
|
||||
|
||||
/**
|
||||
* Totals for the notes table, including the un-itemised Demand Draft cash.
|
||||
*
|
||||
* The DD row carries an amount but no piece count, so pieces and amounts are summed separately
|
||||
* rather than one being derived from the other — a denomination times a count would not reach the
|
||||
* DD's figure, and adding a fictional count to make it balance would misstate the drawer.
|
||||
*/
|
||||
get noteDepositCount(): number {
|
||||
return this.noteRows.reduce((s, r) => s + Number(r.depositCount ?? 0), 0);
|
||||
}
|
||||
|
||||
get noteDepositAmount(): number {
|
||||
return this.noteRows.reduce((s, r) => s + Number(r.depositAmount ?? 0), 0) + this.ddCashTotal;
|
||||
}
|
||||
|
||||
get noteWithdrawalCount(): number {
|
||||
return this.noteRows.reduce((s, r) => s + Number(r.withdrawalCount ?? 0), 0);
|
||||
}
|
||||
|
||||
get noteWithdrawalAmount(): number {
|
||||
return this.noteRows.reduce((s, r) => s + Number(r.withdrawalAmount ?? 0), 0);
|
||||
}
|
||||
|
||||
get noteNetAmount(): number {
|
||||
return this.noteDepositAmount - this.noteWithdrawalAmount;
|
||||
}
|
||||
get coinRows(): any[] {
|
||||
return this.rows.filter((r) => r.kind === 'coin');
|
||||
}
|
||||
@@ -92,6 +151,15 @@ export class ClosingCashReportComponent implements OnInit {
|
||||
this.depositTotal = Number(res?.depositTotal || 0);
|
||||
this.withdrawalTotal = Number(res?.withdrawalTotal || 0);
|
||||
this.netTotal = Number(res?.netTotal || 0);
|
||||
this.ddCashTotal = Number(res?.ddCashTotal || 0);
|
||||
this.ddCashCount = Number(res?.ddCashCount || 0);
|
||||
this.depositTotalWithDd = Number(res?.depositTotalWithDd ?? res?.depositTotal ?? 0);
|
||||
this.scopedToCashier = res?.scopedToCashier === true;
|
||||
// Sent only for a cashier-scoped call, and read as absent rather than zero when missing —
|
||||
// a zero opening balance and no opening balance at all are different statements.
|
||||
this.openingBalance = res?.openingBalance == null ? null : Number(res.openingBalance);
|
||||
this.closingBalance = res?.closingBalance == null ? null : Number(res.closingBalance);
|
||||
this.cashMovement = res?.cashMovement == null ? null : Number(res.cashMovement);
|
||||
this.loading = false;
|
||||
this.loaded = true;
|
||||
},
|
||||
@@ -101,6 +169,13 @@ export class ClosingCashReportComponent implements OnInit {
|
||||
this.depositTotal = 0;
|
||||
this.withdrawalTotal = 0;
|
||||
this.netTotal = 0;
|
||||
this.ddCashTotal = 0;
|
||||
this.ddCashCount = 0;
|
||||
this.depositTotalWithDd = 0;
|
||||
this.scopedToCashier = false;
|
||||
this.openingBalance = null;
|
||||
this.closingBalance = null;
|
||||
this.cashMovement = null;
|
||||
this.loading = false;
|
||||
this.loaded = true;
|
||||
}
|
||||
|
||||
@@ -65,42 +65,6 @@
|
||||
@if (errorMsg) {
|
||||
<div class="pg-alert pg-alert--error">{{ errorMsg }}</div>
|
||||
}
|
||||
|
||||
<!-- Exceptions surface at the top of the day, not only as a badge buried in their own row: a
|
||||
flag raised for investigation is useless if someone has to scroll to find it. -->
|
||||
@if (!loading && exceptionRows.length > 0) {
|
||||
<div class="db-exceptions">
|
||||
<div class="db-exceptions__head">
|
||||
<mat-icon>warning</mat-icon>
|
||||
<strong>
|
||||
{{ exceptionRows.length }} voucher{{ exceptionRows.length === 1 ? '' : 's' }} awaiting investigation
|
||||
</strong>
|
||||
@if (resolvedExceptionCount > 0) {
|
||||
<span class="db-muted">({{ resolvedExceptionCount }} already closed)</span>
|
||||
}
|
||||
<button type="button" class="pg-btn pg-btn--warn" (click)="toggleExceptionsOnly()">
|
||||
{{ exceptionsOnly ? 'Show all vouchers' : 'Show only these' }}
|
||||
</button>
|
||||
</div>
|
||||
@for (e of exceptionRows; track e.voucherNo) {
|
||||
<div class="db-exceptions__item">
|
||||
<span class="pg-chip">{{ e.voucherNo }}</span>
|
||||
<span class="db-exceptions__amt">
|
||||
System ₹{{ e.amount | number: '1.2-2' }}
|
||||
@if (e.slipAmount != null && e.slipAmount !== e.amount) {
|
||||
· Slip ₹{{ e.slipAmount | number: '1.2-2' }}
|
||||
<strong class="db-exceptions__diff"
|
||||
>(diff ₹{{ e.slipAmount - (e.amount ?? 0) | number: '1.2-2' }})</strong
|
||||
>
|
||||
}
|
||||
</span>
|
||||
<span class="db-exceptions__why">{{ e.verifyRemarks }}</span>
|
||||
<span class="db-muted">— {{ e.verifiedByName }}, {{ timeOf(e.verifiedAt) }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (loading) {
|
||||
<div class="pg-empty">Loading…</div>
|
||||
}
|
||||
@@ -139,252 +103,87 @@
|
||||
Totals count approved vouchers only — pending and rejected rows are listed but never move the money.
|
||||
</p>
|
||||
|
||||
<div class="pg-card">
|
||||
<table class="pg-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Voucher No</th>
|
||||
<th>Dr/Cr</th>
|
||||
<th>Type</th>
|
||||
<th>Account</th>
|
||||
<th>Party</th>
|
||||
<th class="num">Amount</th>
|
||||
<th>Payment</th>
|
||||
<th>Status</th>
|
||||
<th>By</th>
|
||||
<th>Approved By</th>
|
||||
<th>Slip Check</th>
|
||||
<th>Time</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (r of filteredRows; track r.voucherNo ?? r.submittedAt) {
|
||||
<tr [class.db-row--cr]="r.side === 'CR'" [class.db-row--dr]="r.side === 'DR'">
|
||||
<td>
|
||||
<span class="pg-chip">{{ r.voucherNo ?? '—' }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="db-side" [class.db-side--cr]="r.side === 'CR'" [class.db-side--dr]="r.side === 'DR'">
|
||||
{{ r.side }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ r.kind }}</td>
|
||||
<td>
|
||||
{{ r.accountNo ?? '—' }}
|
||||
@if (r.accountType) {
|
||||
<span class="db-muted">· {{ r.accountType }}</span>
|
||||
}
|
||||
</td>
|
||||
<td>{{ r.party ?? '—' }}</td>
|
||||
<td class="num">₹{{ r.amount | number: '1.2-2' }}</td>
|
||||
<td>{{ r.paymentTypeName ?? '—' }}</td>
|
||||
<td>
|
||||
<span [class]="statusClass(r.status)">{{ r.status }}</span>
|
||||
</td>
|
||||
<td>{{ r.handledBy ?? '—' }}</td>
|
||||
<td>
|
||||
{{ r.approvedBy ?? '—' }}
|
||||
@if (r.approvedBy && r.actionedAt) {
|
||||
<span class="db-muted">· {{ timeOf(r.actionedAt) }}</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
@if (r.verifyStatus === 'VERIFIED') {
|
||||
<span class="db-badge db-badge--ok" [title]="'Verified by ' + (r.verifiedByName ?? '')"
|
||||
>✓ MATCHED</span
|
||||
>
|
||||
} @else if (r.verifyStatus === 'EXCEPTION' && r.resolutionStatus) {
|
||||
<!-- Closed: the flag is history now, so it reads as settled rather than pending. -->
|
||||
<span
|
||||
class="db-badge db-badge--ok"
|
||||
[title]="resolutionLabel(r.resolutionStatus) + ' — ' + (r.resolutionNotes ?? '')"
|
||||
>
|
||||
✓ RESOLVED
|
||||
</span>
|
||||
} @else if (r.verifyStatus === 'EXCEPTION') {
|
||||
<span
|
||||
class="db-badge db-badge--rejected"
|
||||
[title]="'Slip ₹' + r.slipAmount + ' vs system ₹' + r.amount + ' — ' + (r.verifyRemarks ?? '')"
|
||||
>
|
||||
⚠ EXCEPTION
|
||||
</span>
|
||||
} @else {
|
||||
<span class="db-muted">Not checked</span>
|
||||
}
|
||||
</td>
|
||||
<td>{{ timeOf(r.submittedAt) }}</td>
|
||||
<td class="db-actions">
|
||||
<button type="button" class="db-icon-btn" (click)="openVerify(r)" title="Check against the paper slip">
|
||||
<mat-icon>fact_check</mat-icon>
|
||||
</button>
|
||||
<button type="button" class="db-icon-btn" (click)="openAttach(r)" title="Attach or view scanned slip">
|
||||
<mat-icon>attach_file</mat-icon>
|
||||
@if (r.attachmentCount > 0) {
|
||||
<span class="db-icon-btn__count">{{ r.attachmentCount }}</span>
|
||||
}
|
||||
</button>
|
||||
</td>
|
||||
<!-- One table per source register rather than one long list. The two read differently: the
|
||||
cashier's rows are cash that actually crossed the counter, the clerk's are requests nobody
|
||||
has actioned yet. Mixed together they invited the wrong sum. -->
|
||||
@for (s of sections; track s.key) {
|
||||
<div class="pg-card db-section">
|
||||
<div class="db-section__head">
|
||||
<strong>{{ s.label }}</strong>
|
||||
<span class="db-muted">{{ s.hint }}</span>
|
||||
<span class="db-section__count">{{ s.rows.length }} row{{ s.rows.length === 1 ? '' : 's' }}</span>
|
||||
</div>
|
||||
<table class="pg-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Voucher No</th>
|
||||
<th>Dr/Cr</th>
|
||||
<th>Type</th>
|
||||
<th>Account</th>
|
||||
<th>Party</th>
|
||||
<th class="num">Amount</th>
|
||||
<th>Payment</th>
|
||||
<th>Status</th>
|
||||
<th>By</th>
|
||||
<th>Approved By</th>
|
||||
<th>Time</th>
|
||||
</tr>
|
||||
|
||||
<!-- A flagged voucher spells out its reason in the table itself. A tooltip is not
|
||||
enough: it cannot be printed, exported, or read without a mouse. -->
|
||||
@if (r.verifyStatus === 'EXCEPTION' && verifyRow !== r) {
|
||||
<tr class="db-exc-row" [class.db-exc-row--resolved]="r.resolutionStatus">
|
||||
<td colspan="13">
|
||||
<mat-icon>{{ r.resolutionStatus ? 'task_alt' : 'warning' }}</mat-icon>
|
||||
<strong>Exception</strong>
|
||||
@if (r.slipAmount != null && r.slipAmount !== r.amount) {
|
||||
— slip ₹{{ r.slipAmount | number: '1.2-2' }} vs system ₹{{ r.amount | number: '1.2-2' }} (difference
|
||||
₹{{ r.slipAmount - (r.amount ?? 0) | number: '1.2-2' }})
|
||||
}
|
||||
— {{ r.verifyRemarks }}
|
||||
<span class="db-muted">· raised by {{ r.verifiedByName }} at {{ timeOf(r.verifiedAt) }}</span>
|
||||
<!-- Both halves of the record: what the cashier found, and how it was settled. -->
|
||||
@if (r.resolutionStatus) {
|
||||
<div class="db-exc-row__resolution">
|
||||
<strong>Closed — {{ resolutionLabel(r.resolutionStatus) }}:</strong>
|
||||
{{ r.resolutionNotes }}
|
||||
<span class="db-muted">· by {{ r.resolvedByName }} at {{ timeOf(r.resolvedAt) }}</span>
|
||||
</div>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (r of s.rows; track r.voucherNo ?? r.section + r.txnId) {
|
||||
<tr [class.db-row--cr]="r.side === 'CR'" [class.db-row--dr]="r.side === 'DR'">
|
||||
<td>
|
||||
<span class="pg-chip">{{ r.voucherNo ?? '—' }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="db-side" [class.db-side--cr]="r.side === 'CR'" [class.db-side--dr]="r.side === 'DR'">
|
||||
{{ r.side }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ r.kind }}</td>
|
||||
<td>
|
||||
{{ r.accountNo ?? '—' }}
|
||||
@if (r.accountType) {
|
||||
<span class="db-muted">· {{ r.accountType }}</span>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
|
||||
<!-- Verification panel, opened inline under its own voucher so the two amounts sit
|
||||
next to the row they describe. -->
|
||||
@if (verifyRow === r) {
|
||||
<tr class="db-panel-row">
|
||||
<td colspan="13">
|
||||
<div class="db-panel">
|
||||
<div class="db-panel__head">
|
||||
<strong>Slip check — {{ r.voucherNo }}</strong>
|
||||
<button type="button" class="db-icon-btn" (click)="closeVerify()" title="Close">
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
<div class="db-panel__body">
|
||||
<div class="db-cmp">
|
||||
<span class="db-cmp__label">System recorded</span>
|
||||
<span class="db-cmp__value">₹{{ r.amount | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
<label class="pg-field">
|
||||
<span>Amount written on slip</span>
|
||||
<input type="number" step="0.01" [(ngModel)]="verifySlipAmount" />
|
||||
</label>
|
||||
@if (verifySlipBlank) {
|
||||
<div class="db-cmp">
|
||||
<span class="db-cmp__label">Difference</span>
|
||||
<span class="db-cmp__value">—</span>
|
||||
</div>
|
||||
} @else if (verifyMismatch) {
|
||||
<div class="db-cmp db-cmp--bad">
|
||||
<span class="db-cmp__label">Difference</span>
|
||||
<span class="db-cmp__value">₹{{ verifyDifference | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="db-cmp db-cmp--good">
|
||||
<span class="db-cmp__label">Match</span>
|
||||
<span class="db-cmp__value">Amounts agree</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<label class="pg-field db-panel__remarks">
|
||||
<span>{{ verifyMismatch ? 'What did you find? (required)' : 'Remarks (optional)' }}</span>
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="verifyRemarks"
|
||||
placeholder="e.g. slip written as 500, system posted 400 — teller keyed it wrong"
|
||||
/>
|
||||
</label>
|
||||
@if (verifyMismatch) {
|
||||
<p class="db-panel__warn">
|
||||
Saving this marks the voucher as an <strong>exception</strong> for investigation — it does not
|
||||
change the posted amount.
|
||||
</p>
|
||||
}
|
||||
<div class="db-panel__actions">
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--primary"
|
||||
(click)="saveVerify()"
|
||||
[disabled]="verifySaving || verifySlipBlank"
|
||||
>
|
||||
{{ verifyMismatch ? 'Mark Exception' : 'Mark Verified' }}
|
||||
</button>
|
||||
<!-- Raising an exception on an already-matched voucher stays available: a
|
||||
slip cleared in haste, or a difference noticed later, has to be
|
||||
recordable without erasing the earlier verdict. -->
|
||||
@if (!verifyMismatch) {
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--warn"
|
||||
(click)="raiseException()"
|
||||
[disabled]="verifySaving"
|
||||
>
|
||||
Raise Exception
|
||||
</button>
|
||||
}
|
||||
<button type="button" class="pg-btn" (click)="closeVerify()">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
<td>{{ r.party ?? '—' }}</td>
|
||||
<td class="num">₹{{ r.amount | number: '1.2-2' }}</td>
|
||||
<td>{{ r.paymentTypeName ?? '—' }}</td>
|
||||
<td>
|
||||
<span [class]="statusClass(r.status)">{{ r.status }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
|
||||
<!-- Attachment panel for this voucher. -->
|
||||
@if (attachRow === r) {
|
||||
<tr class="db-panel-row">
|
||||
<td colspan="13">
|
||||
<div class="db-panel">
|
||||
<div class="db-panel__head">
|
||||
<strong>Attachments — {{ r.voucherNo }}</strong>
|
||||
<button type="button" class="db-icon-btn" (click)="closeAttach()" title="Close">
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
<label class="db-upload">
|
||||
<input type="file" (change)="onFileSelected($event)" [disabled]="attachUploading" />
|
||||
<span class="db-muted">Scanned slip, cheque image or ID proof · max 10 MB</span>
|
||||
</label>
|
||||
@if (attachUploading) {
|
||||
<p class="db-muted">Uploading…</p>
|
||||
}
|
||||
@if (attachLoading) {
|
||||
<p class="db-muted">Loading…</p>
|
||||
}
|
||||
@if (!attachLoading && attachments.length === 0) {
|
||||
<p class="db-muted">Nothing attached to this voucher yet.</p>
|
||||
}
|
||||
@for (a of attachments; track a.id) {
|
||||
<div class="db-file">
|
||||
<mat-icon>description</mat-icon>
|
||||
<span class="db-file__name">{{ a.fileName }}</span>
|
||||
<span class="db-muted">{{ a.fileSize }} bytes · {{ a.uploadedByName }}</span>
|
||||
<button type="button" class="db-icon-btn" (click)="downloadAttachment(a)" title="Download">
|
||||
<mat-icon>download</mat-icon>
|
||||
</button>
|
||||
<button type="button" class="db-icon-btn" (click)="deleteAttachment(a)" title="Delete">
|
||||
<mat-icon>delete</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<td>{{ r.handledBy ?? '—' }}</td>
|
||||
<td>
|
||||
{{ r.approvedBy ?? '—' }}
|
||||
@if (r.approvedBy && r.actionedAt) {
|
||||
<span class="db-muted">· {{ timeOf(r.actionedAt) }}</span>
|
||||
}
|
||||
</td>
|
||||
<td>{{ timeOf(r.submittedAt) }}</td>
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td colspan="5">Showing {{ filteredRows.length }} of {{ rows.length }} voucher(s)</td>
|
||||
<td class="num">₹{{ shownCredit - shownDebit | number: '1.2-2' }}</td>
|
||||
<td colspan="7"></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td colspan="5">{{ s.label }} subtotal</td>
|
||||
<td class="num">
|
||||
@if (s.credit > 0 && s.debit > 0) {
|
||||
₹{{ s.credit | number: '1.2-2' }} CR · ₹{{ s.debit | number: '1.2-2' }} DR
|
||||
} @else if (s.credit > 0) {
|
||||
₹{{ s.credit | number: '1.2-2' }} CR
|
||||
} @else if (s.debit > 0) {
|
||||
₹{{ s.debit | number: '1.2-2' }} DR
|
||||
} @else {
|
||||
—
|
||||
}
|
||||
</td>
|
||||
<td colspan="5"></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@if (!loading && loaded && rows.length > 0 && filteredRows.length === 0) {
|
||||
|
||||
@@ -32,6 +32,34 @@
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
/* One card per source register. The heading says which register, and why its rows read differently
|
||||
from the others, so the three are not added up as though they were one list. */
|
||||
.db-section {
|
||||
margin-bottom: 14px;
|
||||
|
||||
&__head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
padding: 10px 14px;
|
||||
background: #f8fafc;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
font-size: 12px;
|
||||
|
||||
strong {
|
||||
font-size: 13px;
|
||||
color: #0d1b2a;
|
||||
}
|
||||
}
|
||||
|
||||
&__count {
|
||||
margin-left: auto;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: #64748b;
|
||||
}
|
||||
}
|
||||
|
||||
/* Dr/Cr marker — the one thing the eye should catch when scanning a day's vouchers. */
|
||||
.db-side {
|
||||
display: inline-block;
|
||||
@@ -169,41 +197,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* The reason spelled out under its own flagged row. */
|
||||
.db-exc-row td {
|
||||
background: #fef2f2 !important;
|
||||
border-bottom: 1px solid #fecaca !important;
|
||||
color: #7f1d1d;
|
||||
font-size: 12px;
|
||||
padding: 8px 14px !important;
|
||||
|
||||
mat-icon {
|
||||
font-size: 15px;
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
vertical-align: -3px;
|
||||
margin-right: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
/* A closed exception stays on the row as history, but drops the alarm colouring — it is no longer
|
||||
something anyone has to act on. */
|
||||
.db-exc-row--resolved td {
|
||||
background: #f0fdf4 !important;
|
||||
border-bottom: 1px solid #bbf7d0 !important;
|
||||
color: #14532d;
|
||||
}
|
||||
|
||||
.db-exc-row__resolution {
|
||||
margin-top: 4px;
|
||||
padding-top: 4px;
|
||||
border-top: 1px dotted #bbf7d0;
|
||||
}
|
||||
|
||||
.db-actions {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Raising an exception is a deliberate, consequential act — styled to read that way without
|
||||
looking destructive, since nothing is deleted. */
|
||||
.pg-btn--warn {
|
||||
@@ -352,38 +345,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.db-upload {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.db-file {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 12px;
|
||||
margin-bottom: 6px;
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
&__name {
|
||||
font-weight: 700;
|
||||
color: #0d1b2a;
|
||||
}
|
||||
}
|
||||
|
||||
.pg-table tfoot td {
|
||||
background: #f8fafc;
|
||||
font-weight: 800;
|
||||
|
||||
@@ -13,8 +13,18 @@ import { MatIconModule } from '@angular/material/icon';
|
||||
import { catchError, of } from 'rxjs';
|
||||
import { CashierService } from '../cashier.service';
|
||||
|
||||
/**
|
||||
* Which part of the day's work a row came from. Set by the server, one per source register.
|
||||
*
|
||||
* Only counter business appears in the day book. Cash moved between the vault and the drawers is
|
||||
* the bank shifting its own money, not a customer transaction, and it is read on the Cashier
|
||||
* Position and Closing Cash Report where the drawer itself is the subject.
|
||||
*/
|
||||
type DayBookSection = 'CASHIER' | 'CLERK';
|
||||
|
||||
interface DayBookRow {
|
||||
voucherNo: string | null;
|
||||
section: DayBookSection;
|
||||
side: 'CR' | 'DR';
|
||||
kind: string;
|
||||
accountNo: string | null;
|
||||
@@ -29,18 +39,6 @@ interface DayBookRow {
|
||||
txnId: string | null;
|
||||
submittedAt: string | null;
|
||||
actionedAt: string | null;
|
||||
/** null until someone has physically checked the slip. */
|
||||
verifyStatus: 'VERIFIED' | 'EXCEPTION' | null;
|
||||
slipAmount: number | null;
|
||||
verifyRemarks: string | null;
|
||||
verifiedByName: string | null;
|
||||
verifiedAt: string | null;
|
||||
/** Set once a clerk or manager has closed the exception; null while it is still open. */
|
||||
resolutionStatus: string | null;
|
||||
resolutionNotes: string | null;
|
||||
resolvedByName: string | null;
|
||||
resolvedAt: string | null;
|
||||
attachmentCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -128,48 +126,47 @@ export class DayBookComponent implements OnInit {
|
||||
this.load();
|
||||
}
|
||||
|
||||
/** Show only the flagged vouchers — set from the exception banner. */
|
||||
exceptionsOnly = false;
|
||||
|
||||
/**
|
||||
* Vouchers still awaiting a conclusion. A closed exception drops out of the banner — it stays
|
||||
* visible on its own row as history, but it is no longer something anyone has to act on.
|
||||
* The day's rows split by where they came from, in the order they are read.
|
||||
*
|
||||
* Cashier work first — that is the counter — then the clerk's outstanding requests. An empty
|
||||
* section is dropped rather than shown as a heading over nothing.
|
||||
*
|
||||
* A section is not "who typed it": a clerk raises a withdrawal that a cashier pays out, so the
|
||||
* heading says which register the entry belongs to, and the By column still names the person.
|
||||
*/
|
||||
get exceptionRows(): DayBookRow[] {
|
||||
return this.rows.filter((r) => r.verifyStatus === 'EXCEPTION' && !r.resolutionStatus);
|
||||
}
|
||||
|
||||
/** Closed exceptions, kept for the record rather than for action. */
|
||||
get resolvedExceptionCount(): number {
|
||||
return this.rows.filter((r) => r.verifyStatus === 'EXCEPTION' && r.resolutionStatus).length;
|
||||
}
|
||||
|
||||
/** Human label for a resolution code. */
|
||||
resolutionLabel(status: string | null): string {
|
||||
switch (status) {
|
||||
case 'RESOLVED_SYSTEM_ERROR':
|
||||
return 'System entry was wrong';
|
||||
case 'RESOLVED_SLIP_ERROR':
|
||||
return 'Slip was written wrong';
|
||||
case 'RESOLVED_NO_ACTION':
|
||||
return 'No action needed';
|
||||
default:
|
||||
return status ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
/** Vouchers nobody has checked against a slip yet. */
|
||||
get uncheckedCount(): number {
|
||||
return this.rows.filter((r) => !r.verifyStatus).length;
|
||||
}
|
||||
|
||||
toggleExceptionsOnly(): void {
|
||||
this.exceptionsOnly = !this.exceptionsOnly;
|
||||
get sections(): {
|
||||
key: DayBookSection;
|
||||
label: string;
|
||||
hint: string;
|
||||
rows: DayBookRow[];
|
||||
credit: number;
|
||||
debit: number;
|
||||
}[] {
|
||||
const order: { key: DayBookSection; label: string; hint: string }[] = [
|
||||
{ key: 'CASHIER', label: 'Cashier', hint: 'Cash actually taken in and paid out over the counter' },
|
||||
{ key: 'CLERK', label: 'Clerk', hint: 'Requests raised but not yet actioned — no cash has moved' }
|
||||
];
|
||||
return order
|
||||
.map((s) => {
|
||||
const rows = this.filteredRows.filter((r) => r.section === s.key);
|
||||
return {
|
||||
...s,
|
||||
rows,
|
||||
credit: rows
|
||||
.filter((r) => r.side === 'CR' && this.countsAsMoney(r))
|
||||
.reduce((t, r) => t + Number(r.amount ?? 0), 0),
|
||||
debit: rows
|
||||
.filter((r) => r.side === 'DR' && this.countsAsMoney(r))
|
||||
.reduce((t, r) => t + Number(r.amount ?? 0), 0)
|
||||
};
|
||||
})
|
||||
.filter((s) => s.rows.length > 0);
|
||||
}
|
||||
|
||||
/** Free-text narrowing over the rows already fetched — no extra round trip per keystroke. */
|
||||
get filteredRows(): DayBookRow[] {
|
||||
const base = this.exceptionsOnly ? this.exceptionRows : this.rows;
|
||||
const base = this.rows;
|
||||
const q = this.search.trim().toLowerCase();
|
||||
if (!q) return base;
|
||||
return base.filter((r) =>
|
||||
@@ -231,236 +228,9 @@ export class DayBookComponent implements OnInit {
|
||||
return t ? t.slice(0, 5) : '—';
|
||||
}
|
||||
|
||||
// ── Physical slip verification ────────────────────────────────────────────
|
||||
|
||||
/** Row whose verification panel is open, or null when none is. */
|
||||
verifyRow: DayBookRow | null = null;
|
||||
verifySlipAmount: number | null = null;
|
||||
verifyRemarks = '';
|
||||
verifySaving = false;
|
||||
|
||||
openVerify(r: DayBookRow): void {
|
||||
this.verifyRow = r;
|
||||
// Pre-filled with the system's own figure: the common case is that the two agree, and when
|
||||
// they don't the cashier only has to change the digits that actually differ.
|
||||
this.verifySlipAmount = Number(r.amount ?? 0);
|
||||
this.verifyRemarks = r.verifyRemarks ?? '';
|
||||
this.attachRow = null;
|
||||
}
|
||||
|
||||
closeVerify(): void {
|
||||
this.verifyRow = null;
|
||||
this.verifySlipAmount = null;
|
||||
this.verifyRemarks = '';
|
||||
}
|
||||
|
||||
/** True when the amount typed off the slip differs from what the system recorded. */
|
||||
get verifyMismatch(): boolean {
|
||||
if (!this.verifyRow || this.verifySlipAmount == null) return false;
|
||||
return Number(this.verifySlipAmount) !== Number(this.verifyRow.amount ?? 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* True while the slip box is empty. Clearing the field must not read as "amounts agree" — an
|
||||
* unanswered question is not a match, so the panel shows neither verdict until a figure is typed.
|
||||
*/
|
||||
get verifySlipBlank(): boolean {
|
||||
return this.verifySlipAmount == null || String(this.verifySlipAmount).trim() === '';
|
||||
}
|
||||
|
||||
get verifyDifference(): number {
|
||||
if (!this.verifyRow || this.verifySlipAmount == null) return 0;
|
||||
return Number(this.verifySlipAmount) - Number(this.verifyRow.amount ?? 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the verdict. The status is derived from the figures rather than picked by the user —
|
||||
* if the slip and the system disagree it is an exception, whatever anyone intended to click.
|
||||
*/
|
||||
saveVerify(): void {
|
||||
const row = this.verifyRow;
|
||||
if (!row?.voucherNo) return;
|
||||
|
||||
const isException = this.verifyMismatch;
|
||||
if (isException && !this.verifyRemarks.trim()) {
|
||||
this.errorMsg = 'The amounts differ — add a remark saying what you found before saving.';
|
||||
return;
|
||||
}
|
||||
|
||||
this.verifySaving = true;
|
||||
this.errorMsg = '';
|
||||
this.service
|
||||
.verifyVoucher(
|
||||
row.voucherNo,
|
||||
isException ? 'EXCEPTION' : 'VERIFIED',
|
||||
isException ? Number(this.verifySlipAmount) : undefined,
|
||||
this.verifyRemarks.trim() || undefined
|
||||
)
|
||||
.pipe(
|
||||
catchError((err: any) => {
|
||||
this.errorMsg =
|
||||
err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.message ?? 'Could not save the verification.';
|
||||
return of(null);
|
||||
})
|
||||
)
|
||||
.subscribe((res: any) => {
|
||||
this.verifySaving = false;
|
||||
if (res) {
|
||||
this.closeVerify();
|
||||
this.load();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Flags a voucher whose amounts agree — the problem is something other than the figure: a
|
||||
* missing signature, a wrong account, a slip the member disputes. Deliberately not a "clear the
|
||||
* check" action: erasing a verdict would erase the evidence that anything was ever wrong, which
|
||||
* is exactly what an investigation needs.
|
||||
*/
|
||||
raiseException(): void {
|
||||
const row = this.verifyRow;
|
||||
if (!row?.voucherNo) return;
|
||||
|
||||
if (!this.verifyRemarks.trim()) {
|
||||
this.errorMsg = 'Say what the problem is before raising an exception.';
|
||||
return;
|
||||
}
|
||||
|
||||
this.verifySaving = true;
|
||||
this.errorMsg = '';
|
||||
this.service
|
||||
// The slip amount is sent as-is: the figures match, so it doubles as the record of what
|
||||
// both the slip and the system said at the time the exception was raised.
|
||||
.verifyVoucher(
|
||||
row.voucherNo,
|
||||
'EXCEPTION',
|
||||
Number(this.verifySlipAmount ?? row.amount ?? 0),
|
||||
this.verifyRemarks.trim()
|
||||
)
|
||||
.pipe(
|
||||
catchError((err: any) => {
|
||||
this.errorMsg =
|
||||
err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.message ?? 'Could not raise the exception.';
|
||||
return of(null);
|
||||
})
|
||||
)
|
||||
.subscribe((res: any) => {
|
||||
this.verifySaving = false;
|
||||
if (res) {
|
||||
this.closeVerify();
|
||||
this.load();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Attachments ───────────────────────────────────────────────────────────
|
||||
|
||||
attachRow: DayBookRow | null = null;
|
||||
attachments: any[] = [];
|
||||
attachLoading = false;
|
||||
attachUploading = false;
|
||||
|
||||
openAttach(r: DayBookRow): void {
|
||||
this.attachRow = r;
|
||||
this.verifyRow = null;
|
||||
this.attachments = [];
|
||||
if (!r.voucherNo) return;
|
||||
this.attachLoading = true;
|
||||
this.service
|
||||
.listVoucherAttachments(r.voucherNo)
|
||||
.pipe(catchError(() => of(null)))
|
||||
.subscribe((res: any) => {
|
||||
this.attachLoading = false;
|
||||
this.attachments = res?.attachments ?? [];
|
||||
});
|
||||
}
|
||||
|
||||
closeAttach(): void {
|
||||
this.attachRow = null;
|
||||
this.attachments = [];
|
||||
}
|
||||
|
||||
onFileSelected(event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
const row = this.attachRow;
|
||||
if (!file || !row?.voucherNo) return;
|
||||
|
||||
// 10 MB, matching the backend's own ceiling — rejecting here saves uploading the whole file
|
||||
// only to have it refused.
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
this.errorMsg = 'That file is larger than 10 MB. Please scan it at a lower resolution.';
|
||||
input.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
this.attachUploading = true;
|
||||
this.errorMsg = '';
|
||||
this.service
|
||||
.uploadVoucherAttachment(
|
||||
row.voucherNo!,
|
||||
file.name,
|
||||
file.type || 'application/octet-stream',
|
||||
String(reader.result)
|
||||
)
|
||||
.pipe(
|
||||
catchError((err: any) => {
|
||||
this.errorMsg =
|
||||
err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.message ?? 'Upload failed. Please try again.';
|
||||
return of(null);
|
||||
})
|
||||
)
|
||||
.subscribe((res: any) => {
|
||||
this.attachUploading = false;
|
||||
input.value = '';
|
||||
if (res) {
|
||||
this.openAttach(row);
|
||||
this.load();
|
||||
}
|
||||
});
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetched through HttpClient rather than window.open: the download endpoint needs the auth
|
||||
* header the interceptor adds, which a bare browser navigation would not carry. The blob is
|
||||
* then handed to the browser as a normal save.
|
||||
*/
|
||||
downloadAttachment(a: any): void {
|
||||
this.service
|
||||
.downloadVoucherAttachment(a.id)
|
||||
.pipe(catchError(() => of(null)))
|
||||
.subscribe((blob: Blob | null) => {
|
||||
if (!blob) {
|
||||
this.errorMsg = 'Could not download that file.';
|
||||
return;
|
||||
}
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = a.fileName ?? 'attachment';
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
});
|
||||
}
|
||||
|
||||
deleteAttachment(a: any): void {
|
||||
const row = this.attachRow;
|
||||
this.service
|
||||
.deleteVoucherAttachment(a.id)
|
||||
.pipe(catchError(() => of(null)))
|
||||
.subscribe(() => {
|
||||
if (row) this.openAttach(row);
|
||||
this.load();
|
||||
});
|
||||
}
|
||||
|
||||
exportCsv(): void {
|
||||
const header = [
|
||||
'Section',
|
||||
'Voucher No',
|
||||
'Side',
|
||||
'Type',
|
||||
@@ -471,18 +241,11 @@ export class DayBookComponent implements OnInit {
|
||||
'Status',
|
||||
'Handled By',
|
||||
'Approved By',
|
||||
'Slip Check',
|
||||
'Slip Amount',
|
||||
'Remarks',
|
||||
'Checked By',
|
||||
'Resolution',
|
||||
'Resolution Notes',
|
||||
'Closed By',
|
||||
'Attachments',
|
||||
'Time'
|
||||
];
|
||||
const lines = this.filteredRows.map((r) =>
|
||||
[
|
||||
r.section,
|
||||
r.voucherNo ?? '',
|
||||
r.side,
|
||||
r.kind,
|
||||
@@ -493,14 +256,6 @@ export class DayBookComponent implements OnInit {
|
||||
r.status,
|
||||
r.handledBy ?? '',
|
||||
r.approvedBy ?? '',
|
||||
r.verifyStatus ?? 'NOT CHECKED',
|
||||
r.slipAmount != null ? String(r.slipAmount) : '',
|
||||
r.verifyRemarks ?? '',
|
||||
r.verifiedByName ?? '',
|
||||
r.resolutionStatus ? this.resolutionLabel(r.resolutionStatus) : '',
|
||||
r.resolutionNotes ?? '',
|
||||
r.resolvedByName ?? '',
|
||||
String(r.attachmentCount ?? 0),
|
||||
this.timeOf(r.submittedAt)
|
||||
]
|
||||
// Quote every field and double any embedded quote: names and narrations routinely
|
||||
|
||||
@@ -0,0 +1,497 @@
|
||||
/**
|
||||
* 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 { FormsModule, ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||
import { MatDialogModule, MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
|
||||
export interface CashierIssueDdDialogData {
|
||||
leaflet: any;
|
||||
branchName: string;
|
||||
sponsorBankName: string;
|
||||
faceValue: number;
|
||||
chargesAmount: number;
|
||||
gstPercentage: number;
|
||||
gstAmount: number;
|
||||
totalCashPayable: number;
|
||||
/**
|
||||
* How the draft is being paid for. Cash is a walk-in purchaser the bank may know nothing about,
|
||||
* so their name, address and Aadhaar have to be captured here. An account debit is a member whose
|
||||
* account is already on screen — those fields are not asked for, and must not be sent, because
|
||||
* the identity is the account holder's rather than a walk-in's.
|
||||
*/
|
||||
paymentMethod?: 'CASH' | 'ACCOUNT_DEBIT';
|
||||
/** The account being debited, shown so the clerk can see what is funding the draft. */
|
||||
accountNo?: string;
|
||||
/** The account holder, for the same reason. */
|
||||
accountHolderName?: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-cashier-issue-dd-dialog',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
MatDialogModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatButtonModule,
|
||||
MatIconModule
|
||||
],
|
||||
template: `
|
||||
<div class="dd-dialog-shell">
|
||||
<div class="dd-dialog-header">
|
||||
<div class="dd-dialog-header__title">
|
||||
<mat-icon class="dd-icon">receipt_long</mat-icon>
|
||||
<div>
|
||||
<h2>{{ isAccountDebit ? 'Account-Debit Demand Draft (DD)' : 'Walk-in Cash Demand Draft (DD)' }}</h2>
|
||||
<p>
|
||||
Next available DD leaflet auto-selected ·
|
||||
{{ isAccountDebit ? 'Enter beneficiary details' : 'Enter purchaser & beneficiary details' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button mat-icon-button type="button" (click)="onClose()" class="close-btn">
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<mat-dialog-content class="dd-dialog-body">
|
||||
<!-- Draft & Financials Summary Card -->
|
||||
<div class="dd-summary-card">
|
||||
<div class="summary-col">
|
||||
<span class="label">Auto-Selected Leaflet</span>
|
||||
<strong class="dd-number-highlight">{{ data.leaflet.ddNumber }}</strong>
|
||||
<span class="sublabel">{{ data.sponsorBankName || 'Sponsor Bank' }} · {{ data.branchName }}</span>
|
||||
</div>
|
||||
<div class="summary-divider"></div>
|
||||
<div class="summary-col">
|
||||
<span class="label">Face Value</span>
|
||||
<strong class="amount">₹{{ data.faceValue | number: '1.2-2' }}</strong>
|
||||
<span class="sublabel">+ Charges ₹{{ data.chargesAmount | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
<div class="summary-divider"></div>
|
||||
<div class="summary-col">
|
||||
<span class="label">GST ({{ data.gstPercentage }}%)</span>
|
||||
<strong class="amount">₹{{ data.gstAmount | number: '1.2-2' }}</strong>
|
||||
<span class="sublabel">on charges only</span>
|
||||
</div>
|
||||
<div class="summary-divider"></div>
|
||||
<div class="summary-col highlight-col">
|
||||
<span class="label">{{ isAccountDebit ? 'Total Debited' : 'Total Cash Collected' }}</span>
|
||||
<strong class="total-amount">₹{{ data.totalCashPayable | number: '1.2-2' }}</strong>
|
||||
<span class="sublabel">
|
||||
@if (isAccountDebit) {
|
||||
{{ data.accountNo || 'Account' }}
|
||||
} @else {
|
||||
Denominations verified
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
The account funding the draft, stated before the form.
|
||||
|
||||
A clerk reaching this dialog has an account loaded on the workstation behind it, and the
|
||||
debit is irreversible once a checker approves. Naming the account and its holder here is
|
||||
what makes a wrong account visible while it can still be cancelled.
|
||||
-->
|
||||
@if (isAccountDebit) {
|
||||
<div class="dd-funding-note">
|
||||
<mat-icon>account_balance_wallet</mat-icon>
|
||||
<div>
|
||||
<strong>Debiting {{ data.accountNo }}</strong>
|
||||
@if (data.accountHolderName) {
|
||||
· {{ data.accountHolderName }}
|
||||
}
|
||||
<div class="dd-funding-sub">
|
||||
₹{{ data.totalCashPayable | number: '1.2-2' }} will be debited from this account when a checker
|
||||
approves. No cash is taken at the counter.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<form [formGroup]="form" class="dd-form">
|
||||
<!--
|
||||
Purchaser identity is asked for only when the bank has none: a walk-in paying cash. On an
|
||||
account debit the purchaser IS the account holder, already identified and KYC'd, and
|
||||
re-capturing a name and Aadhaar here would invite a mismatch against the account on record.
|
||||
-->
|
||||
@if (!isAccountDebit) {
|
||||
<div class="form-section-title">Walk-in Purchaser Details</div>
|
||||
|
||||
<div class="form-row">
|
||||
<mat-form-field appearance="outline" class="form-field-flex">
|
||||
<mat-label>Walk-in Purchaser Name *</mat-label>
|
||||
<input
|
||||
matInput
|
||||
formControlName="walkinPurchaserName"
|
||||
placeholder="e.g. RAMESH CHANDRA"
|
||||
style="text-transform: uppercase;"
|
||||
/>
|
||||
<mat-error *ngIf="form.get('walkinPurchaserName')?.hasError('required')"
|
||||
>Purchaser name is required</mat-error
|
||||
>
|
||||
<mat-error *ngIf="form.get('walkinPurchaserName')?.hasError('minlength')"
|
||||
>Must be at least 3 characters</mat-error
|
||||
>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="form-field-flex">
|
||||
<mat-label>12-Digit Aadhaar Number *</mat-label>
|
||||
<input
|
||||
matInput
|
||||
formControlName="walkinPurchaserAadhaar"
|
||||
placeholder="12-digit Aadhaar"
|
||||
maxlength="12"
|
||||
/>
|
||||
<mat-hint>Masked to last 4 digits before storage</mat-hint>
|
||||
<mat-error *ngIf="form.get('walkinPurchaserAadhaar')?.hasError('required')"
|
||||
>Aadhaar number is required</mat-error
|
||||
>
|
||||
<mat-error *ngIf="form.get('walkinPurchaserAadhaar')?.hasError('pattern')"
|
||||
>Must be exactly 12 numeric digits</mat-error
|
||||
>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<mat-form-field appearance="outline" class="full-width">
|
||||
<mat-label>Purchaser Address / Location *</mat-label>
|
||||
<input
|
||||
matInput
|
||||
formControlName="walkinPurchaserAddress"
|
||||
placeholder="e.g. 14, SECTOR 5, MAIN ROAD, MUMBAI"
|
||||
style="text-transform: uppercase;"
|
||||
/>
|
||||
<mat-error *ngIf="form.get('walkinPurchaserAddress')?.hasError('required')"
|
||||
>Address is required</mat-error
|
||||
>
|
||||
</mat-form-field>
|
||||
}
|
||||
|
||||
<!-- Beneficiary Section -->
|
||||
<div class="form-section-title">Beneficiary & Payment Details</div>
|
||||
|
||||
<mat-form-field appearance="outline" class="full-width">
|
||||
<mat-label>Beneficiary Name (In Favour Of) *</mat-label>
|
||||
<input
|
||||
matInput
|
||||
formControlName="beneficiaryName"
|
||||
placeholder="e.g. UNIVERSITY OF MUMBAI"
|
||||
maxlength="120"
|
||||
style="text-transform: uppercase;"
|
||||
/>
|
||||
<mat-error *ngIf="form.get('beneficiaryName')?.hasError('required')"
|
||||
>Beneficiary name is required</mat-error
|
||||
>
|
||||
<mat-error *ngIf="form.get('beneficiaryName')?.hasError('maxlength')">Max 120 characters</mat-error>
|
||||
</mat-form-field>
|
||||
|
||||
<div class="form-row">
|
||||
<mat-form-field appearance="outline" class="form-field-flex">
|
||||
<mat-label>Payable At - Branch *</mat-label>
|
||||
<input
|
||||
matInput
|
||||
formControlName="payableAtBranch"
|
||||
placeholder="e.g. FORT BRANCH"
|
||||
maxlength="150"
|
||||
style="text-transform: uppercase;"
|
||||
/>
|
||||
<mat-error *ngIf="form.get('payableAtBranch')?.hasError('required')"
|
||||
>Payable Branch is required</mat-error
|
||||
>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="form-field-flex">
|
||||
<mat-label>Payable At - City *</mat-label>
|
||||
<input
|
||||
matInput
|
||||
formControlName="payableAtCity"
|
||||
placeholder="e.g. MUMBAI"
|
||||
maxlength="100"
|
||||
style="text-transform: uppercase;"
|
||||
/>
|
||||
<mat-error *ngIf="form.get('payableAtCity')?.hasError('required')">Payable City is required</mat-error>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</form>
|
||||
</mat-dialog-content>
|
||||
|
||||
<mat-dialog-actions class="dd-dialog-actions" align="end">
|
||||
<button mat-button type="button" (click)="onClose()">Cancel</button>
|
||||
<button mat-flat-button color="primary" [disabled]="form.invalid" (click)="onSubmit()">
|
||||
Submit for Manager Approval
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
</div>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
.dd-dialog-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 90vh;
|
||||
}
|
||||
.dd-dialog-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 24px;
|
||||
background: #0f172a;
|
||||
color: #ffffff;
|
||||
border-top-left-radius: 8px;
|
||||
border-top-right-radius: 8px;
|
||||
}
|
||||
.dd-dialog-header__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.dd-dialog-header__title h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #ffffff;
|
||||
}
|
||||
.dd-dialog-header__title p {
|
||||
margin: 2px 0 0 0;
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
.dd-icon {
|
||||
color: #38bdf8;
|
||||
font-size: 28px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
.close-btn {
|
||||
color: #94a3b8;
|
||||
}
|
||||
.close-btn:hover {
|
||||
color: #ffffff;
|
||||
}
|
||||
.dd-dialog-body {
|
||||
padding: 20px 24px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.dd-summary-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
padding: 14px 18px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.summary-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.summary-col.highlight-col {
|
||||
background: #ecfdf5;
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #a7f3d0;
|
||||
}
|
||||
.summary-divider {
|
||||
width: 1px;
|
||||
height: 36px;
|
||||
background: #e2e8f0;
|
||||
}
|
||||
.summary-col .label {
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
color: #64748b;
|
||||
font-weight: 600;
|
||||
}
|
||||
.dd-number-highlight {
|
||||
font-size: 16px;
|
||||
color: #0369a1;
|
||||
font-family: monospace;
|
||||
}
|
||||
.summary-col .amount {
|
||||
font-size: 15px;
|
||||
color: #1e293b;
|
||||
}
|
||||
.summary-col .total-amount {
|
||||
font-size: 16px;
|
||||
color: #065f46;
|
||||
}
|
||||
.summary-col .sublabel {
|
||||
font-size: 11px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
.form-section-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
color: #475569;
|
||||
margin: 16px 0 10px 0;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
.form-section-title:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
.dd-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.full-width {
|
||||
width: 100%;
|
||||
}
|
||||
.form-row {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
.form-field-flex {
|
||||
flex: 1;
|
||||
}
|
||||
.dd-dialog-actions {
|
||||
padding: 12px 24px;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
}
|
||||
/* The account about to be debited — read before the form, so a wrong one is caught here. */
|
||||
.dd-funding-note {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
margin: 14px 0 4px;
|
||||
padding: 11px 14px;
|
||||
background: #eff6ff;
|
||||
border: 1px solid #bfdbfe;
|
||||
border-left: 3px solid #1d4ed8;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
color: #334155;
|
||||
}
|
||||
.dd-funding-note mat-icon {
|
||||
color: #1d4ed8;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.dd-funding-sub {
|
||||
margin-top: 3px;
|
||||
font-size: 11.5px;
|
||||
line-height: 1.5;
|
||||
color: #64748b;
|
||||
}
|
||||
`
|
||||
]
|
||||
})
|
||||
export class CashierIssueDdDialogComponent implements OnInit {
|
||||
private fb = inject(FormBuilder);
|
||||
private dialogRef = inject(MatDialogRef<CashierIssueDdDialogComponent>);
|
||||
public data: CashierIssueDdDialogData = inject(MAT_DIALOG_DATA);
|
||||
|
||||
form!: FormGroup;
|
||||
|
||||
/** Whether the draft is funded by debiting a member's account rather than by cash at the counter. */
|
||||
get isAccountDebit(): boolean {
|
||||
return this.data.paymentMethod === 'ACCOUNT_DEBIT';
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
// Walk-in identity is required for cash and rejected outright for an account debit — the server
|
||||
// refuses a walk-in name alongside an account — so the controls are only built when they apply.
|
||||
// Registering them unconditionally and merely hiding them would hold the form invalid on a
|
||||
// required field the clerk cannot see.
|
||||
this.form = this.fb.group(
|
||||
this.isAccountDebit
|
||||
? {
|
||||
beneficiaryName: [
|
||||
'',
|
||||
[
|
||||
Validators.required,
|
||||
Validators.maxLength(120)
|
||||
]
|
||||
],
|
||||
payableAtBranch: [
|
||||
'',
|
||||
[Validators.required]
|
||||
],
|
||||
payableAtCity: [
|
||||
'',
|
||||
[Validators.required]
|
||||
]
|
||||
}
|
||||
: {
|
||||
walkinPurchaserName: [
|
||||
'',
|
||||
[
|
||||
Validators.required,
|
||||
Validators.minLength(3)
|
||||
]
|
||||
],
|
||||
walkinPurchaserAddress: [
|
||||
'',
|
||||
[Validators.required]
|
||||
],
|
||||
walkinPurchaserAadhaar: [
|
||||
'',
|
||||
[
|
||||
Validators.required,
|
||||
Validators.pattern(/^[0-9]{12}$/)
|
||||
]
|
||||
],
|
||||
beneficiaryName: [
|
||||
'',
|
||||
[
|
||||
Validators.required,
|
||||
Validators.maxLength(120)
|
||||
]
|
||||
],
|
||||
payableAtBranch: [
|
||||
'',
|
||||
[Validators.required]
|
||||
],
|
||||
payableAtCity: [
|
||||
'',
|
||||
[Validators.required]
|
||||
]
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
onClose(): void {
|
||||
this.dialogRef.close();
|
||||
}
|
||||
|
||||
onSubmit(): void {
|
||||
if (this.form.invalid) return;
|
||||
const val = this.form.value;
|
||||
const beneficiary = {
|
||||
beneficiaryName: (val.beneficiaryName || '').trim().toUpperCase(),
|
||||
payableAtBranch: (val.payableAtBranch || '').trim().toUpperCase(),
|
||||
payableAtCity: (val.payableAtCity || '').trim().toUpperCase()
|
||||
};
|
||||
// Walk-in fields are left off entirely on an account debit rather than sent empty: the server
|
||||
// rejects a purchaser name arriving alongside an account, and '' would be sent as one.
|
||||
this.dialogRef.close(
|
||||
this.isAccountDebit
|
||||
? beneficiary
|
||||
: {
|
||||
walkinPurchaserName: (val.walkinPurchaserName || '').trim().toUpperCase(),
|
||||
walkinPurchaserAddress: (val.walkinPurchaserAddress || '').trim().toUpperCase(),
|
||||
walkinPurchaserAadhaar: (val.walkinPurchaserAadhaar || '').trim(),
|
||||
...beneficiary
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -134,6 +134,23 @@
|
||||
<option [ngValue]="27">027 · Industrial</option>
|
||||
</select>
|
||||
</div>
|
||||
@if (row.typ === 'DD') {
|
||||
<div class="ws-f ws-f--branch">
|
||||
<label class="ws-f__lbl ws-f__lbl--accent" title="Sponsor Bank">SPONSOR BANK</label>
|
||||
<select
|
||||
class="ws-f__ctl ws-f__ctl--accent"
|
||||
[(ngModel)]="row.sponsorBankId"
|
||||
(click)="$event.stopPropagation()"
|
||||
>
|
||||
@if (sponsorBanks.length === 0) {
|
||||
<option [ngValue]="null">-- Select Bank --</option>
|
||||
}
|
||||
@for (sb of sponsorBanks; track sb.id) {
|
||||
<option [ngValue]="sb.id">{{ sb.name }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
}
|
||||
<div class="ws-f ws-f--paytype">
|
||||
<label class="ws-f__lbl ws-f__lbl--accent" title="Transaction Type">TXN TP.</label>
|
||||
<select
|
||||
@@ -182,7 +199,22 @@
|
||||
(input)="onAcNoInput(row, i, $any($event.target).value)"
|
||||
(click)="$event.stopPropagation()"
|
||||
(blur)="onAcNoBlur(row, i)"
|
||||
placeholder="Account number"
|
||||
[placeholder]="
|
||||
isDdAccountDebitRow(row)
|
||||
? 'Account to debit'
|
||||
: row.typ === 'DD'
|
||||
? ddGlMapping?.ddPayableGlCode || 'Auto GL'
|
||||
: 'Account number'
|
||||
"
|
||||
[readonly]="row.typ === 'DD' && !isDdAccountDebitRow(row)"
|
||||
[class.ws-f__ctl--readonly]="row.typ === 'DD' && !isDdAccountDebitRow(row)"
|
||||
[title]="
|
||||
isDdAccountDebitRow(row)
|
||||
? 'Member account the draft is funded from'
|
||||
: row.typ === 'DD'
|
||||
? 'Configured Sponsor DD Payable GL (Auto-selected)'
|
||||
: 'Account number'
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<div class="ws-f ws-f--ecsno">
|
||||
@@ -340,7 +372,7 @@
|
||||
</div>
|
||||
|
||||
<!-- ── Row 3: DD Charges & GST — only for TYPE=DD, TXN TP=Cash, AC.TP=GL ── -->
|
||||
@if (isDdCashGlRow(row)) {
|
||||
@if (isDdRow(row)) {
|
||||
<div class="ws-group-row ws-group-row--mt">
|
||||
<div class="ws-group ws-group--flex">
|
||||
<span class="ws-caption">DD Charges & GST — Admin Rate {{ ddGstPercentage }}%</span>
|
||||
@@ -375,7 +407,7 @@
|
||||
</div>
|
||||
<div class="ws-f ws-f--amt">
|
||||
<label class="ws-f__lbl" title="Amount + Charges + GST, rounded up to the next rupee">
|
||||
TOTAL TO COLLECT
|
||||
{{ isDdAccountDebitRow(row) ? 'TOTAL TO DEBIT' : 'TOTAL TO COLLECT' }}
|
||||
</label>
|
||||
<div class="ws-amt-wrap ws-amt-wrap--locked">
|
||||
<span class="ws-amt-prefix">₹</span>
|
||||
@@ -387,6 +419,23 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ws-f" style="margin-left: auto; align-self: flex-end">
|
||||
<button
|
||||
type="button"
|
||||
class="te-dd-issue-btn"
|
||||
[disabled]="saving"
|
||||
(click)="openIssueWalkinDd(row); $event.stopPropagation()"
|
||||
[title]="
|
||||
isDdAccountDebitRow(row)
|
||||
? 'Auto-selects next available leaflet and opens the beneficiary form'
|
||||
: 'Auto-selects next available leaflet and opens walk-in purchaser form'
|
||||
"
|
||||
>
|
||||
<mat-icon style="font-size: 18px; width: 18px; height: 18px">post_add</mat-icon>
|
||||
<span>Fill DD Details & Send for Approval</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -398,6 +447,18 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- ── DD GL UNCONFIGURED WARNING BANNER ────────────────────────── -->
|
||||
@if (selectedRow?.typ === 'DD' && !isDdGlMappingConfigured) {
|
||||
<div class="ws-dd-unconfigured-banner">
|
||||
<mat-icon>warning</mat-icon>
|
||||
<div class="ws-dd-unconfigured-banner__text">
|
||||
<strong>DD GL Mapping Required:</strong> Demand Draft GL accounts have not been configured yet. Please ask an
|
||||
administrator to set up the mapping under <em>Admin > Sponsor DD GST / GL Settings</em> before issuing
|
||||
drafts.
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- ── APPROVED WITHDRAWAL BANNER (cashier disburse flow) ─────────── -->
|
||||
@if (!isClerk && loadingWithdrawal) {
|
||||
<div class="ws-withdrawal-banner ws-withdrawal-banner--loading">
|
||||
@@ -1404,7 +1465,12 @@
|
||||
<div class="ws-group__fields">
|
||||
<div class="ws-f ws-f--type">
|
||||
<label class="ws-f__lbl">TYPE</label>
|
||||
<select class="ws-f__ctl" [(ngModel)]="row.typ">
|
||||
<select
|
||||
class="ws-f__ctl"
|
||||
[(ngModel)]="row.typ"
|
||||
(click)="$event.stopPropagation()"
|
||||
(change)="onTypChange(row)"
|
||||
>
|
||||
@for (t of typOptions; track t.value) {
|
||||
<option [value]="t.value">{{ t.label }}</option>
|
||||
}
|
||||
@@ -1412,18 +1478,36 @@
|
||||
</div>
|
||||
<div class="ws-f ws-f--branch">
|
||||
<label class="ws-f__lbl">BRANCH</label>
|
||||
<select class="ws-f__ctl" [(ngModel)]="row.br">
|
||||
<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>
|
||||
</div>
|
||||
@if (row.typ === 'DD') {
|
||||
<div class="ws-f ws-f--branch">
|
||||
<label class="ws-f__lbl ws-f__lbl--accent" title="Sponsor Bank">SPONSOR BANK</label>
|
||||
<select
|
||||
class="ws-f__ctl ws-f__ctl--accent"
|
||||
[(ngModel)]="row.sponsorBankId"
|
||||
(click)="$event.stopPropagation()"
|
||||
>
|
||||
@if (sponsorBanks.length === 0) {
|
||||
<option [ngValue]="null">-- Select Bank --</option>
|
||||
}
|
||||
@for (sb of sponsorBanks; track sb.id) {
|
||||
<option [ngValue]="sb.id">{{ sb.name }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
}
|
||||
<div class="ws-f ws-f--paytype">
|
||||
<label class="ws-f__lbl ws-f__lbl--accent">TXN TP.</label>
|
||||
<select
|
||||
class="ws-f__ctl ws-f__ctl--accent"
|
||||
[(ngModel)]="row.paymentTypeId"
|
||||
(click)="$event.stopPropagation()"
|
||||
[class.ws-f__ctl--req]="!row.paymentTypeId"
|
||||
>
|
||||
@for (pt of paymentTypesForRow(row); track pt.id) {
|
||||
@@ -1436,6 +1520,7 @@
|
||||
<select
|
||||
class="ws-f__ctl"
|
||||
[(ngModel)]="row.acTyp"
|
||||
(click)="$event.stopPropagation()"
|
||||
(change)="
|
||||
row.acTyp === 'GL' &&
|
||||
row.typ !== 'Disbursement' &&
|
||||
@@ -1462,7 +1547,14 @@
|
||||
[value]="row.acNo"
|
||||
(input)="onAcNoInput(row, i, $any($event.target).value)"
|
||||
(blur)="onAcNoBlur(row, i)"
|
||||
placeholder="Account number"
|
||||
[placeholder]="
|
||||
row.typ === 'DD' ? ddGlMapping?.ddPayableGlCode || 'Auto GL' : 'Account number'
|
||||
"
|
||||
[readonly]="row.typ === 'DD'"
|
||||
[class.ws-f__ctl--readonly]="row.typ === 'DD'"
|
||||
[title]="
|
||||
row.typ === 'DD' ? 'Configured Sponsor DD Payable GL (Auto-selected)' : 'Account number'
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<div class="ws-f ws-f--ecsno">
|
||||
@@ -1591,14 +1683,19 @@
|
||||
<div class="ws-group__fields">
|
||||
<div class="ws-f ws-f--desc">
|
||||
<label class="ws-f__lbl">DESCRIPTION</label>
|
||||
<input class="ws-f__ctl" [(ngModel)]="row.description" placeholder="Narration / remarks" />
|
||||
<input
|
||||
class="ws-f__ctl"
|
||||
[(ngModel)]="row.description"
|
||||
(click)="$event.stopPropagation()"
|
||||
placeholder="Narration / remarks"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Row 3: DD Charges & GST — only for TYPE=DD, TXN TP=Cash, AC.TP=GL ── -->
|
||||
@if (isDdCashGlRow(row)) {
|
||||
@if (isDdRow(row)) {
|
||||
<div class="ws-group-row ws-group-row--mt">
|
||||
<div class="ws-group ws-group--flex">
|
||||
<span class="ws-caption">DD Charges & GST — Admin Rate {{ ddGstPercentage }}%</span>
|
||||
@@ -1612,6 +1709,7 @@
|
||||
type="number"
|
||||
[value]="row.ddCharges ?? ''"
|
||||
(input)="row.ddCharges = +$any($event.target).value"
|
||||
(click)="$event.stopPropagation()"
|
||||
placeholder="0.00"
|
||||
/>
|
||||
</div>
|
||||
@@ -1632,7 +1730,7 @@
|
||||
</div>
|
||||
<div class="ws-f ws-f--amt">
|
||||
<label class="ws-f__lbl" title="Amount + Charges + GST, rounded up to the next rupee">
|
||||
TOTAL TO COLLECT
|
||||
{{ isDdAccountDebitRow(row) ? 'TOTAL TO DEBIT' : 'TOTAL TO COLLECT' }}
|
||||
</label>
|
||||
<div class="ws-amt-wrap ws-amt-wrap--locked">
|
||||
<span class="ws-amt-prefix">₹</span>
|
||||
@@ -1644,6 +1742,23 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ws-f" style="margin-left: auto; align-self: flex-end">
|
||||
<button
|
||||
type="button"
|
||||
class="te-dd-issue-btn"
|
||||
[disabled]="saving"
|
||||
(click)="openIssueWalkinDd(row); $event.stopPropagation()"
|
||||
[title]="
|
||||
isDdAccountDebitRow(row)
|
||||
? 'Auto-selects next available leaflet and opens the beneficiary form'
|
||||
: 'Auto-selects next available leaflet and opens walk-in purchaser form'
|
||||
"
|
||||
>
|
||||
<mat-icon style="font-size: 18px; width: 18px; height: 18px">post_add</mat-icon>
|
||||
<span>Fill DD Details & Send for Approval</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3384,3 +3384,67 @@ select.te-cell {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.ws-dd-unconfigured-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin: 10px 16px;
|
||||
padding: 12px 16px;
|
||||
background: #fffbeb;
|
||||
border: 1.5px solid #fcd34d;
|
||||
border-radius: 8px;
|
||||
color: #92400e;
|
||||
font-size: 13px;
|
||||
|
||||
mat-icon {
|
||||
color: #d97706;
|
||||
font-size: 24px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__text {
|
||||
flex: 1;
|
||||
line-height: 1.4;
|
||||
|
||||
strong {
|
||||
color: #78350f;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.te-dd-issue-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: linear-gradient(135deg, #0284c7, #0ea5e9);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 7px 16px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 2px 4px rgb(2 132 199 / 35%);
|
||||
transition: all 0.15s ease-in-out;
|
||||
letter-spacing: 0.2px;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: linear-gradient(135deg, #0369a1, #0284c7);
|
||||
box-shadow: 0 3px 8px rgb(2 132 199 / 50%);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
&:active:not(:disabled) {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background: #cbd5e1;
|
||||
color: #64748b;
|
||||
cursor: not-allowed;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
} from '../../shared/customer-search-dialog/customer-search-dialog.component';
|
||||
import { SponsorDDService } from '../../sponsor-dd/sponsor-dd.service';
|
||||
import { OrganizationService } from '../../organization/organization.service';
|
||||
import { CashierIssueDdDialogComponent } from './cashier-issue-dd-dialog.component';
|
||||
|
||||
interface TxnRow {
|
||||
typ: string;
|
||||
@@ -51,6 +52,7 @@ interface TxnRow {
|
||||
routingCode: string;
|
||||
/** DD issuance charges/commission — only used when TYPE=DD, TXN TP=Cash, AC.TP=GL */
|
||||
ddCharges: number | null;
|
||||
sponsorBankId?: number | null;
|
||||
/** Only used when this row is AC.TP=SHR inside an Enter Bank Transfer to its linked savings
|
||||
* account — governs the share redemption (see submitShareRedemptionTransfer()). */
|
||||
resolutionNumber?: string;
|
||||
@@ -1523,7 +1525,14 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
* the one that actually governs how the settlement credit posts (see submitShareRedemptionTransfer()).
|
||||
*/
|
||||
paymentTypesForRow(row: TxnRow): any[] {
|
||||
const tellerFacing = this.paymentTypes.filter((pt: any) => !pt.isSystemDefined);
|
||||
// Sponsor-bank payment types are excluded. They were added for the Demand Draft module, which
|
||||
// picks its bank from m_sponsor_bank on the DD row itself, and they are not counter instruments
|
||||
// — a clerk choosing "SBI" as the TXN TP. of a withdrawal says nothing about how the money
|
||||
// moves. They are recognised by their description rather than by name so that adding another
|
||||
// sponsor bank does not put it back on this list.
|
||||
const tellerFacing = this.paymentTypes.filter(
|
||||
(pt: any) => !pt.isSystemDefined && !/sponsor bank/i.test(pt.description ?? '')
|
||||
);
|
||||
if (!this.isClerk) {
|
||||
// Cashier counter takes cash only — cheque/transfer/DD instruments go through their own
|
||||
// clerk/back-office approval flows, never straight through the cash drawer.
|
||||
@@ -1541,6 +1550,23 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
if (isShareSettlementCredit) {
|
||||
return tellerFacing.filter((pt: any) => !pt.isCashPayment);
|
||||
}
|
||||
// A withdrawal slip is a cash instruction and nothing else: the clerk raises it, the cashier
|
||||
// counts the notes out against the token. There is no non-cash way to settle one, so cash is
|
||||
// the only choice offered rather than the default among several.
|
||||
if (row.typ === 'Withdrawal Slip') {
|
||||
return tellerFacing.filter((pt: any) => !!pt.isCashPayment);
|
||||
}
|
||||
// A clerk's Demand Draft is never funded by cash they take themselves. Cash for a draft is
|
||||
// counted at the counter, so it comes either from the cashier's own DD screen or from an
|
||||
// approved cashier deposit request the clerk points the draft at; the clerk's own route is to
|
||||
// debit the member's account. Offering cash here let a draft be raised against notes no drawer
|
||||
// had counted, which is exactly how the six cash DDs with no denomination breakdown arose.
|
||||
if (row.typ === 'DD') {
|
||||
return tellerFacing.filter((pt: any) => !pt.isCashPayment);
|
||||
}
|
||||
// Everything else can go either way, so both are offered and the clerk says which. A cheque or
|
||||
// a bank transfer normally settles through the instrument, a disbursement or fund transfer
|
||||
// between accounts — the type alone does not fix it to one or the other.
|
||||
return tellerFacing;
|
||||
}
|
||||
|
||||
@@ -1664,9 +1690,6 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
// wrong for crediting a GL amount into a savings/current account for any other reason (e.g.
|
||||
// funding a newly-activated account's opening balance from a GL source).
|
||||
{ value: 'Fund Transfer', label: 'Fund Transfer' },
|
||||
// NOTE: the exact same TxnRow.typ string is used at every dispatch/default-setting site below —
|
||||
// search for 'Disbursement' whenever this needs to change again, since 'Fund Transfer' always
|
||||
// rides alongside it in every one of those checks rather than having its own separate case.
|
||||
{ value: 'Enter Bank Transfer', label: 'Enter Bank Transfer' },
|
||||
{ value: 'DD', label: 'DD (Demand Draft)' }
|
||||
];
|
||||
@@ -1692,6 +1715,9 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
return this.isClerk ? this.clerkTypOptions : this.cashierTypOptions;
|
||||
}
|
||||
|
||||
sponsorBanks: any[] = [];
|
||||
ddGlMapping: any = null;
|
||||
|
||||
// ── Denomination ──────────────────────────────────────────────────────────
|
||||
notes: Denomination[] = [];
|
||||
coins: Denomination[] = [];
|
||||
@@ -1752,6 +1778,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
|
||||
this.loadCashVaultGl();
|
||||
this.loadDdGstPercentage();
|
||||
this.loadSponsorBanks();
|
||||
|
||||
if (this.reviewMode) {
|
||||
this.loadRequestIntoForm(this.reviewRequestId!, 'review');
|
||||
@@ -1842,12 +1869,85 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
.getGlMappings()
|
||||
.pipe(catchError(() => of([])))
|
||||
.subscribe((mappings: any[]) => {
|
||||
const mapping = mappings && mappings.length ? mappings[0] : null;
|
||||
this.ddGstPercentage = Number(mapping?.gstPercentage ?? 18);
|
||||
this.ddLimitAmount = mapping?.ddLimitAmount != null ? Number(mapping.ddLimitAmount) : null;
|
||||
this.ddGlMapping = mappings && mappings.length ? mappings[0] : null;
|
||||
this.ddGstPercentage = Number(this.ddGlMapping?.gstPercentage ?? 18);
|
||||
this.ddLimitAmount = this.ddGlMapping?.ddLimitAmount != null ? Number(this.ddGlMapping.ddLimitAmount) : null;
|
||||
if (this.ddGlMapping?.ddPayableGlAccountId) {
|
||||
if (this.ddGlMapping.ddPayableGlCode) {
|
||||
this.applyDdGlMappingToDdRows();
|
||||
} else {
|
||||
this.http
|
||||
.get<any>(`/glaccounts/${this.ddGlMapping.ddPayableGlAccountId}`)
|
||||
.pipe(catchError(() => of(null)))
|
||||
.subscribe((glAcc: any) => {
|
||||
if (glAcc) {
|
||||
this.ddGlMapping.ddPayableGlCode = glAcc.glCode || String(glAcc.id);
|
||||
this.ddGlMapping.ddPayableGlAccountName = glAcc.name;
|
||||
}
|
||||
this.applyDdGlMappingToDdRows();
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
applyDdGlMappingToRow(row: TxnRow): void {
|
||||
if (row.typ !== 'DD' || !this.ddGlMapping) return;
|
||||
// Leaves an account-funded DD alone: its A/C NO. is the member's account the clerk typed, and
|
||||
// overwriting it with the DD Payable GL would silently turn the row back into a cash draft.
|
||||
if (this.isDdAccountDebitRow(row)) return;
|
||||
row.acTyp = 'GL';
|
||||
row.dbCr = 'Cr';
|
||||
|
||||
if (this.ddGlMapping.ddPayableGlCode) {
|
||||
row.acNo = this.ddGlMapping.ddPayableGlCode;
|
||||
this.selectedRow = row;
|
||||
this.loadAccountDetails(row.acNo, 'GL');
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.ddGlMapping.ddPayableGlAccountId) {
|
||||
this.http
|
||||
.get<any>(`/glaccounts/${this.ddGlMapping.ddPayableGlAccountId}`)
|
||||
.pipe(catchError(() => of(null)))
|
||||
.subscribe((glAcc: any) => {
|
||||
if (glAcc) {
|
||||
this.ddGlMapping.ddPayableGlCode = glAcc.glCode || String(glAcc.id);
|
||||
this.ddGlMapping.ddPayableGlAccountName = glAcc.name;
|
||||
row.acNo = this.ddGlMapping.ddPayableGlCode;
|
||||
this.selectedRow = row;
|
||||
this.loadAccountDetails(row.acNo, 'GL');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private applyDdGlMappingToDdRows(): void {
|
||||
for (const row of this.rows) {
|
||||
if (row.typ === 'DD') {
|
||||
this.applyDdGlMappingToRow(row);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private loadSponsorBanks(): void {
|
||||
this.sponsorDDService
|
||||
.getSponsorBanks()
|
||||
.pipe(catchError(() => of([])))
|
||||
.subscribe((banks: any[]) => {
|
||||
this.sponsorBanks = banks || [];
|
||||
for (const row of this.rows) {
|
||||
if (row.typ === 'DD' && !row.sponsorBankId && this.sponsorBanks.length > 0) {
|
||||
row.sponsorBankId = this.sponsorBanks[0].id;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
get isDdGlMappingConfigured(): boolean {
|
||||
return !!(this.ddGlMapping && this.ddGlMapping.ddPayableGlAccountId && this.ddGlMapping.cashGlAccountId);
|
||||
}
|
||||
|
||||
private loadDayStatus(): void {
|
||||
this.cashierService
|
||||
.getCashierDayStatus()
|
||||
@@ -2093,9 +2193,12 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
row.chequeNo = '';
|
||||
row.cheqDate = '';
|
||||
break;
|
||||
// A cheque clears through the bank rather than the drawer, so it opens on the transfer
|
||||
// instrument. Cash is still on its dropdown — a cheque can be cashed over the counter — but
|
||||
// it is the exception, not what the field should start on.
|
||||
case 'Cheque':
|
||||
row.dbCr = 'Dr';
|
||||
row.paymentTypeId = this.findCashPaymentTypeId();
|
||||
row.paymentTypeId = this.findTransferPaymentTypeId();
|
||||
break;
|
||||
case 'Disbursement':
|
||||
case 'Fund Transfer':
|
||||
@@ -2121,16 +2224,25 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
row.chequeNo = '';
|
||||
row.cheqDate = '';
|
||||
break;
|
||||
// A DD is bought with cash: the customer hands over Face Value + Charges + GST, so it is money
|
||||
// coming IN (Cr) against the GL the draft is drawn on. All three of Cr, a cash payment type and
|
||||
// AC.TP = GL are what isDdCashGlRow() looks for -- without them the DD Charges & GST panel never
|
||||
// appears and the row would post as an ordinary entry with the charges silently dropped.
|
||||
// A DD is bought with cash: the purchaser hands over Face Value + Charges + GST, so it is
|
||||
// money coming IN (Cr) against the GL the draft is drawn on. All three of Cr, a cash payment
|
||||
// type and AC.TP = GL are what isDdCashGlRow() looks for -- without them the DD Charges & GST
|
||||
// panel never appears and the row would post as an ordinary entry with the charges silently
|
||||
// dropped.
|
||||
//
|
||||
// A clerk funds a draft through an account or an instrument instead, and their TXN TP. list no
|
||||
// longer offers cash, so defaulting to it would open the field on a value it cannot show.
|
||||
// isDdCashGlRow() still holds for them through AC.TP = GL.
|
||||
case 'DD':
|
||||
row.dbCr = 'Cr';
|
||||
row.paymentTypeId = this.findCashPaymentTypeId();
|
||||
row.acTyp = 'GL';
|
||||
row.paymentTypeId = this.isClerk ? this.findTransferPaymentTypeId() : this.findCashPaymentTypeId();
|
||||
row.chequeNo = '';
|
||||
row.cheqDate = '';
|
||||
if (this.sponsorBanks.length > 0 && !row.sponsorBankId) {
|
||||
row.sponsorBankId = this.sponsorBanks[0].id;
|
||||
}
|
||||
this.applyDdGlMappingToRow(row);
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -2145,10 +2257,33 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
return pt?.id ?? null;
|
||||
}
|
||||
|
||||
/** TYPE=DD + TXN TP=Cash + AC.TP=GL — the only combination that collects DD charges + GST from the customer. */
|
||||
/** TYPE=DD + TXN TP=Cash */
|
||||
isDdCashGlRow(row: TxnRow): boolean {
|
||||
if (this.isDdAccountDebitRow(row)) return false;
|
||||
const pt = this.paymentTypes.find((p: any) => p.id === row.paymentTypeId);
|
||||
return row.typ === 'DD' && !!pt?.isCashPayment && row.acTyp === 'GL';
|
||||
return row.typ === 'DD' && (row.acTyp === 'GL' || !row.acTyp || !!pt?.isCashPayment);
|
||||
}
|
||||
|
||||
/**
|
||||
* TYPE=DD funded by debiting a member's savings/current account rather than by cash.
|
||||
*
|
||||
* Clerk-only. A cashier's counter takes cash and nothing else, so offering them an account debit
|
||||
* would let a draft be raised against an account with no cash to reconcile it against — and the
|
||||
* clerk flow is the one that carries a maker-checker step over the member's balance.
|
||||
*/
|
||||
isDdAccountDebitRow(row: TxnRow): boolean {
|
||||
return this.isClerk && row.typ === 'DD' && (row.acTyp === 'SD' || row.acTyp === 'CA' || row.acTyp === 'CB');
|
||||
}
|
||||
|
||||
/**
|
||||
* Any DD row the charges/GST panel applies to.
|
||||
*
|
||||
* Charges and GST come from the admin's DD configuration and are collected whichever way the
|
||||
* draft is paid for — the difference is only where the money comes from, so the panel is the same
|
||||
* for both and the label on the total changes rather than the arithmetic.
|
||||
*/
|
||||
isDdRow(row: TxnRow): boolean {
|
||||
return this.isDdCashGlRow(row) || this.isDdAccountDebitRow(row);
|
||||
}
|
||||
|
||||
/** GST on DD charges only (never on the DD face value), at the admin-configured rate. */
|
||||
@@ -2179,6 +2314,258 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
return !!this.selectedRow && Math.abs(this.denominationTotal - this.ddTargetTotal) < 0.005;
|
||||
}
|
||||
|
||||
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}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cashier Walk-in DD creation flow:
|
||||
* 1. Validates one-time persistent GL configuration
|
||||
* 2. Auto-selects next available leaflet
|
||||
* 3. Opens dialog to capture purchaser/beneficiary details
|
||||
* 4. Submits issue request directly with physical denominations for manager approval
|
||||
*/
|
||||
openIssueWalkinDd(row: TxnRow): void {
|
||||
if (!this.isDdGlMappingConfigured) {
|
||||
this.showAlert('error', 'DD GL Mapping Not Configured', [
|
||||
'DD GL mapping is not configured.',
|
||||
'Please ask an administrator to set up the GL accounts in Admin > Sponsor DD GST / GL Settings before creating Demand Drafts.'
|
||||
]);
|
||||
return;
|
||||
}
|
||||
const faceValue = Number(row.amount);
|
||||
if (!faceValue || faceValue <= 0) {
|
||||
this.showAlert('error', 'Invalid Amount', ['Please enter the DD Face Value amount before proceeding.']);
|
||||
return;
|
||||
}
|
||||
const chargesAmount = Number(row.ddCharges) || 0;
|
||||
const gstAmount = this.ddGstAmountFor(row);
|
||||
const totalCashPayable = this.ddTotalPayableFor(row);
|
||||
const accountDebit = this.isDdAccountDebitRow(row);
|
||||
|
||||
// An account-funded draft needs the account resolved to a Fineract id before it can be sent:
|
||||
// the server takes a savings/current account id and a client id, not the typed number. Both are
|
||||
// filled in by the A/C NO. lookup, so an unresolved account means the number was never found.
|
||||
let fundingAccountId: number | null = null;
|
||||
let fundingClientId: number | null = null;
|
||||
if (accountDebit) {
|
||||
const acNo = (row.acNo || '').trim();
|
||||
if (!acNo) {
|
||||
this.showAlert('error', 'Account Required', [
|
||||
'Enter the account number the Demand Draft is funded from.'
|
||||
]);
|
||||
return;
|
||||
}
|
||||
fundingAccountId = this.rowAccountIds[acNo] ?? null;
|
||||
fundingClientId = this.rowClientIds[acNo] ?? null;
|
||||
if (!fundingAccountId || !fundingClientId) {
|
||||
this.showAlert('error', 'Account Not Found', [
|
||||
`Account ${acNo} could not be resolved.`,
|
||||
'Check the account number and wait for its details to load before raising the draft.'
|
||||
]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Only a cash draft has notes to reconcile. An account debit takes no cash at the counter, so a
|
||||
// denomination count left over from an earlier row must not be checked against it.
|
||||
if (!accountDebit && this.denominationTotal > 0 && Math.abs(this.denominationTotal - totalCashPayable) > 0.005) {
|
||||
this.showAlert('error', 'Denomination Mismatch', [
|
||||
`Cash denomination counted (₹${this.denominationTotal}) does not match total payable cash (₹${totalCashPayable}).`,
|
||||
'Please verify and match the physical cash denomination count.'
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
this.saving = true;
|
||||
this.sponsorDDService.getNextAvailableLeaflet(row.br, row.sponsorBankId ?? undefined).subscribe({
|
||||
next: (leaflet) => {
|
||||
this.saving = false;
|
||||
if (!leaflet) {
|
||||
this.showAlert('error', 'No Available Leaflets', [
|
||||
'No available Demand Draft leaflets found for this branch and sponsor bank.',
|
||||
'Please register a DD inventory series first.'
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedBank = this.sponsorBanks.find((b: any) => b.id === row.sponsorBankId);
|
||||
const branchName = this.branchNameFor(row.br) || 'Branch ' + row.br;
|
||||
|
||||
const dialogRef = this.dialog.open(CashierIssueDdDialogComponent, {
|
||||
width: '600px',
|
||||
disableClose: true,
|
||||
data: {
|
||||
leaflet,
|
||||
branchName,
|
||||
sponsorBankName: selectedBank?.name || 'Sponsor Bank',
|
||||
faceValue,
|
||||
chargesAmount,
|
||||
gstPercentage: this.ddGstPercentage,
|
||||
gstAmount,
|
||||
totalCashPayable,
|
||||
paymentMethod: accountDebit ? ('ACCOUNT_DEBIT' as const) : ('CASH' as const),
|
||||
accountNo: accountDebit ? row.acNo : undefined,
|
||||
accountHolderName: accountDebit ? this.rowAccountNames[(row.acNo || '').trim()] : undefined
|
||||
}
|
||||
});
|
||||
|
||||
dialogRef.afterClosed().subscribe((result) => {
|
||||
if (!result) return;
|
||||
if (accountDebit) {
|
||||
this.submitAccountDebitDd(row, leaflet, result, fundingAccountId!, fundingClientId!);
|
||||
} else {
|
||||
this.submitWalkinCashDd(row, leaflet, result);
|
||||
}
|
||||
});
|
||||
},
|
||||
error: (err) => {
|
||||
this.saving = false;
|
||||
this.showAlert('error', 'Error Auto-Selecting DD Leaflet', [err.error?.message || err.message]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Raises a Demand Draft funded by debiting a member's savings or current account.
|
||||
*
|
||||
* Clerk-only, and a maker-checker request like the cash flow: nothing is debited here. The member's
|
||||
* account is only touched when a checker approves, which is also when the balance is re-checked —
|
||||
* so a draft raised against a sufficient balance can still be refused if the balance has since
|
||||
* gone. No denominations are sent, because no cash is taken.
|
||||
*
|
||||
* The account goes in savingsAccountId or currentAccountId, never both: the server insists on
|
||||
* exactly one, since which table the account lives in decides which control GL is debited.
|
||||
*/
|
||||
private submitAccountDebitDd(
|
||||
row: TxnRow,
|
||||
leaflet: any,
|
||||
beneficiaryData: any,
|
||||
fundingAccountId: number,
|
||||
fundingClientId: number
|
||||
): void {
|
||||
const d = new Date();
|
||||
const issuedDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
const isCurrent = row.acTyp === 'CA' || row.acTyp === 'CB';
|
||||
|
||||
const payload = {
|
||||
clientId: fundingClientId,
|
||||
beneficiaryName: beneficiaryData.beneficiaryName,
|
||||
payableAtBranch: beneficiaryData.payableAtBranch,
|
||||
payableAtCity: beneficiaryData.payableAtCity,
|
||||
faceValue: Number(row.amount),
|
||||
chargesAmount: Number(row.ddCharges) || 0,
|
||||
issuingBranchId: row.br,
|
||||
issuedDate,
|
||||
paymentMethod: 'ACCOUNT_DEBIT' as const,
|
||||
savingsAccountId: isCurrent ? undefined : fundingAccountId,
|
||||
currentAccountId: isCurrent ? fundingAccountId : undefined
|
||||
};
|
||||
|
||||
this.saving = true;
|
||||
this.sponsorDDService.submitIssueRequest(leaflet.id, payload).subscribe({
|
||||
next: () => {
|
||||
this.saving = false;
|
||||
this.showAlert('success', 'DD Issuance Request Submitted', [
|
||||
`Demand Draft request for DD No. ${leaflet.ddNumber} (Face Value: ₹${row.amount}) submitted successfully!`,
|
||||
`₹${this.ddTotalPayableFor(row)} will be debited from ${row.acNo} on approval.`,
|
||||
'Sent to Manager / Head Cashier for maker-checker approval.'
|
||||
]);
|
||||
row.amount = null;
|
||||
row.ddCharges = null;
|
||||
row.description = '';
|
||||
},
|
||||
error: (err) => {
|
||||
this.saving = false;
|
||||
this.showAlert('error', 'DD Submission Failed', [err.error?.message || err.message]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private submitWalkinCashDd(row: TxnRow, leaflet: any, purchaserData: any): void {
|
||||
// Read through countFor, which knows the map is keyed by kind:value. Indexing by d.id looked
|
||||
// right but matched nothing, so every cash DD went in with no breakdown at all and its money
|
||||
// vanished from the closing report -- the notes were counted on screen and then dropped.
|
||||
const denominationMap: { [key: string]: number } = {};
|
||||
for (const d of [
|
||||
...this.notes,
|
||||
...this.coins
|
||||
]) {
|
||||
const count = this.countFor(d);
|
||||
if (count > 0) {
|
||||
denominationMap[this.denomKey(d)] = count;
|
||||
}
|
||||
}
|
||||
|
||||
// The breakdown is the only record of which notes were taken, and the server insists it add up
|
||||
// to the cash collected. Checked here so the cashier is told which figure is wrong while the
|
||||
// counts are still on screen, rather than getting a rejection after the fact.
|
||||
//
|
||||
// ddTotalPayableFor rounds up to the whole rupee actually collected, which is what the notes
|
||||
// can add to; the exact figure with paise is accepted too, for a draft that lands on a rupee.
|
||||
const totalCollected = Number(this.ddTotalPayableFor(row));
|
||||
const exactPayable = Number(row.amount || 0) + (Number(row.ddCharges) || 0) + this.ddGstAmountFor(row);
|
||||
const countedTotal = Object.entries(denominationMap).reduce(
|
||||
(sum, [
|
||||
key,
|
||||
count
|
||||
]) => sum + Number(key.split(':')[1]) * count,
|
||||
0
|
||||
);
|
||||
if (countedTotal !== totalCollected && countedTotal !== exactPayable) {
|
||||
this.showAlert('error', 'Denomination Entry Does Not Match', [
|
||||
countedTotal === 0
|
||||
? 'Enter the notes and coins taken from the purchaser before submitting.'
|
||||
: `The notes and coins entered come to ₹${countedTotal.toFixed(2)}, but ₹${totalCollected.toFixed(2)} is collected (face value + charges + GST).`,
|
||||
'The breakdown must equal the cash taken — it is what the closing report and the drawer are checked against.'
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
const d = new Date();
|
||||
const issuedDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
|
||||
const payload = {
|
||||
walkinPurchaserName: purchaserData.walkinPurchaserName,
|
||||
walkinPurchaserAddress: purchaserData.walkinPurchaserAddress,
|
||||
walkinPurchaserAadhaar: purchaserData.walkinPurchaserAadhaar,
|
||||
beneficiaryName: purchaserData.beneficiaryName,
|
||||
payableAtBranch: purchaserData.payableAtBranch,
|
||||
payableAtCity: purchaserData.payableAtCity,
|
||||
faceValue: Number(row.amount),
|
||||
chargesAmount: Number(row.ddCharges) || 0,
|
||||
issuingBranchId: row.br,
|
||||
issuedDate,
|
||||
paymentMethod: 'CASH' as const,
|
||||
denominationData: Object.keys(denominationMap).length > 0 ? JSON.stringify(denominationMap) : undefined
|
||||
};
|
||||
|
||||
this.saving = true;
|
||||
this.sponsorDDService.submitIssueRequest(leaflet.id, payload).subscribe({
|
||||
next: () => {
|
||||
this.saving = false;
|
||||
this.showAlert('success', 'DD Issuance Request Submitted', [
|
||||
`Demand Draft request for DD No. ${leaflet.ddNumber} (Face Value: ₹${row.amount}, Total Cash: ₹${this.ddTotalPayableFor(row)}) submitted successfully!`,
|
||||
'Sent to Manager / Head Cashier for maker-checker approval.'
|
||||
]);
|
||||
row.amount = null;
|
||||
row.ddCharges = null;
|
||||
row.description = '';
|
||||
this.clearDenominations();
|
||||
},
|
||||
error: (err) => {
|
||||
this.saving = false;
|
||||
this.showAlert('error', 'DD Submission Failed', [err.error?.message || err.message]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Submits a clerk double-entry request (maker-checker, PENDING until manager approves).
|
||||
* Disbursement: 1 DR [GL] source → N CR savings/current/GL destinations.
|
||||
@@ -2811,16 +3198,23 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
const f = cfg.matchField;
|
||||
// Exact match first, then suffix/partial match. Some account types (e.g. Share — its
|
||||
// list endpoint only accepts offset/limit, no accountNo filter at all) always return the
|
||||
// FULL unfiltered list rather than a server-side-narrowed one, so a partial match can't be
|
||||
// gated on "exactly one row came back" — it never would be for those types.
|
||||
const exactMatch = allItems.find((x: any) => x[f] === acNo);
|
||||
const exactMatch = allItems.find((x: any) => String(x[f] ?? '').trim() === String(acNo).trim());
|
||||
const partialMatch = !exactMatch
|
||||
? (allItems.find((x: any) => x[f]?.endsWith(acNo)) ?? allItems.find((x: any) => x[f]?.includes(acNo)))
|
||||
? (allItems.find((x: any) => String(x[f] ?? '').endsWith(acNo)) ??
|
||||
allItems.find((x: any) => String(x[f] ?? '').includes(acNo)))
|
||||
: null;
|
||||
const match = exactMatch ?? partialMatch ?? null;
|
||||
const idMatch =
|
||||
!exactMatch && !partialMatch && acTyp === 'GL'
|
||||
? allItems.find((x: any) => String(x.id) === String(acNo))
|
||||
: null;
|
||||
const match = exactMatch ?? partialMatch ?? idMatch ?? null;
|
||||
if (match?.id) {
|
||||
const matchedGlCode = match[f] ?? acNo;
|
||||
if (this.selectedRow && this.selectedRow.typ === 'DD' && match.glCode) {
|
||||
this.selectedRow.acNo = match.glCode;
|
||||
}
|
||||
const clientIdFromSearch = acTyp !== 'GL' && match.clientId != null ? String(match.clientId) : null;
|
||||
this.fetchAllDetails(match.id, match[f] ?? acNo, cfg, token, acTyp, clientIdFromSearch);
|
||||
this.fetchAllDetails(match.id, matchedGlCode, cfg, token, acTyp, clientIdFromSearch);
|
||||
return;
|
||||
}
|
||||
this.loadingAccount = false;
|
||||
@@ -3048,7 +3442,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
return false;
|
||||
}
|
||||
const returnedId = acc.glCode ?? acc.accountNo;
|
||||
if (returnedId && returnedId !== acNo) {
|
||||
if (returnedId && returnedId !== acNo && String(acc.id) !== acNo) {
|
||||
this.loadingAccount = false;
|
||||
this.accountError = 'Account mismatch. Please re-enter account number and try again.';
|
||||
return false;
|
||||
@@ -3294,7 +3688,6 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
{ label: 'NEFT Facility', active: !!f.neftFacility },
|
||||
{ label: 'RTGS Facility', active: !!f.rtgsFacility },
|
||||
{ label: 'Inter-Bank Cap', active: !!f.interBankTotalCapFacility },
|
||||
{ label: 'Withdrawal', active: !!f.withdrawalFacility },
|
||||
{ label: 'Deposit (KYC)', active: !!f.depositFacilityKyc },
|
||||
{ label: 'Virtual Debit Card', active: !!f.virtualDebitCard },
|
||||
{ label: 'OD', active: !!this.accountDetails?.allowOverdraft }
|
||||
@@ -4579,6 +4972,10 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
confirmSubmit(): void {
|
||||
this.showConfirmDialog = false;
|
||||
const row = this.selectedRow!;
|
||||
if (row.typ === 'DD') {
|
||||
this.openIssueWalkinDd(row);
|
||||
return;
|
||||
}
|
||||
const amount = this.isDdCashGlRow(row) ? this.ddTotalPayableFor(row) : (row.amount ?? this.denominationTotal);
|
||||
const isDeposit = row.dbCr === 'Cr';
|
||||
const command = isDeposit ? 'deposit' : 'withdrawal';
|
||||
|
||||
@@ -1,183 +0,0 @@
|
||||
<!--
|
||||
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 · EXCEPTIONS</div>
|
||||
<h1 class="pg-title">Voucher Exceptions</h1>
|
||||
<p class="pg-sub">
|
||||
Vouchers flagged at the counter — where the slip and the system disagree, or something else about the entry is
|
||||
wrong. A clerk or manager reviews each one with the cashier, records what was found, and attaches the supporting
|
||||
document.
|
||||
</p>
|
||||
</div>
|
||||
<div class="pg-toolbar">
|
||||
<label class="pg-field">
|
||||
<span>Show</span>
|
||||
<select [(ngModel)]="filter" (change)="load()">
|
||||
<option value="OPEN">Open</option>
|
||||
<option value="RESOLVED">Resolved</option>
|
||||
<option value="ALL">All</option>
|
||||
</select>
|
||||
</label>
|
||||
<button type="button" class="pg-btn pg-btn--primary" (click)="load()" [disabled]="loading">
|
||||
<mat-icon>refresh</mat-icon> Reload
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@if (errorMsg) {
|
||||
<div class="pg-alert pg-alert--error">{{ errorMsg }}</div>
|
||||
}
|
||||
|
||||
@if (!canResolve && loaded) {
|
||||
<div class="ve-note">
|
||||
<mat-icon>info</mat-icon>
|
||||
You can see these exceptions but not close them — a Clerk, Head Cashier or Bank Manager records the conclusion, so
|
||||
that whoever raised the flag is not the one who rules on it.
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (loading) {
|
||||
<div class="pg-empty">Loading…</div>
|
||||
}
|
||||
|
||||
@if (!loading && loaded && exceptions.length === 0) {
|
||||
<div class="pg-empty">
|
||||
<mat-icon>check_circle</mat-icon>
|
||||
<p>{{ filter === 'OPEN' ? 'No open exceptions. Everything checked so far agrees.' : 'Nothing to show.' }}</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
@for (e of exceptions; track e.voucherNo) {
|
||||
<div class="ve-card" [class.ve-card--resolved]="e.resolutionStatus">
|
||||
<div class="ve-card__head">
|
||||
<span class="pg-chip">{{ e.voucherNo }}</span>
|
||||
@if (e.resolutionStatus) {
|
||||
<span class="db-badge db-badge--ok">RESOLVED</span>
|
||||
} @else {
|
||||
<span class="db-badge db-badge--rejected">⚠ OPEN</span>
|
||||
}
|
||||
<span class="db-muted">
|
||||
{{ e.accountNo }} · {{ e.party }} · raised by {{ e.raisedByName }} on {{ stamp(e.raisedAt) }}
|
||||
</span>
|
||||
@if (!e.resolutionStatus && canResolve) {
|
||||
<button type="button" class="pg-btn pg-btn--primary ve-card__act" (click)="openResolve(e)">
|
||||
Review & Close
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="ve-card__amts">
|
||||
<div class="db-cmp">
|
||||
<span class="db-cmp__label">System recorded</span>
|
||||
<span class="db-cmp__value">₹{{ e.systemAmount | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
@if (!amountsAgree(e)) {
|
||||
<div class="db-cmp db-cmp--bad">
|
||||
<span class="db-cmp__label">Written on slip</span>
|
||||
<span class="db-cmp__value">₹{{ e.slipAmount | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
<div class="db-cmp db-cmp--bad">
|
||||
<span class="db-cmp__label">Difference</span>
|
||||
<span class="db-cmp__value">₹{{ difference(e) | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="db-cmp">
|
||||
<span class="db-cmp__label">Amounts</span>
|
||||
<span class="db-cmp__value">Agree — flagged for another reason</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<p class="ve-card__why"><strong>Cashier reported:</strong> {{ e.remarks }}</p>
|
||||
|
||||
@if (e.resolutionStatus) {
|
||||
<div class="ve-card__resolution">
|
||||
<strong>{{ resolutionLabel(e.resolutionStatus) }}</strong> — {{ e.resolutionNotes }}
|
||||
<span class="db-muted">· closed by {{ e.resolvedByName }} on {{ stamp(e.resolvedAt) }}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Resolution panel, opened under the exception it closes. -->
|
||||
@if (resolveRow === e) {
|
||||
<div class="db-panel ve-panel">
|
||||
<div class="db-panel__head">
|
||||
<strong>Close exception — {{ e.voucherNo }}</strong>
|
||||
<button type="button" class="db-icon-btn" (click)="closeResolve()" title="Close">
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<label class="pg-field ve-panel__field">
|
||||
<span>What was wrong?</span>
|
||||
<select [(ngModel)]="resolutionStatus">
|
||||
<option value="">— Select —</option>
|
||||
@for (o of resolutionOptions; track o.value) {
|
||||
<option [value]="o.value">{{ o.label }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="pg-field ve-panel__field">
|
||||
<span>What was found, and what was done about it?</span>
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="resolutionNotes"
|
||||
placeholder="e.g. teller keyed 700 instead of 500 — reversing entry passed, member informed"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div class="ve-panel__docs">
|
||||
<strong>Supporting document</strong>
|
||||
<p class="db-muted">
|
||||
An exception cannot be closed on someone's word alone — attach the corrected slip, a signed note, or
|
||||
whatever the decision rests on.
|
||||
</p>
|
||||
<label class="db-upload">
|
||||
<input type="file" (change)="onFileSelected($event)" [disabled]="attachUploading" />
|
||||
<span class="db-muted">Max 10 MB</span>
|
||||
</label>
|
||||
@if (attachUploading) {
|
||||
<p class="db-muted">Uploading…</p>
|
||||
}
|
||||
@if (attachLoading) {
|
||||
<p class="db-muted">Loading…</p>
|
||||
}
|
||||
@if (!attachLoading && attachments.length === 0) {
|
||||
<p class="ve-panel__warn">No document attached yet — required before closing.</p>
|
||||
}
|
||||
@for (a of attachments; track a.id) {
|
||||
<div class="db-file">
|
||||
<mat-icon>description</mat-icon>
|
||||
<span class="db-file__name">{{ a.fileName }}</span>
|
||||
<span class="db-muted">{{ a.uploadedByName }}</span>
|
||||
<button type="button" class="db-icon-btn" (click)="downloadAttachment(a)" title="Download">
|
||||
<mat-icon>download</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="db-panel__actions">
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--primary"
|
||||
(click)="saveResolution()"
|
||||
[disabled]="!canSubmitResolution"
|
||||
>
|
||||
Close Exception
|
||||
</button>
|
||||
<button type="button" class="pg-btn" (click)="closeResolve()">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -1,120 +0,0 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
/* Page chrome comes from cashier-shared.scss, and the comparison tiles, panel and file rows are
|
||||
shared with the day book. Only the exception card itself is defined here. */
|
||||
|
||||
.ve-note {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 14px;
|
||||
margin-bottom: 14px;
|
||||
background: #eff6ff;
|
||||
border: 1px solid #bfdbfe;
|
||||
border-radius: 8px;
|
||||
color: #1e40af;
|
||||
font-size: 12px;
|
||||
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
/* One card per flagged voucher. Open ones carry a red edge; closed ones go green, so a queue
|
||||
reads at a glance without opening anything. */
|
||||
.ve-card {
|
||||
padding: 14px 16px;
|
||||
margin-bottom: 12px;
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-left: 4px solid #dc2626;
|
||||
border-radius: 8px;
|
||||
|
||||
&--resolved {
|
||||
border-left-color: #16a34a;
|
||||
background: #fafffb;
|
||||
}
|
||||
|
||||
&__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
/* Pushed right so the identity of the voucher reads first. */
|
||||
&__act {
|
||||
margin-left: auto;
|
||||
height: 32px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
&__amts {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
&__why {
|
||||
margin: 0;
|
||||
padding: 8px 12px;
|
||||
background: #f8fafc;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
color: #0d1b2a;
|
||||
}
|
||||
|
||||
&__resolution {
|
||||
margin-top: 10px;
|
||||
padding: 8px 12px;
|
||||
background: #f0fdf4;
|
||||
border: 1px solid #bbf7d0;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
color: #14532d;
|
||||
}
|
||||
}
|
||||
|
||||
.ve-panel {
|
||||
margin-top: 12px;
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
|
||||
&__field {
|
||||
display: block;
|
||||
margin-bottom: 12px;
|
||||
max-width: 640px;
|
||||
}
|
||||
|
||||
&__docs {
|
||||
padding: 12px;
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
|
||||
p {
|
||||
margin: 4px 0 10px;
|
||||
}
|
||||
}
|
||||
|
||||
&__warn {
|
||||
padding: 6px 10px;
|
||||
background: #fffbeb;
|
||||
border: 1px solid #fde68a;
|
||||
border-radius: 4px;
|
||||
color: #92400e;
|
||||
}
|
||||
}
|
||||
@@ -1,248 +0,0 @@
|
||||
/**
|
||||
* 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 { FormsModule } from '@angular/forms';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { catchError, of } from 'rxjs';
|
||||
import { CashierService } from '../cashier.service';
|
||||
|
||||
interface VoucherException {
|
||||
voucherNo: string;
|
||||
systemAmount: number | null;
|
||||
slipAmount: number | null;
|
||||
remarks: string | null;
|
||||
raisedByName: string | null;
|
||||
raisedAt: string | null;
|
||||
accountNo: string | null;
|
||||
party: string | null;
|
||||
raisedAgainst: string | null;
|
||||
attachmentCount: number;
|
||||
resolutionStatus: string | null;
|
||||
resolutionNotes: string | null;
|
||||
resolvedByName: string | null;
|
||||
resolvedAt: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Voucher exceptions raised at the counter, and the conclusion recorded on each.
|
||||
*
|
||||
* The cashier who spots a difference cannot close it — this screen is where a clerk, head cashier
|
||||
* or manager sits down with them, records what was actually wrong, and attaches the paper that
|
||||
* supports the decision.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-voucher-exceptions',
|
||||
templateUrl: './voucher-exceptions.component.html',
|
||||
styleUrls: [
|
||||
'../cashier-shared.scss',
|
||||
'../day-book/day-book.component.scss',
|
||||
'./voucher-exceptions.component.scss'
|
||||
],
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
MatIconModule
|
||||
]
|
||||
})
|
||||
export class VoucherExceptionsComponent implements OnInit {
|
||||
private service = inject(CashierService);
|
||||
|
||||
filter: 'OPEN' | 'RESOLVED' | 'ALL' = 'OPEN';
|
||||
exceptions: VoucherException[] = [];
|
||||
/** Set by the backend from the caller's own roles — the cashier who raised a flag cannot close it. */
|
||||
canResolve = false;
|
||||
loading = false;
|
||||
loaded = false;
|
||||
errorMsg = '';
|
||||
|
||||
// Resolution panel
|
||||
resolveRow: VoucherException | null = null;
|
||||
resolutionStatus = '';
|
||||
resolutionNotes = '';
|
||||
resolveSaving = false;
|
||||
|
||||
// Attachments for the voucher being resolved
|
||||
attachments: any[] = [];
|
||||
attachLoading = false;
|
||||
attachUploading = false;
|
||||
|
||||
readonly resolutionOptions = [
|
||||
{ value: 'RESOLVED_SYSTEM_ERROR', label: 'System entry was wrong' },
|
||||
{ value: 'RESOLVED_SLIP_ERROR', label: 'Slip was written wrong' },
|
||||
{ value: 'RESOLVED_NO_ACTION', label: 'No action needed' }
|
||||
];
|
||||
|
||||
ngOnInit(): void {
|
||||
this.load();
|
||||
}
|
||||
|
||||
load(): void {
|
||||
this.loading = true;
|
||||
this.errorMsg = '';
|
||||
this.service
|
||||
.voucherExceptions(this.filter)
|
||||
.pipe(
|
||||
catchError((err: any) => {
|
||||
this.errorMsg =
|
||||
err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.message ?? 'Could not load voucher exceptions.';
|
||||
return of(null);
|
||||
})
|
||||
)
|
||||
.subscribe((res: any) => {
|
||||
this.loading = false;
|
||||
this.loaded = true;
|
||||
this.exceptions = res?.exceptions ?? [];
|
||||
this.canResolve = !!res?.canResolve;
|
||||
});
|
||||
}
|
||||
|
||||
difference(e: VoucherException): number {
|
||||
if (e.slipAmount == null) return 0;
|
||||
return Number(e.slipAmount) - Number(e.systemAmount ?? 0);
|
||||
}
|
||||
|
||||
/** True when the flag was about something other than the amount. */
|
||||
amountsAgree(e: VoucherException): boolean {
|
||||
return e.slipAmount == null || Number(e.slipAmount) === Number(e.systemAmount ?? 0);
|
||||
}
|
||||
|
||||
openResolve(e: VoucherException): void {
|
||||
this.resolveRow = e;
|
||||
this.resolutionStatus = '';
|
||||
this.resolutionNotes = '';
|
||||
this.loadAttachments(e);
|
||||
}
|
||||
|
||||
closeResolve(): void {
|
||||
this.resolveRow = null;
|
||||
this.attachments = [];
|
||||
}
|
||||
|
||||
private loadAttachments(e: VoucherException): void {
|
||||
this.attachments = [];
|
||||
this.attachLoading = true;
|
||||
this.service
|
||||
.listVoucherAttachments(e.voucherNo)
|
||||
.pipe(catchError(() => of(null)))
|
||||
.subscribe((res: any) => {
|
||||
this.attachLoading = false;
|
||||
this.attachments = res?.attachments ?? [];
|
||||
});
|
||||
}
|
||||
|
||||
onFileSelected(event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
const row = this.resolveRow;
|
||||
if (!file || !row) return;
|
||||
|
||||
// 10 MB, matching the backend — refusing here saves uploading the whole file to be rejected.
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
this.errorMsg = 'That file is larger than 10 MB. Please scan it at a lower resolution.';
|
||||
input.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
this.attachUploading = true;
|
||||
this.errorMsg = '';
|
||||
this.service
|
||||
.uploadVoucherAttachment(
|
||||
row.voucherNo,
|
||||
file.name,
|
||||
file.type || 'application/octet-stream',
|
||||
String(reader.result),
|
||||
'Supporting document for exception resolution'
|
||||
)
|
||||
.pipe(
|
||||
catchError((err: any) => {
|
||||
this.errorMsg =
|
||||
err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.message ?? 'Upload failed. Please try again.';
|
||||
return of(null);
|
||||
})
|
||||
)
|
||||
.subscribe((res: any) => {
|
||||
this.attachUploading = false;
|
||||
input.value = '';
|
||||
if (res) {
|
||||
this.loadAttachments(row);
|
||||
this.load();
|
||||
}
|
||||
});
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
|
||||
downloadAttachment(a: any): void {
|
||||
this.service
|
||||
.downloadVoucherAttachment(a.id)
|
||||
.pipe(catchError(() => of(null)))
|
||||
.subscribe((blob: Blob | null) => {
|
||||
if (!blob) {
|
||||
this.errorMsg = 'Could not download that file.';
|
||||
return;
|
||||
}
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = a.fileName ?? 'attachment';
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
});
|
||||
}
|
||||
|
||||
/** Mirrors the backend's own rule, so the button explains itself before it is pressed. */
|
||||
get canSubmitResolution(): boolean {
|
||||
return (
|
||||
!!this.resolutionStatus && !!this.resolutionNotes.trim() && this.attachments.length > 0 && !this.resolveSaving
|
||||
);
|
||||
}
|
||||
|
||||
saveResolution(): void {
|
||||
const row = this.resolveRow;
|
||||
if (!row) return;
|
||||
|
||||
this.resolveSaving = true;
|
||||
this.errorMsg = '';
|
||||
this.service
|
||||
.resolveVoucherException(row.voucherNo, this.resolutionStatus, this.resolutionNotes.trim())
|
||||
.pipe(
|
||||
catchError((err: any) => {
|
||||
this.errorMsg =
|
||||
err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.message ?? 'Could not close the exception.';
|
||||
return of(null);
|
||||
})
|
||||
)
|
||||
.subscribe((res: any) => {
|
||||
this.resolveSaving = false;
|
||||
if (res) {
|
||||
this.closeResolve();
|
||||
this.load();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
resolutionLabel(status: string | null): string {
|
||||
if (!status) return '';
|
||||
return this.resolutionOptions.find((o) => o.value === status)?.label ?? status;
|
||||
}
|
||||
|
||||
/** Date and time — an exception queue spans days, so the day matters here. */
|
||||
stamp(iso: string | null): string {
|
||||
if (!iso) return '—';
|
||||
const [
|
||||
d,
|
||||
t
|
||||
] = iso.split('T');
|
||||
return t ? `${d} ${t.slice(0, 5)}` : d;
|
||||
}
|
||||
}
|
||||
@@ -10,10 +10,8 @@
|
||||
<header class="pg-head vr-no-print">
|
||||
<div>
|
||||
<div class="pg-eyebrow">CASHIER · REPORT</div>
|
||||
<h1 class="pg-title">Voucher Report</h1>
|
||||
<p class="pg-sub">
|
||||
Every voucher raised over a day or a month — the number, what was debited, what was credited, and who raised it.
|
||||
</p>
|
||||
<h1 class="pg-title">{{ title }}</h1>
|
||||
<p class="pg-sub">{{ subtitle }}</p>
|
||||
</div>
|
||||
<div class="pg-toolbar">
|
||||
<label class="pg-field">
|
||||
@@ -42,10 +40,8 @@
|
||||
<button type="button" class="pg-btn pg-btn--primary" (click)="load()" [disabled]="loading">
|
||||
<mat-icon>refresh</mat-icon> Run
|
||||
</button>
|
||||
@if (report && report.count > 0) {
|
||||
<button type="button" class="pg-btn" (click)="print()">
|
||||
<mat-icon>print</mat-icon> Print
|
||||
</button>
|
||||
@if (report && count > 0) {
|
||||
<button type="button" class="pg-btn" (click)="print()"><mat-icon>print</mat-icon> Print</button>
|
||||
}
|
||||
</div>
|
||||
</header>
|
||||
@@ -64,8 +60,7 @@
|
||||
@if (report.from !== report.to) {
|
||||
— {{ report.to | date: 'dd MMM yyyy' }}
|
||||
}
|
||||
· {{ report.count }} voucher{{ report.count === 1 ? '' : 's' }}
|
||||
· ₹{{ report.totalAmount | number: '1.2-2' }}
|
||||
· {{ count }} {{ noun }}{{ count === 1 ? '' : 's' }} · ₹{{ totalAmount | number: '1.2-2' }}
|
||||
@if (report.username) {
|
||||
· {{ report.username }}
|
||||
}
|
||||
@@ -74,10 +69,46 @@
|
||||
@if (!report.canSeeAllUsers) {
|
||||
<span class="vr-scope-note">Your own vouchers</span>
|
||||
}
|
||||
<!-- The total on a cash register means something narrower than the whole voucher book, so the
|
||||
screen says which one it is instead of leaving the figure ambiguous. -->
|
||||
@if (cashOnly) {
|
||||
<span class="vr-scope-note">Cash only</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (report.count === 0) {
|
||||
<div class="pg-empty">No vouchers were raised in this period.</div>
|
||||
<!-- A voucher with no CR/DR prefix belongs to neither report. Say so, so the two reports not
|
||||
adding up to the day's total is explained rather than left to be puzzled over. -->
|
||||
@if (unclassifiedCount > 0) {
|
||||
<div class="pg-alert vr-no-print">
|
||||
{{ unclassifiedCount }} voucher{{ unclassifiedCount === 1 ? '' : 's' }} in this period
|
||||
{{ unclassifiedCount === 1 ? 'carries' : 'carry' }} no CR or DR prefix, so
|
||||
{{ unclassifiedCount === 1 ? 'it appears' : 'they appear' }} on neither the Received nor the Payment report.
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Which part of the total has notes and coins counted behind it. Shown on the cash register
|
||||
as well as the full one: a voucher with no breakdown is still real money, so it stays in
|
||||
the total, and what the drawer can be checked against is stated instead of assumed. -->
|
||||
@if (uncountedCount > 0) {
|
||||
<div class="pg-alert vr-no-print">
|
||||
{{ uncountedCount }} of these {{ noun }}s {{ uncountedCount === 1 ? 'has' : 'have' }} no denomination breakdown
|
||||
recorded, totalling ₹{{ uncountedTotal | number: '1.2-2' }}. Some registers pay out without counting notes
|
||||
against the voucher, so these are still cash — ₹{{ countedTotal | number: '1.2-2' }} of the total has a
|
||||
breakdown behind it.
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Explains the blank Debit/Credit cells instead of leaving them looking like lost data. -->
|
||||
@if (missingSidesCount > 0) {
|
||||
<div class="pg-alert vr-no-print">
|
||||
{{ missingSidesCount }} of these {{ noun }}s {{ missingSidesCount === 1 ? 'has' : 'have' }} no debit or credit
|
||||
recorded. The sides are attached when a request is approved, so vouchers raised before that step was added keep
|
||||
their number and amount but show no accounts. Newly raised {{ noun }}s carry both sides.
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (count === 0) {
|
||||
<div class="pg-empty">No {{ noun }}s were raised in this period.</div>
|
||||
} @else {
|
||||
<div class="vr-summaries">
|
||||
<div class="pg-card">
|
||||
@@ -129,7 +160,7 @@
|
||||
</div>
|
||||
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">Vouchers</div>
|
||||
<div class="pos-section-label">{{ isReceived ? 'Receipts' : 'Payments' }}</div>
|
||||
<table class="pg-table vr-rows">
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -143,33 +174,39 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (row of report.vouchers; track row.voucherNo) {
|
||||
@for (row of rows; track row.voucherNo) {
|
||||
<tr>
|
||||
<td>
|
||||
<a class="vr-link" [routerLink]="['/cashier/voucher']" [queryParams]="{ voucherNo: row.voucherNo }">
|
||||
{{ row.voucherNo }}
|
||||
</a>
|
||||
{{ row.voucherNo }}
|
||||
@if (row.verificationStatus === 'EXCEPTION') {
|
||||
<mat-icon class="vr-flag" title="An exception was raised against this voucher">error_outline</mat-icon>
|
||||
<mat-icon class="vr-flag" title="An exception was raised against this voucher"
|
||||
>error_outline</mat-icon
|
||||
>
|
||||
}
|
||||
</td>
|
||||
<td>{{ row.voucherType || '—' }}</td>
|
||||
<td>
|
||||
<div class="vr-party">{{ row.debitParty || '—' }}</div>
|
||||
@if (row.debitAccount) {
|
||||
<div class="vr-acct">{{ row.debitAccount }}</div>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<div class="vr-party">{{ row.creditParty || '—' }}</div>
|
||||
@if (row.creditAccount) {
|
||||
<div class="vr-acct">{{ row.creditAccount }}</div>
|
||||
}
|
||||
<!-- A DD with charges, or a compound journal, has sides this row does not show. -->
|
||||
@if (row.extraLines > 0) {
|
||||
<div class="vr-more">+{{ row.extraLines }} more on voucher</div>
|
||||
}
|
||||
</td>
|
||||
<!-- A voucher raised before its sides were recorded has neither. Say that once,
|
||||
across both cells, rather than showing two dashes that read as "nowhere". -->
|
||||
@if (!hasSides(row)) {
|
||||
<td colspan="2" class="vr-nosides">Sides not recorded</td>
|
||||
} @else {
|
||||
<td>
|
||||
<div class="vr-party">{{ row.debitParty || '—' }}</div>
|
||||
@if (row.debitAccount) {
|
||||
<div class="vr-acct">{{ row.debitAccount }}</div>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<div class="vr-party">{{ row.creditParty || '—' }}</div>
|
||||
@if (row.creditAccount) {
|
||||
<div class="vr-acct">{{ row.creditAccount }}</div>
|
||||
}
|
||||
<!-- A DD with charges, or a compound journal, has sides this row does not show. -->
|
||||
@if (row.extraLines > 0) {
|
||||
<div class="vr-more">+{{ row.extraLines }} more on voucher</div>
|
||||
}
|
||||
</td>
|
||||
}
|
||||
<td class="num">{{ row.amount | number: '1.2-2' }}</td>
|
||||
<td>{{ row.username || '—' }}</td>
|
||||
<td>{{ row.createdAt | date: 'dd MMM, HH:mm' }}</td>
|
||||
|
||||
@@ -36,16 +36,6 @@
|
||||
}
|
||||
|
||||
.vr-rows {
|
||||
.vr-link {
|
||||
color: #1d4ed8;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.vr-flag {
|
||||
margin-left: 4px;
|
||||
font-size: 15px;
|
||||
@@ -65,6 +55,13 @@
|
||||
font-size: 11px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
// Spans the Debit and Credit cells for a voucher raised before its sides were recorded.
|
||||
.vr-nosides {
|
||||
font-size: 11px;
|
||||
font-style: italic;
|
||||
color: #9ca3af;
|
||||
}
|
||||
}
|
||||
|
||||
/* Printing a report should put the register on paper — the filters are how you reached it. */
|
||||
@@ -72,9 +69,4 @@
|
||||
.vr-no-print {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.vr-link {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,11 +9,19 @@
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { catchError, of } from 'rxjs';
|
||||
import { CashierService } from '../cashier.service';
|
||||
|
||||
/**
|
||||
* Which side of the register a screen shows.
|
||||
*
|
||||
* 'CR' is the Received report (money in), 'DR' the Payment report (money out). Set from the route,
|
||||
* so the two screens are the same component pointed at opposite sides.
|
||||
*/
|
||||
export type VoucherSide = 'CR' | 'DR';
|
||||
|
||||
/** One voucher as the register shows it: the number, the two sides, and who raised it. */
|
||||
interface VoucherRow {
|
||||
voucherNo: string;
|
||||
@@ -30,6 +38,13 @@ interface VoucherRow {
|
||||
creditAccount: string | null;
|
||||
/** Sides beyond the one debit and one credit shown — a DD's charges, a compound journal. */
|
||||
extraLines: number;
|
||||
/**
|
||||
* Whether a denomination breakdown was recorded against this voucher.
|
||||
*
|
||||
* True proves cash was counted. False does not prove the opposite: several registers pay real
|
||||
* money out without recording a breakdown, so this marks a row as unconfirmed, not as non-cash.
|
||||
*/
|
||||
denominationCounted: boolean;
|
||||
}
|
||||
|
||||
interface VoucherReport {
|
||||
@@ -38,6 +53,8 @@ interface VoucherReport {
|
||||
username: string | null;
|
||||
/** Set by the server from the caller's roles; a cashier is never offered the user filter. */
|
||||
canSeeAllUsers: boolean;
|
||||
/** Whether the server restricted the register to cash movements. */
|
||||
cashOnly: boolean;
|
||||
vouchers: VoucherRow[];
|
||||
count: number;
|
||||
totalAmount: number;
|
||||
@@ -60,12 +77,12 @@ interface VoucherReport {
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
RouterLink,
|
||||
MatIconModule
|
||||
]
|
||||
})
|
||||
export class VoucherReportComponent implements OnInit {
|
||||
private service = inject(CashierService);
|
||||
private route = inject(ActivatedRoute);
|
||||
|
||||
/** Day is the working default; a month is what you ask for at a close. */
|
||||
period: 'DAY' | 'MONTH' = 'DAY';
|
||||
@@ -74,21 +91,67 @@ export class VoucherReportComponent implements OnInit {
|
||||
usernameFilter = '';
|
||||
typeFilter = '';
|
||||
|
||||
/** Which side this screen is: Received (CR) or Payment (DR). Comes from the route's data. */
|
||||
side: VoucherSide = 'CR';
|
||||
|
||||
/**
|
||||
* Whether this screen is the cash register rather than the whole voucher register.
|
||||
*
|
||||
* A cashier handles notes and coins, so entries that cannot be cash — an interest posting, a
|
||||
* clearing settlement, a cheque book fee — are left out, and everything that could have crossed
|
||||
* the counter stays in. The filter errs towards keeping a row: a book entry on the register can
|
||||
* be seen and recognised, whereas cash that never appears is invisible until it turns up as an
|
||||
* unexplained difference at the end of the day.
|
||||
*/
|
||||
cashOnly = false;
|
||||
|
||||
report: VoucherReport | null = null;
|
||||
loading = false;
|
||||
error: string | null = null;
|
||||
|
||||
ngOnInit(): void {
|
||||
this.side = (this.route.snapshot.data['side'] as VoucherSide) ?? 'CR';
|
||||
this.cashOnly = this.route.snapshot.data['cashOnly'] === true;
|
||||
this.load();
|
||||
}
|
||||
|
||||
get isReceived(): boolean {
|
||||
return this.side === 'CR';
|
||||
}
|
||||
|
||||
get title(): string {
|
||||
return this.isReceived ? 'Received Report' : 'Payment Report';
|
||||
}
|
||||
|
||||
get subtitle(): string {
|
||||
if (this.cashOnly) {
|
||||
return this.isReceived
|
||||
? 'Cash taken in over a day or a month, and who took it — book entries left out.'
|
||||
: 'Cash paid out over a day or a month, and who paid it — book entries left out.';
|
||||
}
|
||||
return this.isReceived
|
||||
? 'Every receipt voucher raised over a day or a month — money taken in, and who took it.'
|
||||
: 'Every payment voucher raised over a day or a month — money paid out, and who paid it.';
|
||||
}
|
||||
|
||||
/** The word for one row on this side, for counts and the empty state. */
|
||||
get noun(): string {
|
||||
return this.isReceived ? 'receipt' : 'payment';
|
||||
}
|
||||
|
||||
load(): void {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
const { from, to } = this.range();
|
||||
this.service
|
||||
.voucherReport(from, to, this.usernameFilter.trim() || undefined, this.typeFilter.trim() || undefined)
|
||||
.voucherReport(
|
||||
from,
|
||||
to,
|
||||
this.usernameFilter.trim() || undefined,
|
||||
this.typeFilter.trim() || undefined,
|
||||
this.cashOnly
|
||||
)
|
||||
.pipe(
|
||||
catchError((err: any) => {
|
||||
this.error =
|
||||
@@ -125,9 +188,95 @@ export class VoucherReportComponent implements OnInit {
|
||||
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The rows belonging to this screen's side.
|
||||
*
|
||||
* The side is read off the first segment of the voucher number — `CR/001/2608/0003` — which the
|
||||
* server stamps when it raises the voucher: a deposit credits the member and gets CR, everything
|
||||
* that takes money out gets DR. That prefix is the authoritative record of the side, so it is
|
||||
* matched directly rather than re-deriving the side from the voucher type here — a new type added
|
||||
* on the server then lands on the correct report with no change needed on this screen.
|
||||
*/
|
||||
get rows(): VoucherRow[] {
|
||||
const all = this.report?.vouchers ?? [];
|
||||
return all.filter((row) => this.sideOf(row) === this.side);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a voucher has its two sides recorded against it.
|
||||
*
|
||||
* The registers raise a voucher when the request is made and only attach its sides once it is
|
||||
* approved and posted, so a voucher raised before that step existed carries a number but no
|
||||
* lines. Those rows are labelled rather than shown as an empty dash, which reads like the money
|
||||
* went nowhere.
|
||||
*/
|
||||
hasSides(row: VoucherRow): boolean {
|
||||
return !!(row.debitParty || row.creditParty);
|
||||
}
|
||||
|
||||
/** How many rows on this side are missing their debit/credit lines. */
|
||||
get missingSidesCount(): number {
|
||||
return this.rows.filter((row) => !this.hasSides(row)).length;
|
||||
}
|
||||
|
||||
/** A voucher's side, from its number's prefix. Null when the number is missing or unrecognised. */
|
||||
private sideOf(row: VoucherRow): VoucherSide | null {
|
||||
const no = (row.voucherNo ?? '').toUpperCase();
|
||||
if (no.startsWith('CR/')) {
|
||||
return 'CR';
|
||||
}
|
||||
if (no.startsWith('DR/')) {
|
||||
return 'DR';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** How many rows on this side — not the server's count, which spans both. */
|
||||
get count(): number {
|
||||
return this.rows.length;
|
||||
}
|
||||
|
||||
/** Total for this side only. */
|
||||
get totalAmount(): number {
|
||||
return this.rows.reduce((sum, row) => sum + (row.amount ?? 0), 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* The part of this side's total whose notes and coins were actually counted.
|
||||
*
|
||||
* Always at most `totalAmount`: a voucher can be real cash and still be missing its breakdown,
|
||||
* because the register that raised it records none. So this is the confirmed part of the cash,
|
||||
* not the whole of it, and the gap is reported rather than treated as book entries.
|
||||
*/
|
||||
get countedTotal(): number {
|
||||
return this.rows.filter((row) => row.denominationCounted).reduce((sum, row) => sum + (row.amount ?? 0), 0);
|
||||
}
|
||||
|
||||
/** Rows carrying no denomination breakdown — unconfirmed against the drawer, not non-cash. */
|
||||
get uncountedCount(): number {
|
||||
return this.rows.filter((row) => !row.denominationCounted).length;
|
||||
}
|
||||
|
||||
/** How much of this side's total has no note-and-coin breakdown behind it. */
|
||||
get uncountedTotal(): number {
|
||||
return this.totalAmount - this.countedTotal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vouchers whose number carries neither prefix, so they appear on neither report.
|
||||
*
|
||||
* Counted and shown rather than silently dropped: a voucher that vanishes from both sides of the
|
||||
* register is exactly the kind of thing a cashier must be told about, not left to discover when
|
||||
* the two reports do not add up to the day.
|
||||
*/
|
||||
get unclassifiedCount(): number {
|
||||
const all = this.report?.vouchers ?? [];
|
||||
return all.filter((row) => this.sideOf(row) === null).length;
|
||||
}
|
||||
|
||||
/** Totals per user, for the summary a manager reads before the rows. */
|
||||
get byUser(): { username: string; count: number; total: number }[] {
|
||||
const rows = this.report?.vouchers ?? [];
|
||||
const rows = this.rows;
|
||||
const tally = new Map<string, { username: string; count: number; total: number }>();
|
||||
for (const row of rows) {
|
||||
const key = row.username ?? '—';
|
||||
@@ -141,7 +290,7 @@ export class VoucherReportComponent implements OnInit {
|
||||
|
||||
/** Totals per voucher type — deposits against withdrawals against journals. */
|
||||
get byType(): { type: string; count: number; total: number }[] {
|
||||
const rows = this.report?.vouchers ?? [];
|
||||
const rows = this.rows;
|
||||
const tally = new Map<string, { type: string; count: number; total: number }>();
|
||||
for (const row of rows) {
|
||||
const key = row.voucherType ?? '—';
|
||||
|
||||
@@ -1,144 +0,0 @@
|
||||
<!--
|
||||
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 vv-no-print">
|
||||
<div>
|
||||
<div class="pg-eyebrow">CASHIER · VOUCHER</div>
|
||||
<h1 class="pg-title">Voucher</h1>
|
||||
<p class="pg-sub">
|
||||
Look up a voucher by its number to see the entry behind it — which accounts were debited, which were credited,
|
||||
and for how much.
|
||||
</p>
|
||||
</div>
|
||||
<div class="pg-toolbar">
|
||||
<label class="pg-field">
|
||||
<span>Voucher No</span>
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="voucherNo"
|
||||
(keyup.enter)="search()"
|
||||
placeholder="CR/2708/0001"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</label>
|
||||
<button type="button" class="pg-btn pg-btn--primary" (click)="search()" [disabled]="loading">
|
||||
<mat-icon>search</mat-icon> Find
|
||||
</button>
|
||||
@if (voucher) {
|
||||
<button type="button" class="pg-btn" (click)="print()">
|
||||
<mat-icon>print</mat-icon> Print
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@if (error) {
|
||||
<div class="pg-alert pg-alert--error vv-no-print">{{ error }}</div>
|
||||
}
|
||||
|
||||
@if (loading) {
|
||||
<div class="pg-empty vv-no-print">Loading…</div>
|
||||
}
|
||||
|
||||
@if (voucher && !loading) {
|
||||
<div class="pg-card vv-slip">
|
||||
<div class="vv-slip-head">
|
||||
<div>
|
||||
<div class="vv-no">{{ voucher.voucherNo }}</div>
|
||||
<div class="vv-meta">
|
||||
{{ voucher.voucherType || '—' }}
|
||||
@if (voucher.branchName) {
|
||||
· {{ voucher.branchName }}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="vv-slip-head-right">
|
||||
<div class="vv-amount">₹{{ voucher.amount | number: '1.2-2' }}</div>
|
||||
<div class="vv-meta">
|
||||
@if (voucher.createdAt) {
|
||||
{{ voucher.createdAt | date: 'dd MMM yyyy, HH:mm' }}
|
||||
}
|
||||
@if (voucher.createdBy) {
|
||||
· {{ voucher.createdBy }}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (voucher.verificationStatus) {
|
||||
<div class="vv-status" [class.vv-status--exception]="voucher.verificationStatus === 'EXCEPTION'">
|
||||
<mat-icon>{{ voucher.verificationStatus === 'EXCEPTION' ? 'error_outline' : 'verified' }}</mat-icon>
|
||||
{{ voucher.verificationStatus === 'EXCEPTION' ? 'Exception raised against this voucher' : 'Verified against the slip' }}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- A voucher raised before its sides were recorded: the number is valid, the posting behind it
|
||||
simply was never captured, so it is shown as an older record rather than as an error. -->
|
||||
@if (hasNoLines) {
|
||||
<div class="pg-empty">
|
||||
No debit or credit detail was recorded for this voucher. It was raised before the entry sides were kept.
|
||||
</div>
|
||||
} @else {
|
||||
<table class="pg-table vv-lines">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Particulars</th>
|
||||
<th>Account</th>
|
||||
<th class="num">Debit</th>
|
||||
<th class="num">Credit</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (line of debitLines; track line.lineNo) {
|
||||
<tr>
|
||||
<td>
|
||||
<div class="vv-party">{{ line.partyName || '—' }}</div>
|
||||
@if (line.description) {
|
||||
<div class="vv-desc">{{ line.description }}</div>
|
||||
}
|
||||
</td>
|
||||
<td>{{ line.accountNo || '—' }}</td>
|
||||
<td class="num">{{ line.amount | number: '1.2-2' }}</td>
|
||||
<td class="num">—</td>
|
||||
</tr>
|
||||
}
|
||||
@for (line of creditLines; track line.lineNo) {
|
||||
<tr>
|
||||
<td class="vv-indent">
|
||||
<div class="vv-party">{{ line.partyName || '—' }}</div>
|
||||
@if (line.description) {
|
||||
<div class="vv-desc">{{ line.description }}</div>
|
||||
}
|
||||
</td>
|
||||
<td>{{ line.accountNo || '—' }}</td>
|
||||
<td class="num">—</td>
|
||||
<td class="num">{{ line.amount | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td colspan="2">Total</td>
|
||||
<td class="num">{{ voucher.totalDebit | number: '1.2-2' }}</td>
|
||||
<td class="num">{{ voucher.totalCredit | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
|
||||
<!-- Stated rather than assumed: a voucher whose sides were written before the balance check
|
||||
existed, or repaired by hand, should read as unbalanced rather than pass as a valid slip. -->
|
||||
@if (!voucher.balanced) {
|
||||
<div class="pg-alert pg-alert--error">
|
||||
This voucher does not balance — debits and credits differ. Refer it to accounts before relying on it.
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -1,109 +0,0 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
/* Page chrome and the table come from cashier-shared.scss. Only the slip itself is defined here. */
|
||||
|
||||
.vv-slip {
|
||||
max-width: 860px;
|
||||
}
|
||||
|
||||
.vv-slip-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding-bottom: 14px;
|
||||
margin-bottom: 14px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.vv-slip-head-right {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.vv-no {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.4px;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.vv-amount {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.vv-meta {
|
||||
margin-top: 2px;
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.vv-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
margin-bottom: 14px;
|
||||
background: #ecfdf5;
|
||||
border: 1px solid #a7f3d0;
|
||||
border-radius: 8px;
|
||||
color: #065f46;
|
||||
font-size: 12px;
|
||||
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
/* An exception is a warning about this voucher, not a state of it — it has to stop the eye. */
|
||||
&.vv-status--exception {
|
||||
background: #fef2f2;
|
||||
border-color: #fecaca;
|
||||
color: #991b1b;
|
||||
}
|
||||
}
|
||||
|
||||
.vv-lines {
|
||||
/* Credits sit under their debits the way they do on a written slip, so the indent carries the
|
||||
relationship that the DR/CR columns alone would leave to the reader to work out. */
|
||||
.vv-indent {
|
||||
padding-left: 28px;
|
||||
}
|
||||
|
||||
.vv-party {
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.vv-desc {
|
||||
margin-top: 2px;
|
||||
font-size: 11px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
tfoot td {
|
||||
font-weight: 600;
|
||||
border-top: 2px solid #e5e7eb;
|
||||
}
|
||||
}
|
||||
|
||||
/* Printing a voucher should put the slip on paper and nothing else — the search box and buttons are
|
||||
how you reached it, not part of it. */
|
||||
@media print {
|
||||
.vv-no-print {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.vv-slip {
|
||||
max-width: none;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
/**
|
||||
* 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, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { catchError, of } from 'rxjs';
|
||||
import { CashierService } from '../cashier.service';
|
||||
|
||||
/** One side of a voucher: a debit or a credit against a single account. */
|
||||
interface VoucherLine {
|
||||
lineNo: number;
|
||||
entryType: 'DR' | 'CR';
|
||||
glAccountId: number | null;
|
||||
savingsAccountId: number | null;
|
||||
loanAccountId: number | null;
|
||||
partyName: string | null;
|
||||
accountNo: string | null;
|
||||
amount: number;
|
||||
description: string | null;
|
||||
}
|
||||
|
||||
interface Voucher {
|
||||
voucherNo: string;
|
||||
voucherType: string | null;
|
||||
referenceType: string | null;
|
||||
referenceId: number | null;
|
||||
amount: number | null;
|
||||
branchOfficeId: number | null;
|
||||
branchName: string | null;
|
||||
createdBy: string | null;
|
||||
createdAt: string | null;
|
||||
verificationStatus: string | null;
|
||||
lines: VoucherLine[];
|
||||
totalDebit: number;
|
||||
totalCredit: number;
|
||||
balanced: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* One voucher, as a slip: which accounts were debited, which credited, and for how much.
|
||||
*
|
||||
* Looked up by voucher number rather than listed, because that is how a voucher is reached — from a
|
||||
* paper slip in someone's hand, or from a number quoted on a statement.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-voucher-view',
|
||||
templateUrl: './voucher-view.component.html',
|
||||
styleUrls: [
|
||||
'../cashier-shared.scss',
|
||||
'./voucher-view.component.scss'
|
||||
],
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
MatIconModule
|
||||
]
|
||||
})
|
||||
export class VoucherViewComponent {
|
||||
private service = inject(CashierService);
|
||||
private route = inject(ActivatedRoute);
|
||||
private router = inject(Router);
|
||||
|
||||
voucherNo = '';
|
||||
voucher: Voucher | null = null;
|
||||
loading = false;
|
||||
/** Shown in place of the slip — a bad number and a server error read differently to the user. */
|
||||
error: string | null = null;
|
||||
|
||||
constructor() {
|
||||
// A voucher number in the URL means someone arrived from a link or a statement rather than by
|
||||
// typing, so the slip is fetched straight away instead of showing them an empty search box.
|
||||
const fromUrl = this.route.snapshot.queryParamMap.get('voucherNo');
|
||||
if (fromUrl) {
|
||||
this.voucherNo = fromUrl;
|
||||
this.search();
|
||||
}
|
||||
}
|
||||
|
||||
search(): void {
|
||||
const trimmed = (this.voucherNo || '').trim();
|
||||
if (!trimmed) {
|
||||
this.error = 'Enter a voucher number.';
|
||||
this.voucher = null;
|
||||
return;
|
||||
}
|
||||
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
// Kept in the URL so the slip can be linked to and survives a refresh.
|
||||
this.router.navigate([], {
|
||||
relativeTo: this.route,
|
||||
queryParams: { voucherNo: trimmed },
|
||||
replaceUrl: true
|
||||
});
|
||||
|
||||
this.service
|
||||
.voucherDetail(trimmed)
|
||||
.pipe(
|
||||
catchError((err: any) => {
|
||||
this.error =
|
||||
err?.error?.errors?.[0]?.defaultUserMessage ??
|
||||
err?.error?.developerMessage ??
|
||||
`Voucher ${trimmed} could not be loaded.`;
|
||||
return of(null);
|
||||
})
|
||||
)
|
||||
.subscribe((res: Voucher | null) => {
|
||||
this.loading = false;
|
||||
this.voucher = res;
|
||||
});
|
||||
}
|
||||
|
||||
/** Debits first, then credits — the order a slip is read in. */
|
||||
get debitLines(): VoucherLine[] {
|
||||
return (this.voucher?.lines ?? []).filter((l) => l.entryType === 'DR');
|
||||
}
|
||||
|
||||
get creditLines(): VoucherLine[] {
|
||||
return (this.voucher?.lines ?? []).filter((l) => l.entryType === 'CR');
|
||||
}
|
||||
|
||||
/**
|
||||
* A voucher raised before its sides were recorded. The voucher is real and its number valid — only
|
||||
* the posting behind it was never captured — so this reads as an older record, not a broken one.
|
||||
*/
|
||||
get hasNoLines(): boolean {
|
||||
return !!this.voucher && this.voucher.lines.length === 0;
|
||||
}
|
||||
|
||||
print(): void {
|
||||
window.print();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user