feat(cashier): add end-of-day report, my work, and cashier updates
This commit is contained in:
@@ -117,6 +117,59 @@ const routes: Routes = [
|
||||
import('../clerk/photo-signature/photo-signature.component').then((m) => m.PhotoSignatureComponent),
|
||||
data: { title: 'Photo & Signature', breadcrumb: 'Photo & Signature' }
|
||||
},
|
||||
// The same register the cashier screens use, without their cash-only filter: a clerk raises
|
||||
// book entries — a closure settled to savings, a share redemption, an interest posting the
|
||||
// system made on their approval — and cash-only would hide exactly those.
|
||||
{
|
||||
path: 'received-report',
|
||||
component: VoucherReportComponent,
|
||||
data: {
|
||||
title: 'Received Vouchers',
|
||||
breadcrumb: 'Received Vouchers',
|
||||
side: 'CR',
|
||||
cashOnly: false,
|
||||
cashiersOnly: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'payment-report',
|
||||
component: VoucherReportComponent,
|
||||
data: {
|
||||
title: 'Payment Vouchers',
|
||||
breadcrumb: 'Payment Vouchers',
|
||||
side: 'DR',
|
||||
cashOnly: false,
|
||||
cashiersOnly: true
|
||||
}
|
||||
},
|
||||
// Both sides in one list, scoped to whoever is signed in — the "what did I do today"
|
||||
// question the two registers above cannot answer, because an approval raises no voucher.
|
||||
{
|
||||
path: 'my-work',
|
||||
loadComponent: () => import('../cashier/my-work/my-work.component').then((m) => m.MyWorkComponent),
|
||||
data: { title: 'My Vouchers', breadcrumb: 'My Vouchers' }
|
||||
},
|
||||
{
|
||||
path: 'end-of-day',
|
||||
loadComponent: () =>
|
||||
import('../cashier/end-of-day-report/end-of-day-report.component').then((m) => m.EndOfDayReportComponent),
|
||||
data: { title: 'End of the Day Report', breadcrumb: 'End of the Day Report' }
|
||||
},
|
||||
// The system's own postings — interest, charges, escheats, journals. Both sides in one
|
||||
// list: a night's run credits and debits, and splitting it across two screens would hide
|
||||
// half of it from whichever one you opened.
|
||||
{
|
||||
path: 'system-vouchers',
|
||||
component: VoucherReportComponent,
|
||||
data: {
|
||||
title: 'System Vouchers',
|
||||
breadcrumb: 'System Vouchers',
|
||||
side: 'ALL',
|
||||
cashOnly: false,
|
||||
cashiersOnly: false,
|
||||
systemOnly: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'standing-instructions',
|
||||
loadComponent: () =>
|
||||
@@ -172,6 +225,23 @@ const routes: Routes = [
|
||||
path: 'vault-position',
|
||||
component: VaultPositionComponent,
|
||||
data: { title: 'Vault Position', breadcrumb: 'Vault Position' }
|
||||
},
|
||||
{
|
||||
// The same day book the clerk and cashier read, reached from where a principal officer
|
||||
// signing the day off actually looks. It carries the Officer Verification Sheet — each
|
||||
// person's own credit, debit and net — which is the branch-level reading of the day, not
|
||||
// a counter one.
|
||||
path: 'day-book',
|
||||
component: DayBookComponent,
|
||||
data: { title: 'Day Book & Verification', breadcrumb: 'Day Book' }
|
||||
},
|
||||
// The one sheet the day is signed off on: the transaction summary, the drawer counted
|
||||
// against the books, and the notes that say why it cannot be closed yet.
|
||||
{
|
||||
path: 'end-of-day',
|
||||
loadComponent: () =>
|
||||
import('./end-of-day-report/end-of-day-report.component').then((m) => m.EndOfDayReportComponent),
|
||||
data: { title: 'End of the Day Report', breadcrumb: 'End of the Day Report' }
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -235,18 +305,41 @@ const routes: Routes = [
|
||||
{
|
||||
path: 'received-report',
|
||||
component: VoucherReportComponent,
|
||||
data: { title: 'Received Report', breadcrumb: 'Received Report', side: 'CR', cashOnly: true }
|
||||
data: {
|
||||
title: 'Received Report',
|
||||
breadcrumb: 'Received Report',
|
||||
side: 'CR',
|
||||
cashOnly: true,
|
||||
cashiersOnly: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'payment-report',
|
||||
component: VoucherReportComponent,
|
||||
data: { title: 'Payment Report', breadcrumb: 'Payment Report', side: 'DR', cashOnly: true }
|
||||
data: {
|
||||
title: 'Payment Report',
|
||||
breadcrumb: 'Payment Report',
|
||||
side: 'DR',
|
||||
cashOnly: true,
|
||||
cashiersOnly: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'my-work',
|
||||
loadComponent: () => import('./my-work/my-work.component').then((m) => m.MyWorkComponent),
|
||||
data: { title: 'My Vouchers', breadcrumb: 'My Vouchers' }
|
||||
},
|
||||
{
|
||||
path: 'closing-report',
|
||||
component: ClosingCashReportComponent,
|
||||
data: { title: 'Closing Cash Report', breadcrumb: 'Closing Cash Report' }
|
||||
},
|
||||
{
|
||||
path: 'end-of-day',
|
||||
loadComponent: () =>
|
||||
import('./end-of-day-report/end-of-day-report.component').then((m) => m.EndOfDayReportComponent),
|
||||
data: { title: 'End of the Day Report', breadcrumb: 'End of the Day Report' }
|
||||
},
|
||||
{
|
||||
path: 'excess-report',
|
||||
component: ExcessTransactionReportComponent,
|
||||
|
||||
@@ -714,3 +714,15 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Names a figure strip before the reader meets it. A run of tiles ending in a total reads as
|
||||
arithmetic to somebody who already knows the screen and as five unrelated numbers to somebody
|
||||
who does not. */
|
||||
.closing-lede {
|
||||
margin: 0 0 -4px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
@@ -138,7 +138,9 @@ export class CashierService {
|
||||
to: string,
|
||||
username?: string,
|
||||
voucherType?: string,
|
||||
cashOnly?: boolean
|
||||
cashOnly?: boolean,
|
||||
cashiersOnly?: boolean,
|
||||
systemOnly?: boolean
|
||||
): Observable<any> {
|
||||
let params = new HttpParams().set('from', from).set('to', to);
|
||||
if (username) {
|
||||
@@ -152,9 +154,30 @@ export class CashierService {
|
||||
if (cashOnly) {
|
||||
params = params.set('cashOnly', 'true');
|
||||
}
|
||||
// Opt-in the same way: the counter registers ask for the drawer-holders only, the back-office
|
||||
// ones keep seeing whoever raised a voucher.
|
||||
if (cashiersOnly) {
|
||||
params = params.set('cashiersOnly', 'true');
|
||||
}
|
||||
// The postings nobody filed a request for: interest a nightly run credited, a charge taken from
|
||||
// a balance, an escheat, a journal keyed to the ledger. Nowhere else shows them — My Vouchers is
|
||||
// the reader's own work and a scheduled job is nobody's.
|
||||
if (systemOnly) {
|
||||
params = params.set('systemOnly', 'true');
|
||||
}
|
||||
return this.http.get('/vouchers/report', { params });
|
||||
}
|
||||
|
||||
/**
|
||||
* The signed-in user's own day: every voucher they raised and every one they verified, receipts
|
||||
* and payments in one list. Always scoped to the caller by the server — there is no username
|
||||
* parameter, because "my work" for somebody else is the register's job, not this screen's.
|
||||
*/
|
||||
myVoucherWork(from: string, to: string): Observable<any> {
|
||||
const params = new HttpParams().set('from', from).set('to', to);
|
||||
return this.http.get('/vouchers/my-work', { params });
|
||||
}
|
||||
|
||||
/**
|
||||
* Account register for one date: what opened and closed that day, each account's balance as it
|
||||
* stood then, and what it holds now.
|
||||
|
||||
@@ -9,9 +9,8 @@
|
||||
<div class="pg">
|
||||
<header class="pg-head">
|
||||
<div>
|
||||
<div class="pg-eyebrow">CASHIER · REPORT</div>
|
||||
<h1 class="pg-title">Closing Cash Report</h1>
|
||||
<p class="pg-sub">Denomination-wise deposit, withdrawal and net cash position for the selected day.</p>
|
||||
<p class="pg-sub">How much cash you started with, what came in and went out, and how much you must hand back.</p>
|
||||
</div>
|
||||
<div class="pg-toolbar">
|
||||
<label class="pg-field">
|
||||
@@ -27,79 +26,71 @@
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 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">
|
||||
<!-- The day's own cash, leg by leg, ending in what should physically be in the drawer.
|
||||
A cashier carries no balance: they draw from the vault in the morning and hand back at close,
|
||||
so this is the figure a physical count is checked against.
|
||||
|
||||
This used to sit under a second strip reading Deposits − Withdrawals = Net Counter Movement.
|
||||
That strip stated the same two figures again and then a net the cashier has no use for: it
|
||||
excludes the vault legs, so on a day the vault gave ₹600 and customer business cancelled out
|
||||
it read ₹0 while the drawer held ₹600. Two panels, four repeated figures, and the one that
|
||||
came first was the one to ignore. -->
|
||||
@if (!showGl && scopedToCashier && hasTodayLegs) {
|
||||
<!-- Says what the strip is before the reader meets five figures and a running sum. Somebody
|
||||
opening this screen for the first time has no way to know that the last tile is the one
|
||||
that matters. -->
|
||||
<p class="closing-lede">Your cash for the day, step by step</p>
|
||||
<div class="closing-summary closing-summary--balance">
|
||||
<div class="closing-summary__item closing-summary__item--in">
|
||||
<span class="closing-summary__label">Total Deposits (IN)</span>
|
||||
<span class="closing-summary__label">Cash you got this morning</span>
|
||||
<span class="closing-summary__value">₹{{ todayVaultIn | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
<div class="closing-summary__sep">+</div>
|
||||
<div class="closing-summary__item closing-summary__item--in">
|
||||
<span class="closing-summary__label">Cash customers gave you</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{{
|
||||
₹{{ ddCashTotal | number: '1.2-2' }} of this was for {{ ddCashCount }} draft{{
|
||||
ddCashCount === 1 ? '' : 's'
|
||||
}})
|
||||
}}, notes not listed
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
<div class="closing-summary__sep">−</div>
|
||||
<div class="closing-summary__item closing-summary__item--out">
|
||||
<span class="closing-summary__label">Total Withdrawals (OUT)</span>
|
||||
<span class="closing-summary__label">Cash you gave customers</span>
|
||||
<span class="closing-summary__value">₹{{ withdrawalTotal | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
<div class="closing-summary__sep">=</div>
|
||||
<div
|
||||
class="closing-summary__item"
|
||||
[class.closing-summary__item--net-pos]="netTotal >= 0"
|
||||
[class.closing-summary__item--net-neg]="netTotal < 0"
|
||||
>
|
||||
<!-- 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 class="closing-summary__sep">−</div>
|
||||
<div class="closing-summary__item closing-summary__item--out">
|
||||
<span class="closing-summary__label">Cash already returned</span>
|
||||
<span class="closing-summary__value">₹{{ todaySettledOut | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
<div class="closing-summary__sep">=</div>
|
||||
<!-- The figure the day ends on. Named for what the cashier does with it — hand this back to
|
||||
the vault — rather than "closing balance", which reads as something they keep. -->
|
||||
<div
|
||||
class="closing-summary__item"
|
||||
[class.closing-summary__item--net-pos]="(closingBalance ?? 0) >= 0"
|
||||
[class.closing-summary__item--net-neg]="(closingBalance ?? 0) < 0"
|
||||
[class.closing-summary__item--net-pos]="expectedInDrawer >= 0"
|
||||
[class.closing-summary__item--net-neg]="expectedInDrawer < 0"
|
||||
>
|
||||
<span class="closing-summary__label">Closing Balance</span>
|
||||
<span class="closing-summary__value">₹{{ closingBalance | number: '1.2-2' }}</span>
|
||||
<span class="closing-summary__label">Cash left with you now</span>
|
||||
<span class="closing-summary__value">₹{{ expectedInDrawer | 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">
|
||||
Count your cash now — it should come to ₹{{ expectedInDrawer | number: '1.2-2' }}. Hand that amount back and
|
||||
your day is done.
|
||||
</p>
|
||||
<!-- Cashier-to-cashier hand-overs are left out of the row above to keep it to the four legs a
|
||||
teller sees, and stated here when they happened rather than silently folded in. -->
|
||||
@if ((todayInterCashierIn ?? 0) > 0 || (todayInterCashierOut ?? 0) > 0) {
|
||||
<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.
|
||||
This already counts cash swapped with another cashier — ₹{{ todayInterCashierIn | number: '1.2-2' }} taken
|
||||
from them, ₹{{ todayInterCashierOut | number: '1.2-2' }} given to them.
|
||||
</p>
|
||||
}
|
||||
}
|
||||
@@ -111,13 +102,16 @@
|
||||
<table class="pg-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Denomination</th>
|
||||
<th class="num col-in">Deposit Pcs</th>
|
||||
<th class="num col-in">Deposit Amt</th>
|
||||
<th class="num col-out">Withdrawal Pcs</th>
|
||||
<th class="num col-out">Withdrawal Amt</th>
|
||||
<th class="num col-net">Net Pcs</th>
|
||||
<th class="num col-net">Net Amount</th>
|
||||
<th>Note</th>
|
||||
<!-- Counted in pieces, because that is what a teller does with notes. Nine columns of
|
||||
pieces-and-amount for every leg was a reconciliation nobody could read at a
|
||||
counter; these five are the sentence the day actually makes: this many came in
|
||||
from the vault, this many over the counter, this many went out, this many are
|
||||
left, and that is worth this much. -->
|
||||
<th class="num col-in">Came in</th>
|
||||
<th class="num col-out">Went out</th>
|
||||
<th class="num col-net">Left with you</th>
|
||||
<th class="num col-net">Worth</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -126,15 +120,16 @@
|
||||
<td>
|
||||
<span class="pg-chip">₹{{ r.denominationValue }}</span>
|
||||
</td>
|
||||
<td class="num col-in">{{ r.depositCount }}</td>
|
||||
<td class="num col-in">₹{{ r.depositAmount | number: '1.2-2' }}</td>
|
||||
<td class="num col-out">{{ r.withdrawalCount }}</td>
|
||||
<td class="num col-out">₹{{ r.withdrawalAmount | number: '1.2-2' }}</td>
|
||||
<!-- Vault and counter added together: to a teller both are notes arriving, and
|
||||
splitting them made the reader do the sum. A dash rather than a zero where
|
||||
nothing happened, so the eye passes over it. -->
|
||||
<td class="num col-in">{{ r.vaultInCount + r.depositCount || '—' }}</td>
|
||||
<td class="num col-out">{{ r.withdrawalCount + r.settledOutCount || '—' }}</td>
|
||||
<td
|
||||
class="num col-net"
|
||||
[style.color]="r.netCount > 0 ? '#16a34a' : r.netCount < 0 ? '#dc2626' : '#94a3b8'"
|
||||
>
|
||||
{{ r.netCount > 0 ? '+' : '' }}{{ r.netCount }}
|
||||
{{ r.netCount }}
|
||||
</td>
|
||||
<td
|
||||
class="num col-net"
|
||||
@@ -153,15 +148,13 @@
|
||||
@if (ddCashTotal > 0) {
|
||||
<tr>
|
||||
<td>
|
||||
<span class="pg-chip">Not itemised</span>
|
||||
<span class="pg-chip">Notes not listed</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>
|
||||
<td class="num col-net" style="color: #16a34a">₹{{ ddCashTotal | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
@@ -170,13 +163,11 @@
|
||||
<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-in">{{ noteVaultInCount + noteDepositCount || '—' }}</td>
|
||||
<td class="num col-out">{{ noteWithdrawalCount + noteSettledOutCount || '—' }}</td>
|
||||
<td class="num col-net">{{ noteNetCount }}</td>
|
||||
<td class="num col-net">
|
||||
{{ noteNetAmount >= 0 ? '+' : '−' }}₹{{
|
||||
{{ noteNetAmount >= 0 ? '' : '−' }}₹{{
|
||||
(noteNetAmount < 0 ? -noteNetAmount : noteNetAmount) | number: '1.2-2'
|
||||
}}
|
||||
</td>
|
||||
@@ -190,13 +181,11 @@
|
||||
<table class="pg-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Denomination</th>
|
||||
<th class="num col-in">Deposit Pcs</th>
|
||||
<th class="num col-in">Deposit Amt</th>
|
||||
<th class="num col-out">Withdrawal Pcs</th>
|
||||
<th class="num col-out">Withdrawal Amt</th>
|
||||
<th class="num col-net">Net Pcs</th>
|
||||
<th class="num col-net">Net Amount</th>
|
||||
<th>Coin</th>
|
||||
<th class="num col-in">Came in</th>
|
||||
<th class="num col-out">Went out</th>
|
||||
<th class="num col-net">Left with you</th>
|
||||
<th class="num col-net">Worth</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -205,15 +194,16 @@
|
||||
<td>
|
||||
<span class="pg-chip pg-chip--coin">₹{{ r.denominationValue }}</span>
|
||||
</td>
|
||||
<td class="num col-in">{{ r.depositCount }}</td>
|
||||
<td class="num col-in">₹{{ r.depositAmount | number: '1.2-2' }}</td>
|
||||
<td class="num col-out">{{ r.withdrawalCount }}</td>
|
||||
<td class="num col-out">₹{{ r.withdrawalAmount | number: '1.2-2' }}</td>
|
||||
<!-- Vault and counter added together: to a teller both are notes arriving, and
|
||||
splitting them made the reader do the sum. A dash rather than a zero where
|
||||
nothing happened, so the eye passes over it. -->
|
||||
<td class="num col-in">{{ r.vaultInCount + r.depositCount || '—' }}</td>
|
||||
<td class="num col-out">{{ r.withdrawalCount + r.settledOutCount || '—' }}</td>
|
||||
<td
|
||||
class="num col-net"
|
||||
[style.color]="r.netCount > 0 ? '#16a34a' : r.netCount < 0 ? '#dc2626' : '#94a3b8'"
|
||||
>
|
||||
{{ r.netCount > 0 ? '+' : '' }}{{ r.netCount }}
|
||||
{{ r.netCount }}
|
||||
</td>
|
||||
<td
|
||||
class="num col-net"
|
||||
@@ -236,9 +226,9 @@
|
||||
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.
|
||||
@if (scopedToCashier && hasTodayLegs && expectedInDrawer !== 0) {
|
||||
No cash crossed the counter on this date. The drawer should hold ₹{{ expectedInDrawer | number: '1.2-2' }}
|
||||
from the day's vault and settlement legs.
|
||||
} @else {
|
||||
No cash denomination data for this date.
|
||||
}
|
||||
|
||||
@@ -65,6 +65,19 @@ export class ClosingCashReportComponent implements OnInit {
|
||||
*/
|
||||
openingBalance: number | null = null;
|
||||
closingBalance: number | null = null;
|
||||
|
||||
/**
|
||||
* The day's own cash legs, which is what a cashier reconciles against the drawer.
|
||||
*
|
||||
* A cashier carries no balance: they draw from the vault in the morning and hand back at close.
|
||||
* The cumulative opening/closing figures answer a question nobody at the counter asks, and one
|
||||
* carrying years of history answers it with a number that cannot be counted against anything on
|
||||
* the desk.
|
||||
*/
|
||||
todayVaultIn: number | null = null;
|
||||
todaySettledOut: number | null = null;
|
||||
todayInterCashierIn: number | null = null;
|
||||
todayInterCashierOut: 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. */
|
||||
@@ -95,6 +108,29 @@ export class ClosingCashReportComponent implements OnInit {
|
||||
selectedTellerId: number | null = null;
|
||||
selectedCashierId: number | null = null;
|
||||
|
||||
/**
|
||||
* What should be in the drawer at the end of the day, from the day's own legs.
|
||||
*
|
||||
* Drawn from the vault, plus anything taken over the counter and from another cashier, less what
|
||||
* went back out. This is the figure a cashier counts against — it does not carry a balance
|
||||
* forward, because a cashier does not.
|
||||
*/
|
||||
get expectedInDrawer(): number {
|
||||
return (
|
||||
Number(this.todayVaultIn ?? 0) +
|
||||
Number(this.todayInterCashierIn ?? 0) +
|
||||
Number(this.depositTotalWithDd ?? 0) -
|
||||
Number(this.withdrawalTotal ?? 0) -
|
||||
Number(this.todaySettledOut ?? 0) -
|
||||
Number(this.todayInterCashierOut ?? 0)
|
||||
);
|
||||
}
|
||||
|
||||
/** True when any of the day's own legs carries a figure, so the panel is worth showing. */
|
||||
get hasTodayLegs(): boolean {
|
||||
return this.todayVaultIn !== null;
|
||||
}
|
||||
|
||||
get noteRows(): any[] {
|
||||
return this.rows.filter((r) => r.kind !== 'coin');
|
||||
}
|
||||
@@ -122,8 +158,35 @@ export class ClosingCashReportComponent implements OnInit {
|
||||
return this.noteRows.reduce((s, r) => s + Number(r.withdrawalAmount ?? 0), 0);
|
||||
}
|
||||
|
||||
get noteVaultInCount(): number {
|
||||
return this.noteRows.reduce((s, r) => s + Number(r.vaultInCount ?? 0), 0);
|
||||
}
|
||||
|
||||
get noteVaultInAmount(): number {
|
||||
return this.noteRows.reduce((s, r) => s + Number(r.vaultInAmount ?? 0), 0);
|
||||
}
|
||||
|
||||
get noteSettledOutCount(): number {
|
||||
return this.noteRows.reduce((s, r) => s + Number(r.settledOutCount ?? 0), 0);
|
||||
}
|
||||
|
||||
get noteSettledOutAmount(): number {
|
||||
return this.noteRows.reduce((s, r) => s + Number(r.settledOutAmount ?? 0), 0);
|
||||
}
|
||||
|
||||
get noteNetCount(): number {
|
||||
return this.noteVaultInCount + this.noteDepositCount - this.noteWithdrawalCount - this.noteSettledOutCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* What is left in the drawer in notes: everything that came in, less everything that went out.
|
||||
*
|
||||
* Was deposits minus withdrawals, which is the counter's movement rather than the drawer's
|
||||
* contents — it left out the cash drawn from the vault, so a teller holding a ₹500 note saw a net
|
||||
* of zero and had nothing to count against.
|
||||
*/
|
||||
get noteNetAmount(): number {
|
||||
return this.noteDepositAmount - this.noteWithdrawalAmount;
|
||||
return this.noteVaultInAmount + this.noteDepositAmount - this.noteWithdrawalAmount - this.noteSettledOutAmount;
|
||||
}
|
||||
get coinRows(): any[] {
|
||||
return this.rows.filter((r) => r.kind === 'coin');
|
||||
@@ -160,6 +223,10 @@ export class ClosingCashReportComponent implements OnInit {
|
||||
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.todayVaultIn = res?.todayVaultIn == null ? null : Number(res.todayVaultIn);
|
||||
this.todaySettledOut = res?.todaySettledOut == null ? null : Number(res.todaySettledOut);
|
||||
this.todayInterCashierIn = res?.todayInterCashierIn == null ? null : Number(res.todayInterCashierIn);
|
||||
this.todayInterCashierOut = res?.todayInterCashierOut == null ? null : Number(res.todayInterCashierOut);
|
||||
this.loading = false;
|
||||
this.loaded = true;
|
||||
},
|
||||
@@ -176,6 +243,10 @@ export class ClosingCashReportComponent implements OnInit {
|
||||
this.openingBalance = null;
|
||||
this.closingBalance = null;
|
||||
this.cashMovement = null;
|
||||
this.todayVaultIn = null;
|
||||
this.todaySettledOut = null;
|
||||
this.todayInterCashierIn = null;
|
||||
this.todayInterCashierOut = null;
|
||||
this.loading = false;
|
||||
this.loaded = true;
|
||||
}
|
||||
|
||||
@@ -9,11 +9,10 @@
|
||||
<div class="pg">
|
||||
<header class="pg-head">
|
||||
<div>
|
||||
<div class="pg-eyebrow">CASHIER · DAY BOOK</div>
|
||||
<h1 class="pg-title">Day Book</h1>
|
||||
<h1 class="pg-title">{{ title }}</h1>
|
||||
<p class="pg-sub">
|
||||
Every voucher raised on the selected date — cash deposits, withdrawals, disbursements, collections and bank
|
||||
transfers, with credit and debit totals.
|
||||
Every voucher raised on the selected date — cash deposits, withdrawals, disbursements, collections, interest and
|
||||
journals, with each person's own credit, debit and net.
|
||||
</p>
|
||||
</div>
|
||||
<div class="pg-toolbar">
|
||||
@@ -103,11 +102,90 @@
|
||||
Totals count approved vouchers only — pending and rejected rows are listed but never move the money.
|
||||
</p>
|
||||
|
||||
<!-- The day per person, for the officer who signs the book off. The sections below trace one
|
||||
voucher at a time; this is the other reading a branch needs — what each member of staff
|
||||
handled, and whether their column adds up. Built from whoever appears in the day's rows, so
|
||||
one person working alone gets one line and five people get five. -->
|
||||
@if (byUser.length > 0) {
|
||||
<div class="pg-card db-section db-byuser">
|
||||
<div class="db-section__head">
|
||||
<strong>Officer Verification Sheet</strong>
|
||||
<span class="db-muted">Each person's own credit, debit and net for the day</span>
|
||||
<span class="db-section__count"> {{ byUser.length }} {{ byUser.length === 1 ? 'person' : 'people' }} </span>
|
||||
</div>
|
||||
<div class="pg-table-wrap">
|
||||
<table class="pg-table db-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Handled by</th>
|
||||
<th class="num">Items</th>
|
||||
<th class="num">Credit (CR)</th>
|
||||
<th class="num">Debit (DR)</th>
|
||||
<th class="num">Net</th>
|
||||
<th class="num">Pending</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (u of byUser; track u.name) {
|
||||
<tr>
|
||||
<td class="db-byuser__name">{{ u.name }}</td>
|
||||
<td class="num">{{ u.items }}</td>
|
||||
<td class="num db-byuser__cr">₹{{ u.credit | number: '1.2-2' }}</td>
|
||||
<td class="num db-byuser__dr">₹{{ u.debit | number: '1.2-2' }}</td>
|
||||
<td
|
||||
class="num db-byuser__net"
|
||||
[class.db-byuser__net--pos]="u.net >= 0"
|
||||
[class.db-byuser__net--neg]="u.net < 0"
|
||||
>
|
||||
₹{{ u.net | number: '1.2-2' }}
|
||||
</td>
|
||||
<td class="num">
|
||||
@if (u.pending > 0) {
|
||||
<span class="db-badge db-badge--pending">{{ u.pending }}</span>
|
||||
} @else {
|
||||
—
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
<!-- The sheet's own total, which must equal the day's totals above. That equality is
|
||||
what makes the sheet verifiable rather than just informative. -->
|
||||
<tfoot>
|
||||
<tr class="db-byuser__total">
|
||||
<td>All staff</td>
|
||||
<td class="num">{{ byUserTotals.items }}</td>
|
||||
<td class="num db-byuser__cr">₹{{ byUserTotals.credit | number: '1.2-2' }}</td>
|
||||
<td class="num db-byuser__dr">₹{{ byUserTotals.debit | number: '1.2-2' }}</td>
|
||||
<td
|
||||
class="num db-byuser__net"
|
||||
[class.db-byuser__net--pos]="byUserTotals.net >= 0"
|
||||
[class.db-byuser__net--neg]="byUserTotals.net < 0"
|
||||
>
|
||||
₹{{ byUserTotals.net | number: '1.2-2' }}
|
||||
</td>
|
||||
<td class="num">
|
||||
@if (byUserTotals.pending > 0) {
|
||||
{{ byUserTotals.pending }}
|
||||
} @else {
|
||||
—
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- 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">
|
||||
<!-- The section's own colour on the left edge: cash that actually crossed the counter, work
|
||||
still waiting to be actioned, and postings the system made are three different things to
|
||||
a person closing the day. -->
|
||||
<div [class]="'pg-card db-section db-section--' + s.key">
|
||||
<div class="db-section__head">
|
||||
<strong>{{ s.label }}</strong>
|
||||
<span class="db-muted">{{ s.hint }}</span>
|
||||
|
||||
@@ -36,6 +36,36 @@
|
||||
from the others, so the three are not added up as though they were one list. */
|
||||
.db-section {
|
||||
margin-bottom: 14px;
|
||||
border-left: 3px solid #cbd5e1;
|
||||
|
||||
/* Cash that actually crossed the counter — the section a drawer is checked against, so it
|
||||
carries the darker green. */
|
||||
&--CASHIER {
|
||||
border-left-color: #15803d;
|
||||
|
||||
.db-section__head {
|
||||
background: #f0fdf4;
|
||||
}
|
||||
}
|
||||
|
||||
/* Raised but not actioned: no cash has moved yet, so the lighter shade of the same green. */
|
||||
&--CLERK {
|
||||
border-left-color: #86efac;
|
||||
|
||||
.db-section__head {
|
||||
background: #f7fef9;
|
||||
}
|
||||
}
|
||||
|
||||
/* Postings with no counter behind them — interest, charges, journals. Not the branch's cash at
|
||||
all, so a different colour rather than a third green that would read as a third cash state. */
|
||||
&--SYSTEM {
|
||||
border-left-color: #6366f1;
|
||||
|
||||
.db-section__head {
|
||||
background: #f5f5ff;
|
||||
}
|
||||
}
|
||||
|
||||
&__head {
|
||||
display: flex;
|
||||
@@ -110,11 +140,19 @@
|
||||
background: #f1f5f9;
|
||||
color: #475569;
|
||||
|
||||
/* Passed, but nothing handed over yet — the lighter green. */
|
||||
&--ok {
|
||||
background: #dcfce7;
|
||||
color: #15803d;
|
||||
}
|
||||
|
||||
/* Cash has actually left the counter. Filled and dark, so the final state is the one the eye
|
||||
lands on: on this page the difference between "approved" and "paid out" is the whole question. */
|
||||
&--done {
|
||||
background: #15803d;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
&--pending {
|
||||
background: #fef3c7;
|
||||
color: #b45309;
|
||||
@@ -352,3 +390,52 @@
|
||||
color: #334155;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
/* Officer Verification Sheet — the day totalled per person, for whoever signs the book off.
|
||||
Numeric cells use the shared `.num` from cashier-shared.scss rather than a class of their own:
|
||||
that one already right-aligns and tabular-figures both the heading and the cell, which is what
|
||||
lines a money column up. A local class only styled the cells, leaving every heading left-aligned
|
||||
over right-aligned figures. */
|
||||
.db-byuser {
|
||||
/* Not one of the three sections below, so no section colour: this is the whole day summed by
|
||||
person, and giving it one of their edges would read as a fourth section. */
|
||||
border-left-color: #94a3b8;
|
||||
|
||||
&__name {
|
||||
font-weight: 600;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
/* Received green, paid-out red — the convention a passbook already uses, so the two columns are
|
||||
told apart without reading the headings. */
|
||||
&__cr {
|
||||
color: #15803d;
|
||||
}
|
||||
|
||||
&__dr {
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
&__net {
|
||||
font-weight: 600;
|
||||
|
||||
&--pos {
|
||||
color: #15803d;
|
||||
}
|
||||
|
||||
&--neg {
|
||||
color: #b91c1c;
|
||||
}
|
||||
}
|
||||
|
||||
/* The sheet's own total sits against a tinted band so the eye lands on the line that has to
|
||||
agree with the day's figures above. */
|
||||
&__total {
|
||||
background: #f8fafc;
|
||||
font-weight: 600;
|
||||
|
||||
td {
|
||||
border-top: 2px solid #cbd5e1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Component, OnInit, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { Router } from '@angular/router';
|
||||
import { catchError, of } from 'rxjs';
|
||||
import { CashierService } from '../cashier.service';
|
||||
|
||||
@@ -20,7 +21,7 @@ import { CashierService } from '../cashier.service';
|
||||
* 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';
|
||||
type DayBookSection = 'CASHIER' | 'CLERK' | 'SYSTEM';
|
||||
|
||||
interface DayBookRow {
|
||||
voucherNo: string | null;
|
||||
@@ -61,6 +62,18 @@ interface DayBookRow {
|
||||
})
|
||||
export class DayBookComponent implements OnInit {
|
||||
private service = inject(CashierService);
|
||||
private router = inject(Router);
|
||||
|
||||
/**
|
||||
* The heading, taken from the route the screen was opened by.
|
||||
*
|
||||
* One screen serves three menus, and only the Branch Ops reading is a different thing: there it
|
||||
* is the branch's book with the verification sheet an officer signs off, rather than one desk's
|
||||
* view of the day.
|
||||
*/
|
||||
get title(): string {
|
||||
return this.router.url.includes('/branch-ops/') ? 'Day Book & Officer Verification' : 'Day Book';
|
||||
}
|
||||
|
||||
date = '';
|
||||
side: '' | 'CR' | 'DR' = '';
|
||||
@@ -145,7 +158,15 @@ export class DayBookComponent implements OnInit {
|
||||
}[] {
|
||||
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' }
|
||||
{ key: 'CLERK', label: 'Clerk', hint: 'Requests raised but not yet actioned — no cash has moved' },
|
||||
// Postings that reached the ledger without a request behind them: interest the nightly run
|
||||
// credited, a charge taken from a balance, a manual journal. These are the day's work too,
|
||||
// and an officer accounting for the day cannot do it from the counter registers alone.
|
||||
{
|
||||
key: 'SYSTEM',
|
||||
label: 'System & Journal',
|
||||
hint: 'Interest, charges and journals posted without a counter request'
|
||||
}
|
||||
];
|
||||
return order
|
||||
.map((s) => {
|
||||
@@ -206,11 +227,90 @@ export class DayBookComponent implements OnInit {
|
||||
return s === 'APPROVED' || s === 'COMPLETED' || s === 'WITHDRAWN';
|
||||
}
|
||||
|
||||
/**
|
||||
* The day totalled per person, for the officer who signs the book off.
|
||||
*
|
||||
* A branch's day book is read two ways. Entry by entry, to trace one voucher — which the sections
|
||||
* below already give. And person by person, to ask what each member of staff handled and whether
|
||||
* their column adds up: that is the reconciliation a principal officer actually performs, and
|
||||
* without it they are adding a day of rows in their head.
|
||||
*
|
||||
* Built from whoever appears in the day's own rows rather than from a list of staff, so it needs
|
||||
* no configuring: one person working alone gets one line, five people get five, and somebody who
|
||||
* handled nothing today is simply absent instead of showing as a row of zeros.
|
||||
*
|
||||
* Only approved money counts, the same rule the section totals use — a pending request is not
|
||||
* cash anybody is answerable for yet. Pending and rejected rows are still counted as ITEMS, so a
|
||||
* person whose work is all awaiting approval does not vanish from the sheet.
|
||||
*
|
||||
* Ordered by the largest turnover first: the columns worth checking hardest are the ones that
|
||||
* moved the most money.
|
||||
*/
|
||||
get byUser(): {
|
||||
name: string;
|
||||
credit: number;
|
||||
debit: number;
|
||||
net: number;
|
||||
items: number;
|
||||
pending: number;
|
||||
}[] {
|
||||
const map = new Map<string, { name: string; credit: number; debit: number; items: number; pending: number }>();
|
||||
|
||||
for (const r of this.filteredRows) {
|
||||
// Rows the registers could not attribute are grouped rather than dropped: an unattributed
|
||||
// amount is exactly what an officer needs to see, and hiding it would leave the sheet's own
|
||||
// total disagreeing with the day's.
|
||||
const name = (r.handledBy ?? '').trim() || 'Unattributed';
|
||||
const entry = map.get(name) ?? { name, credit: 0, debit: 0, items: 0, pending: 0 };
|
||||
|
||||
entry.items++;
|
||||
if (this.countsAsMoney(r)) {
|
||||
const amount = Number(r.amount ?? 0);
|
||||
if (r.side === 'CR') {
|
||||
entry.credit += amount;
|
||||
} else {
|
||||
entry.debit += amount;
|
||||
}
|
||||
} else {
|
||||
entry.pending++;
|
||||
}
|
||||
map.set(name, entry);
|
||||
}
|
||||
|
||||
return Array.from(map.values())
|
||||
.map((e) => ({ ...e, net: e.credit - e.debit }))
|
||||
.sort((a, b) => b.credit + b.debit - (a.credit + a.debit));
|
||||
}
|
||||
|
||||
/** The sheet's own totals, which must equal the day's — that is what makes it verifiable. */
|
||||
get byUserTotals(): { credit: number; debit: number; net: number; items: number; pending: number } {
|
||||
return this.byUser.reduce(
|
||||
(t, u) => ({
|
||||
credit: t.credit + u.credit,
|
||||
debit: t.debit + u.debit,
|
||||
net: t.net + u.net,
|
||||
items: t.items + u.items,
|
||||
pending: t.pending + u.pending
|
||||
}),
|
||||
{ credit: 0, debit: 0, net: 0, items: 0, pending: 0 }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Approved and paid out are not the same state, and the day book is where the difference matters:
|
||||
* an approved withdrawal is a promise, a withdrawn one is money gone. Both used to render in the
|
||||
* same green, so a reader could not tell from the badge whether the customer had actually been
|
||||
* handed the cash — the one thing this page is read to find out.
|
||||
*/
|
||||
statusClass(status: string): string {
|
||||
switch ((status ?? '').toUpperCase()) {
|
||||
case 'APPROVED':
|
||||
case 'COMPLETED':
|
||||
case 'WITHDRAWN':
|
||||
case 'COMPLETED':
|
||||
case 'CLOSED':
|
||||
// Cash has left the counter. The darker, filled badge — the final state.
|
||||
return 'db-badge db-badge--done';
|
||||
case 'APPROVED':
|
||||
// Passed, but nobody has handed anything over yet.
|
||||
return 'db-badge db-badge--ok';
|
||||
case 'PENDING':
|
||||
return 'db-badge db-badge--pending';
|
||||
|
||||
@@ -0,0 +1,606 @@
|
||||
<!--
|
||||
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 eod">
|
||||
<!-- Toolbar: never printed, because a printed sheet has nothing to click. -->
|
||||
<div class="pg-head no-print">
|
||||
<div>
|
||||
<h1 class="pg-title">End of the Day Report</h1>
|
||||
<p class="pg-sub">What moved today, whether the cash agrees with the books, and who signs off.</p>
|
||||
</div>
|
||||
<div class="pg-toolbar">
|
||||
<label class="pg-field">
|
||||
<span>Business date</span>
|
||||
<input type="date" [(ngModel)]="date" (change)="reload()" />
|
||||
</label>
|
||||
<button class="pg-btn pg-btn--ghost" (click)="reload()" [disabled]="loading">
|
||||
<mat-icon>refresh</mat-icon>
|
||||
Reload
|
||||
</button>
|
||||
<button class="pg-btn pg-btn--primary" (click)="print()" [disabled]="!loaded">
|
||||
<mat-icon>print</mat-icon>
|
||||
Print / Save as PDF
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (loading) {
|
||||
<div class="pg-empty">Loading the day’s figures…</div>
|
||||
}
|
||||
|
||||
@if (loaded) {
|
||||
<div class="eod-sheet">
|
||||
<!-- ── Header ── -->
|
||||
<div class="eod-sheet__head">
|
||||
<h2 class="eod-sheet__title">Daily EOD & Transaction Report</h2>
|
||||
<div class="eod-sheet__meta">
|
||||
<div class="eod-meta">
|
||||
<span class="eod-meta__label">Date</span>
|
||||
<span class="eod-meta__value">{{ date | date: 'dd MMM yyyy' }}</span>
|
||||
</div>
|
||||
<div class="eod-meta">
|
||||
<span class="eod-meta__label">Branch</span>
|
||||
<span class="eod-meta__value">{{ branchName || '—' }}</span>
|
||||
</div>
|
||||
<div class="eod-meta">
|
||||
<span class="eod-meta__label">Prepared by</span>
|
||||
<span class="eod-meta__value">{{ preparedBy || '—' }}</span>
|
||||
</div>
|
||||
<div class="eod-meta">
|
||||
<span class="eod-meta__label">Scope</span>
|
||||
<span class="eod-meta__value">{{ scopedToCashier ? 'My counter' : 'Branch counter' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── 1. Transaction summary ── -->
|
||||
<section class="eod-section">
|
||||
<h3 class="eod-section__title"><span class="eod-section__no">1</span> Transaction Summary</h3>
|
||||
|
||||
@if (sectionLines.length) {
|
||||
<p class="eod-caption">Where the money came from</p>
|
||||
<table class="pg-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Source</th>
|
||||
<th class="num">Entries</th>
|
||||
<th class="num col-in">Money in</th>
|
||||
<th class="num col-out">Money out</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (l of sectionLines; track l.label) {
|
||||
<tr>
|
||||
<td>{{ l.label }}</td>
|
||||
<td class="num">{{ l.count }}</td>
|
||||
<td class="num col-in">{{ l.credit ? (l.credit | currency: 'INR') : '—' }}</td>
|
||||
<td class="num col-out">{{ l.debit ? (l.debit | currency: 'INR') : '—' }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td><strong>Total</strong></td>
|
||||
<td class="num">
|
||||
<strong>{{ txnCount }}</strong>
|
||||
</td>
|
||||
<td class="num col-in">
|
||||
<strong>{{ summaryCreditTotal | currency: 'INR' }}</strong>
|
||||
</td>
|
||||
<td class="num col-out">
|
||||
<strong>{{ summaryDebitTotal | currency: 'INR' }}</strong>
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
|
||||
<p class="eod-caption eod-caption--spaced">What kind of entries</p>
|
||||
<table class="pg-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Kind</th>
|
||||
<th class="num">Entries</th>
|
||||
<th class="num col-in">Money in</th>
|
||||
<th class="num col-out">Money out</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (l of kindLines; track l.label) {
|
||||
<tr>
|
||||
<td>{{ l.label }}</td>
|
||||
<td class="num">{{ l.count }}</td>
|
||||
<td class="num col-in">{{ l.credit ? (l.credit | currency: 'INR') : '—' }}</td>
|
||||
<td class="num col-out">{{ l.debit ? (l.debit | currency: 'INR') : '—' }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<p class="eod-note">
|
||||
Only entries that actually moved money. Anything still pending is listed at the bottom. These figures match
|
||||
the Day Book for the same date.
|
||||
</p>
|
||||
} @else {
|
||||
<div class="pg-empty">No transactions on this date.</div>
|
||||
}
|
||||
</section>
|
||||
|
||||
<!-- ── 2. Cash ──
|
||||
Movement first, because that is the part every figure here agrees on. A comparison
|
||||
against what is held is only offered where both sides describe the same period. -->
|
||||
<section class="eod-section">
|
||||
<h3 class="eod-section__title"><span class="eod-section__no">2</span> Cash</h3>
|
||||
|
||||
@if (scopedToCashier) {
|
||||
<p class="eod-caption">Your drawer today</p>
|
||||
<table class="pg-table eod-recon">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Cash you took from the vault this morning</td>
|
||||
<td class="num">{{ openingFloat | currency: 'INR' }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Cash you took from members <span class="eod-hint">deposits, including DD cash</span></td>
|
||||
<td class="num col-in">+{{ cashDeposits | currency: 'INR' }}</td>
|
||||
</tr>
|
||||
@if (interCashierIn) {
|
||||
<tr>
|
||||
<td>Cash another cashier gave you</td>
|
||||
<td class="num col-in">+{{ interCashierIn | currency: 'INR' }}</td>
|
||||
</tr>
|
||||
}
|
||||
<tr>
|
||||
<td>Cash you paid to members <span class="eod-hint">withdrawals</span></td>
|
||||
<td class="num col-out">−{{ cashWithdrawals | currency: 'INR' }}</td>
|
||||
</tr>
|
||||
@if (settledOut) {
|
||||
<tr>
|
||||
<td>Cash you handed back to the vault</td>
|
||||
<td class="num col-out">−{{ settledOut | currency: 'INR' }}</td>
|
||||
</tr>
|
||||
}
|
||||
@if (interCashierOut) {
|
||||
<tr>
|
||||
<td>Cash you gave another cashier</td>
|
||||
<td class="num col-out">−{{ interCashierOut | currency: 'INR' }}</td>
|
||||
</tr>
|
||||
}
|
||||
<tr class="eod-recon__expected">
|
||||
<td><strong>So your drawer should hold</strong></td>
|
||||
<td class="num">
|
||||
@if (expectedCash !== null) {
|
||||
<strong>{{ expectedCash | currency: 'INR' }}</strong>
|
||||
} @else {
|
||||
<span class="eod-blank">—</span>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>System says you hold</strong></td>
|
||||
<td class="num">
|
||||
@if (actualCash !== null) {
|
||||
<strong>{{ actualCash | currency: 'INR' }}</strong>
|
||||
} @else {
|
||||
<span class="eod-blank">nothing recorded</span>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
@if (variance !== null) {
|
||||
<tr class="eod-recon__variance" [class]="varianceClass">
|
||||
<td><strong>Difference</strong></td>
|
||||
<td class="num">
|
||||
<strong>{{ variance | currency: 'INR' }}</strong>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@if (variance !== null) {
|
||||
@if (varianceOk) {
|
||||
<p class="eod-verdict eod-verdict--ok">
|
||||
<mat-icon>check_circle</mat-icon>
|
||||
Your cash agrees. Nothing short, nothing over.
|
||||
</p>
|
||||
} @else {
|
||||
<p class="eod-verdict eod-verdict--bad">
|
||||
<mat-icon>error</mat-icon>
|
||||
{{ variance! > 0 ? 'Extra' : 'Short' }} by
|
||||
{{ (variance! > 0 ? variance : -variance!) | currency: 'INR' }}. Explain this before closing.
|
||||
</p>
|
||||
}
|
||||
} @else {
|
||||
<p class="eod-note">Today’s cash legs were not sent for this login, so nothing is compared.</p>
|
||||
}
|
||||
} @else {
|
||||
<p class="eod-caption">Cash that moved across the branch today</p>
|
||||
<table class="pg-table eod-recon">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Cash taken from members <span class="eod-hint">deposits</span></td>
|
||||
<td class="num col-in">+{{ branchDeposits ?? 0 | currency: 'INR' }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Cash paid to members <span class="eod-hint">withdrawals and closures</span></td>
|
||||
<td class="num col-out">−{{ branchWithdrawals ?? 0 | currency: 'INR' }}</td>
|
||||
</tr>
|
||||
<tr class="eod-recon__expected">
|
||||
<td><strong>Net change today</strong></td>
|
||||
<td class="num col-net">
|
||||
<strong>{{ branchNetMovement | currency: 'INR' }}</strong>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<p class="eod-caption eod-caption--spaced">Cash the branch is holding right now</p>
|
||||
<table class="pg-table eod-recon">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>In the vaults</td>
|
||||
<td class="num">{{ vaultTotal ?? 0 | currency: 'INR' }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>With the cashiers</td>
|
||||
<td class="num">{{ drawersTotal ?? 0 | currency: 'INR' }}</td>
|
||||
</tr>
|
||||
<tr class="eod-recon__expected">
|
||||
<td><strong>Total cash in the branch</strong></td>
|
||||
<td class="num">
|
||||
@if (branchHeldTotal !== null) {
|
||||
<strong>{{ branchHeldTotal | currency: 'INR' }}</strong>
|
||||
} @else {
|
||||
<span class="eod-blank">nothing recorded</span>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<p class="eod-note">
|
||||
These are two different things and are not subtracted from each other. The first is today’s movement;
|
||||
the second is everything the branch has accumulated to date. Each cashier’s own drawer is checked
|
||||
against their own day on their login, and the branch figure is signed off on the Day Open / Day Close
|
||||
screen.
|
||||
</p>
|
||||
}
|
||||
</section>
|
||||
|
||||
<!-- ── 3. Who handled the day, and what each drawer holds ──
|
||||
A clerk signs off other people's work, so the sheet has to name them. On a cashier's own
|
||||
login this collapses to one row, which is still the right thing to print. -->
|
||||
<section class="eod-section">
|
||||
<h3 class="eod-section__title"><span class="eod-section__no">3</span> Handled By</h3>
|
||||
|
||||
@if (byUser.length) {
|
||||
<p class="eod-caption">What each person handled</p>
|
||||
<table class="pg-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Handled by</th>
|
||||
<th class="num">Entries</th>
|
||||
<th class="num col-in">Money in</th>
|
||||
<th class="num col-out">Money out</th>
|
||||
<th class="num col-net">Net</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (u of byUser; track u.label) {
|
||||
<tr>
|
||||
<td>{{ u.label }}</td>
|
||||
<td class="num">{{ u.count }}</td>
|
||||
<td class="num col-in">{{ u.credit ? (u.credit | currency: 'INR') : '—' }}</td>
|
||||
<td class="num col-out">{{ u.debit ? (u.debit | currency: 'INR') : '—' }}</td>
|
||||
<td class="num col-net">{{ u.credit - u.debit | currency: 'INR' }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td><strong>Total</strong></td>
|
||||
<td class="num">
|
||||
<strong>{{ txnCount }}</strong>
|
||||
</td>
|
||||
<td class="num col-in">
|
||||
<strong>{{ summaryCreditTotal | currency: 'INR' }}</strong>
|
||||
</td>
|
||||
<td class="num col-out">
|
||||
<strong>{{ summaryDebitTotal | currency: 'INR' }}</strong>
|
||||
</td>
|
||||
<td class="num col-net">
|
||||
<strong>{{ summaryCreditTotal - summaryDebitTotal | currency: 'INR' }}</strong>
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
} @else {
|
||||
<div class="pg-empty">Nobody moved money on this date.</div>
|
||||
}
|
||||
|
||||
@if (cashierPositions.length) {
|
||||
<p class="eod-caption eod-caption--spaced">What each cashier is holding</p>
|
||||
<table class="pg-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Cashier</th>
|
||||
<th class="num">Cash held</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (p of cashierPositions; track p.cashierId) {
|
||||
<tr>
|
||||
<td>{{ p.cashierName }}</td>
|
||||
<td class="num">{{ p.totalAmount | currency: 'INR' }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
@if (cashierPositions.length > 1) {
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td><strong>Total</strong></td>
|
||||
<td class="num">
|
||||
<strong>{{ cashierPositionTotal | currency: 'INR' }}</strong>
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
}
|
||||
</table>
|
||||
<p class="eod-note">
|
||||
This is what each drawer should contain right now — a running total, not just today. Count against it
|
||||
before settling to the vault.
|
||||
</p>
|
||||
}
|
||||
</section>
|
||||
|
||||
<!-- ── 4. Every transaction ──
|
||||
The sections above are totals, and a total nobody can break down is a total nobody can
|
||||
check. This is the day itself, line by line, so a figure above can be traced to the
|
||||
entries that made it. -->
|
||||
<section class="eod-section eod-section--wide">
|
||||
<h3 class="eod-section__title"><span class="eod-section__no">4</span> Every Transaction</h3>
|
||||
|
||||
@if (approvedRows.length) {
|
||||
<table class="pg-table eod-txns">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Voucher</th>
|
||||
<th>Kind</th>
|
||||
<th>Account</th>
|
||||
<th>Party</th>
|
||||
<th>Handled by</th>
|
||||
<th class="num col-in">Money in</th>
|
||||
<th class="num col-out">Money out</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (r of approvedRows; track r.voucherNo + '|' + r.txnId) {
|
||||
<tr>
|
||||
<td class="eod-mono">{{ r.voucherNo || '—' }}</td>
|
||||
<td>{{ kindLabel(r.kind) }}</td>
|
||||
<td class="eod-mono">{{ r.accountNo || '—' }}</td>
|
||||
<td>{{ r.party || '—' }}</td>
|
||||
<td>{{ r.handledBy || '—' }}</td>
|
||||
<td class="num col-in">{{ r.side === 'CR' ? (r.amount | currency: 'INR') : '—' }}</td>
|
||||
<td class="num col-out">{{ r.side === 'DR' ? (r.amount | currency: 'INR') : '—' }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td colspan="5">
|
||||
<strong>Total — {{ txnCount }} entries</strong>
|
||||
</td>
|
||||
<td class="num col-in">
|
||||
<strong>{{ summaryCreditTotal | currency: 'INR' }}</strong>
|
||||
</td>
|
||||
<td class="num col-out">
|
||||
<strong>{{ summaryDebitTotal | currency: 'INR' }}</strong>
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
} @else {
|
||||
<div class="pg-empty">No transactions on this date.</div>
|
||||
}
|
||||
|
||||
@if (pendingRows.length) {
|
||||
<p class="eod-caption eod-caption--spaced">Still pending — no money has moved on these</p>
|
||||
<table class="pg-table eod-txns">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Voucher</th>
|
||||
<th>Kind</th>
|
||||
<th>Account</th>
|
||||
<th>Party</th>
|
||||
<th>Raised by</th>
|
||||
<th class="num">Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (r of pendingRows; track r.voucherNo + '|' + r.txnId) {
|
||||
<tr>
|
||||
<td class="eod-mono">{{ r.voucherNo || '—' }}</td>
|
||||
<td>{{ kindLabel(r.kind) }}</td>
|
||||
<td class="eod-mono">{{ r.accountNo || '—' }}</td>
|
||||
<td>{{ r.party || '—' }}</td>
|
||||
<td>{{ r.handledBy || '—' }}</td>
|
||||
<td class="num">{{ r.amount | currency: 'INR' }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
</section>
|
||||
|
||||
<!-- ── Last night's batch ──
|
||||
Yesterday's work, posted after that day had already been counted and signed. Shown here
|
||||
so it is not lost, kept out of every total above so today's figures still tie to the
|
||||
ledger. Only appears when there is such a batch. -->
|
||||
@if (priorDayRows.length) {
|
||||
<section class="eod-section eod-section--wide eod-section--prior">
|
||||
<h3 class="eod-section__title">
|
||||
<mat-icon class="eod-section__icon">nights_stay</mat-icon>
|
||||
Posted Overnight — work of
|
||||
{{ priorDayDate ? (priorDayDate | date: 'dd MMM yyyy') : 'the previous day' }}
|
||||
</h3>
|
||||
|
||||
<!-- Both dates side by side, because the whole point of this block is that they differ.
|
||||
A reader who only sees one of them cannot tell whether the entry is being counted
|
||||
today or merely reported today. -->
|
||||
<div class="eod-datepair">
|
||||
<div class="eod-datepair__item">
|
||||
<span class="eod-datepair__label">These belong to</span>
|
||||
<span class="eod-datepair__value">{{
|
||||
priorDayDate ? (priorDayDate | date: 'dd MMM yyyy') : 'the previous day'
|
||||
}}</span>
|
||||
<span class="eod-datepair__hint">the day the system posted them against — already closed</span>
|
||||
</div>
|
||||
<div class="eod-datepair__arrow">→</div>
|
||||
<div class="eod-datepair__item">
|
||||
<span class="eod-datepair__label">Showing on</span>
|
||||
<span class="eod-datepair__value">{{ date | date: 'dd MMM yyyy' }}</span>
|
||||
<span class="eod-datepair__hint">today’s report, because the run finished after midnight</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="eod-note eod-note--lead">
|
||||
The nightly run posted these after
|
||||
{{ priorDayDate ? (priorDayDate | date: 'dd MMM') : 'the previous day' }} had already closed and been signed
|
||||
off, so they surface on today’s report instead. In the ledger they sit on
|
||||
{{ priorDayDate ? (priorDayDate | date: 'dd MMM') : 'that date' }}, and they are
|
||||
<strong>not counted</strong> in any figure above.
|
||||
</p>
|
||||
|
||||
<table class="pg-table eod-txns">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Voucher</th>
|
||||
<th>Kind</th>
|
||||
<th>Account</th>
|
||||
<th>Party</th>
|
||||
<th>Ledger date</th>
|
||||
<th class="num col-in">Money in</th>
|
||||
<th class="num col-out">Money out</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (r of priorDayRows; track r.voucherNo + '|' + r.txnId) {
|
||||
<tr>
|
||||
<td class="eod-mono">{{ r.voucherNo || '—' }}</td>
|
||||
<td>{{ kindLabel(r.kind) }}</td>
|
||||
<td class="eod-mono">{{ r.accountNo || '—' }}</td>
|
||||
<td>{{ r.party || '—' }}</td>
|
||||
<!-- Per row, not just in the heading: one run can span a date boundary, and a
|
||||
reader checking a single figure against the ledger needs the date on the line
|
||||
they are looking at. -->
|
||||
<td class="eod-datecell">{{ r.effectiveDate ? (r.effectiveDate | date: 'dd MMM yyyy') : '—' }}</td>
|
||||
<td class="num col-in">{{ r.side === 'CR' ? (r.amount | currency: 'INR') : '—' }}</td>
|
||||
<td class="num col-out">{{ r.side === 'DR' ? (r.amount | currency: 'INR') : '—' }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td colspan="5">
|
||||
<strong>Total — {{ priorDayRows.length }} entries</strong>
|
||||
</td>
|
||||
<td class="num col-in">
|
||||
<strong>{{ priorDayCredit | currency: 'INR' }}</strong>
|
||||
</td>
|
||||
<td class="num col-out">
|
||||
<strong>{{ priorDayDebit | currency: 'INR' }}</strong>
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</section>
|
||||
}
|
||||
|
||||
<!-- ── 5. Operational notes ── -->
|
||||
<section class="eod-section">
|
||||
<h3 class="eod-section__title"><span class="eod-section__no">5</span> Anything Outstanding</h3>
|
||||
|
||||
<ul class="eod-notes">
|
||||
<li>
|
||||
<span class="eod-notes__label">Entries handled</span>
|
||||
<span class="eod-notes__value">{{ txnCount }} entries · {{ kindLines.length }} kind(s)</span>
|
||||
</li>
|
||||
<li [class.eod-notes--warn]="pendingRows.length > 0">
|
||||
<span class="eod-notes__label">Waiting for approval</span>
|
||||
<span class="eod-notes__value">
|
||||
@if (pendingRows.length) {
|
||||
{{ pendingRows.length }} entry(ies) — the day cannot close until these are done
|
||||
} @else {
|
||||
None
|
||||
}
|
||||
</span>
|
||||
</li>
|
||||
<li [class.eod-notes--warn]="mismatchRows.length > 0">
|
||||
<span class="eod-notes__label">Slips that did not match</span>
|
||||
<span class="eod-notes__value">
|
||||
@if (mismatchRows.length) {
|
||||
{{ mismatchRows.length }} slip(s) do not agree with the system
|
||||
} @else {
|
||||
None
|
||||
}
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
<span class="eod-notes__label">Slips not checked yet</span>
|
||||
<span class="eod-notes__value">
|
||||
{{
|
||||
unverifiedRows.length
|
||||
? unverifiedRows.length + ' entry(ies) still to be checked against paper'
|
||||
: 'All checked'
|
||||
}}
|
||||
</span>
|
||||
</li>
|
||||
@if (priorDayRows.length) {
|
||||
<li>
|
||||
<span class="eod-notes__label">Posted overnight</span>
|
||||
<span class="eod-notes__value">
|
||||
{{ priorDayRows.length }} entry(ies) for
|
||||
{{ priorDayDate ? (priorDayDate | date: 'dd MMM') : 'the previous day' }} — listed in their own
|
||||
section, not in today’s figures
|
||||
</span>
|
||||
</li>
|
||||
}
|
||||
<li [class.eod-notes--ok]="allClear">
|
||||
<span class="eod-notes__label">Status</span>
|
||||
<span class="eod-notes__value">
|
||||
{{
|
||||
allClear
|
||||
? 'Everything is clear — the day is ready to close.'
|
||||
: 'Sort out the items above before closing the day.'
|
||||
}}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<!-- ── Signatures ── -->
|
||||
<section class="eod-sign">
|
||||
<div class="eod-sign__block">
|
||||
<div class="eod-sign__line"></div>
|
||||
<div class="eod-sign__role">{{ supervising ? 'Prepared by' : 'Cashier' }}</div>
|
||||
<div class="eod-sign__name">{{ preparedBy || '' }}</div>
|
||||
</div>
|
||||
<div class="eod-sign__block">
|
||||
<div class="eod-sign__line"></div>
|
||||
<div class="eod-sign__role">Manager / Principal Officer</div>
|
||||
<div class="eod-sign__name"> </div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<p class="eod-foot">
|
||||
Generated from the day book and the closing cash report for {{ date | date: 'dd MMM yyyy' }}. Figures move with
|
||||
the underlying vouchers — reprint after any reversal.
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,448 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
/* The sheet itself, which is what gets signed. Bordered on screen so it reads as one document
|
||||
rather than three panels, and the same borders carry into print. */
|
||||
|
||||
/* Centred in the page, because it is a document rather than a screen: a signed sheet pinned to the
|
||||
left edge of a wide monitor reads as a panel that failed to fill its space. */
|
||||
.eod-sheet {
|
||||
background: #fff;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 8px;
|
||||
padding: 28px 32px 24px;
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* The toolbar and the loading line above it share the sheet's own edges, not the viewport's. */
|
||||
.eod .pg-head,
|
||||
.eod > .pg-empty {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.eod-sheet__head {
|
||||
border-bottom: 2px solid #0f172a;
|
||||
padding-bottom: 14px;
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
|
||||
.eod-sheet__title {
|
||||
margin: 0 0 14px;
|
||||
font-size: 19px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
text-transform: uppercase;
|
||||
color: #0f172a;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.eod-sheet__meta {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 10px 18px;
|
||||
}
|
||||
|
||||
.eod-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.eod-meta__label {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.eod-meta__value {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
/* ── Sections ── */
|
||||
.eod-section {
|
||||
margin-bottom: 26px;
|
||||
|
||||
/* A section must not be split across two printed pages: a reconciliation whose expected line is
|
||||
on page one and whose variance is on page two is not a document anybody can sign. */
|
||||
break-inside: avoid;
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
|
||||
.eod-section__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
margin: 0 0 12px;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: #0f172a;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
padding-bottom: 7px;
|
||||
}
|
||||
|
||||
.eod-section__no {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 4px;
|
||||
background: #0f172a;
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.eod-note {
|
||||
margin: 8px 0 0;
|
||||
font-size: 11.5px;
|
||||
color: #64748b;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Names the table below it, so two tables in one section do not read as one long list. */
|
||||
.eod-caption {
|
||||
margin: 0 0 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.eod-caption--spaced {
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
/* The line-by-line list is wider than the rest of the sheet, so it scrolls in its own box rather
|
||||
than making the whole page scroll sideways. */
|
||||
.eod-section--wide {
|
||||
.pg-table {
|
||||
min-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.eod-txns {
|
||||
font-size: 12px;
|
||||
|
||||
td,
|
||||
th {
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
/* Voucher numbers and account numbers are read digit by digit, so they get a fixed pitch. */
|
||||
.eod-mono {
|
||||
font-family: 'Roboto Mono', ui-monospace, 'Courier New', monospace;
|
||||
font-size: 11.5px;
|
||||
}
|
||||
|
||||
/* Last night's batch. Set apart on purpose: these figures belong to a day that has already been
|
||||
signed off, so the block has to read as an appendix rather than as part of the day being counted.
|
||||
Indigo, because that is already the day book's colour for system postings. */
|
||||
.eod-section--prior {
|
||||
background: #f5f3ff;
|
||||
border: 1px solid #ddd6fe;
|
||||
border-radius: 8px;
|
||||
padding: 16px 18px 14px;
|
||||
margin-top: 30px;
|
||||
|
||||
.eod-section__title {
|
||||
color: #5b21b6;
|
||||
border-bottom-color: #ddd6fe;
|
||||
}
|
||||
|
||||
.pg-table th {
|
||||
background: #ede9fe;
|
||||
color: #5b21b6;
|
||||
}
|
||||
}
|
||||
|
||||
/* Sits in the title row in place of the numbered chip: this block is outside the numbered sequence,
|
||||
because it is not a step in counting today. */
|
||||
.eod-section__icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: #7c3aed;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.eod-note--lead {
|
||||
margin: 0 0 12px;
|
||||
color: #5b21b6;
|
||||
}
|
||||
|
||||
/* The two dates, stated as a pair with an arrow between them. Spelling out "belongs to X, showing
|
||||
on Y" is the only way a reader can be sure which of the two a figure is counted under — a single
|
||||
date in a heading leaves exactly the ambiguity this block exists to remove. */
|
||||
.eod-datepair {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
margin: 0 0 14px;
|
||||
padding: 12px 14px;
|
||||
background: #fff;
|
||||
border: 1px solid #ddd6fe;
|
||||
border-radius: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.eod-datepair__item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
flex: 1 1 200px;
|
||||
}
|
||||
|
||||
.eod-datepair__label {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: #7c3aed;
|
||||
}
|
||||
|
||||
.eod-datepair__value {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.eod-datepair__hint {
|
||||
font-size: 10.5px;
|
||||
color: #64748b;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.eod-datepair__arrow {
|
||||
font-size: 20px;
|
||||
color: #a78bfa;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
/* Dates in the row are read against the ledger, so they do not shrink into the grey the way a
|
||||
secondary column normally would. */
|
||||
.eod-datecell {
|
||||
font-size: 11.5px;
|
||||
font-weight: 600;
|
||||
color: #5b21b6;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.eod-hint {
|
||||
display: block;
|
||||
font-size: 10.5px;
|
||||
font-weight: 400;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.eod-blank {
|
||||
color: #94a3b8;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* ── Reconciliation table ── */
|
||||
|
||||
/* Two columns only, so the label takes the room and the figure sits in a fixed money column. */
|
||||
.eod-recon {
|
||||
td:first-child {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
td:last-child {
|
||||
width: 190px;
|
||||
}
|
||||
}
|
||||
|
||||
.eod-recon__expected td {
|
||||
border-top: 1px solid #94a3b8;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.eod-recon__variance td {
|
||||
border-top: 2px solid #0f172a;
|
||||
}
|
||||
|
||||
.eod-recon__variance.eod-ok td {
|
||||
background: #f0fdf4;
|
||||
color: #15803d;
|
||||
}
|
||||
|
||||
.eod-recon__variance.eod-bad td {
|
||||
background: #fef2f2;
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
/* ── Verdict line ── */
|
||||
.eod-verdict {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
margin: 12px 0 0;
|
||||
padding: 9px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
line-height: 1.45;
|
||||
|
||||
mat-icon {
|
||||
font-size: 17px;
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
}
|
||||
|
||||
.eod-verdict--ok {
|
||||
background: #f0fdf4;
|
||||
border: 1px solid #86efac;
|
||||
color: #15803d;
|
||||
}
|
||||
|
||||
.eod-verdict--bad {
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fca5a5;
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
/* ── Operational notes ── */
|
||||
.eod-notes {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
|
||||
li {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
font-size: 12.5px;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.eod-notes__label {
|
||||
flex: 0 0 190px;
|
||||
font-weight: 600;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.eod-notes__value {
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.eod-notes--warn {
|
||||
background: #fffbeb;
|
||||
|
||||
.eod-notes__value {
|
||||
color: #b45309;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.eod-notes--ok {
|
||||
background: #f0fdf4;
|
||||
|
||||
.eod-notes__value {
|
||||
color: #15803d;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Signatures ── */
|
||||
.eod-sign {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 40px;
|
||||
margin-top: 46px;
|
||||
break-inside: avoid;
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
|
||||
.eod-sign__block {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.eod-sign__line {
|
||||
border-bottom: 1px solid #0f172a;
|
||||
height: 34px;
|
||||
margin-bottom: 7px;
|
||||
}
|
||||
|
||||
.eod-sign__role {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.eod-sign__name {
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.eod-foot {
|
||||
margin: 26px 0 0;
|
||||
padding-top: 12px;
|
||||
border-top: 1px dashed #cbd5e1;
|
||||
font-size: 10.5px;
|
||||
color: #94a3b8;
|
||||
text-align: center;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ── Print ──
|
||||
The sheet is the document; the shell, the toolbar and the app chrome are not. Colours are forced
|
||||
so the variance verdict survives a printer that would otherwise drop backgrounds. */
|
||||
@media print {
|
||||
.no-print,
|
||||
.pg-head {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.eod-sheet {
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
padding: 0;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.eod-sheet,
|
||||
.eod-recon__variance td,
|
||||
.eod-verdict,
|
||||
.eod-notes--warn,
|
||||
.eod-notes--ok {
|
||||
-webkit-print-color-adjust: exact;
|
||||
print-color-adjust: exact;
|
||||
}
|
||||
|
||||
.eod-sheet__title {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.pg-table {
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,536 @@
|
||||
/**
|
||||
* 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 { CashierService } from '../cashier.service';
|
||||
import { SettingsService } from 'app/settings/settings.service';
|
||||
import { AuthenticationService } from 'app/core/authentication/authentication.service';
|
||||
|
||||
/** One part of the day's work — a section of the day book, or a kind of entry within it. */
|
||||
interface SummaryLine {
|
||||
label: string;
|
||||
credit: number;
|
||||
debit: number;
|
||||
count: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The single sheet a cashier signs the day off with.
|
||||
*
|
||||
* Every other cashier screen answers one question — what is in the drawer, which vouchers are
|
||||
* unverified, what did each person handle. At close of day somebody has to sign one piece of paper
|
||||
* saying the money was counted and it agreed, and until now that meant reading four screens and
|
||||
* copying figures onto a form by hand.
|
||||
*
|
||||
* Nothing here is computed on the server that was not already being computed: the closing report
|
||||
* supplies the drawer legs, the day book supplies the transactions, and the cashier position
|
||||
* supplies what the system believes is held. This screen assembles them in the order a person
|
||||
* verifying the day reads them in, and prints.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-end-of-day-report',
|
||||
templateUrl: './end-of-day-report.component.html',
|
||||
styleUrls: [
|
||||
'../cashier-shared.scss',
|
||||
'./end-of-day-report.component.scss'
|
||||
],
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
MatIconModule
|
||||
]
|
||||
})
|
||||
export class EndOfDayReportComponent implements OnInit {
|
||||
private service = inject(CashierService);
|
||||
private settings = inject(SettingsService);
|
||||
private auth = inject(AuthenticationService);
|
||||
|
||||
date = '';
|
||||
loading = false;
|
||||
loaded = false;
|
||||
|
||||
/** Who is signing, and for which branch — read from the session, never typed in. */
|
||||
preparedBy = '';
|
||||
branchName = '';
|
||||
|
||||
// ── Section 1: transaction summary ──
|
||||
rows: any[] = [];
|
||||
creditTotal = 0;
|
||||
debitTotal = 0;
|
||||
|
||||
// ── Section 2: cash drawer ──
|
||||
/** Drawn from the vault in the morning: a cashier's opening float. */
|
||||
openingFloat = 0;
|
||||
cashDeposits = 0;
|
||||
interCashierIn = 0;
|
||||
cashWithdrawals = 0;
|
||||
settledOut = 0;
|
||||
interCashierOut = 0;
|
||||
/** Whether the server sent this day's own cash legs, which only a cashier-scoped call gets. */
|
||||
hasDayLegs = false;
|
||||
/** Whether the drawer figures are one cashier's or the whole branch's counter work. */
|
||||
scopedToCashier = false;
|
||||
|
||||
/** What the drawers hold between them, per the system. */
|
||||
drawersTotal: number | null = null;
|
||||
|
||||
/**
|
||||
* Each cashier and what their drawer holds.
|
||||
*
|
||||
* A clerk holds no drawer of their own — they supervise the counter — so a single "actual cash"
|
||||
* figure says nothing to them. The position endpoint already answers for whoever is asking: one
|
||||
* drawer for a cashier, every drawer for a clerk or officer, so the same call serves both and the
|
||||
* screen reads differently depending on who opened it.
|
||||
*/
|
||||
cashierPositions: { cashierId: number | null; cashierName: string; totalAmount: number }[] = [];
|
||||
|
||||
/**
|
||||
* The branch's own expected cash, from the day register.
|
||||
*
|
||||
* A branch-wide reading cannot be assembled from the per-cashier day legs: those are only
|
||||
* returned when the call is scoped to one cashier. The branch day register already keeps this
|
||||
* arithmetic for the close-day gate — opening cash, plus approved deposits, less what was
|
||||
* withdrawn — so the sheet reads it rather than inventing a second version of the same sum.
|
||||
*
|
||||
* Null when no day has been opened for this date, which is a real state: there is nothing to
|
||||
* reconcile against on a day the branch never opened.
|
||||
*/
|
||||
branchOpeningCash: number | null = null;
|
||||
branchDeposits: number | null = null;
|
||||
branchWithdrawals: number | null = null;
|
||||
branchExpectedCash: number | null = null;
|
||||
|
||||
/**
|
||||
* What the vaults hold, which a branch-wide count must include.
|
||||
*
|
||||
* The branch register's expected cash covers the whole branch — vault and every drawer together —
|
||||
* because cash moved from the vault to a cashier has not left the branch. Counting only the
|
||||
* drawers against it would report the entire vault as a shortage.
|
||||
*/
|
||||
vaultTotal: number | null = null;
|
||||
|
||||
ngOnInit(): void {
|
||||
this.date = this.businessDate();
|
||||
this.preparedBy = this.currentUserName();
|
||||
this.branchName = this.currentOfficeName();
|
||||
this.load();
|
||||
}
|
||||
|
||||
/**
|
||||
* The date the books are open on, not the wall clock.
|
||||
*
|
||||
* An EOD sheet dated by the browser would be filed against the wrong day whenever the branch is
|
||||
* still working yesterday's business date, which is exactly when a day is being closed.
|
||||
*/
|
||||
private businessDate(): string {
|
||||
const d = this.settings.businessDate;
|
||||
const safe = d instanceof Date && !isNaN(d.getTime()) ? d : new Date();
|
||||
return `${safe.getFullYear()}-${String(safe.getMonth() + 1).padStart(2, '0')}-${String(safe.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/** The member of staff behind the login, falling back to the username when no staff is linked. */
|
||||
private currentUserName(): string {
|
||||
const c = this.auth.getCredentials();
|
||||
return c ? c.staffDisplayName || c.username || '' : '';
|
||||
}
|
||||
|
||||
private currentOfficeName(): string {
|
||||
return this.auth.getCredentials()?.officeName || '';
|
||||
}
|
||||
|
||||
load(): void {
|
||||
this.loading = true;
|
||||
this.loaded = false;
|
||||
|
||||
this.service.dayBook(this.date).subscribe({
|
||||
next: (res: any) => {
|
||||
this.rows = res?.rows || [];
|
||||
this.creditTotal = Number(res?.creditTotal || 0);
|
||||
this.debitTotal = Number(res?.debitTotal || 0);
|
||||
this.loading = false;
|
||||
this.loaded = true;
|
||||
},
|
||||
error: () => {
|
||||
this.rows = [];
|
||||
this.creditTotal = 0;
|
||||
this.debitTotal = 0;
|
||||
this.loading = false;
|
||||
this.loaded = true;
|
||||
}
|
||||
});
|
||||
|
||||
this.service.closingCashReport(this.date).subscribe({
|
||||
next: (res: any) => {
|
||||
// The four day legs are only sent for a cashier-scoped call. Read as absent rather than
|
||||
// zero: a leg that is missing and a leg that is genuinely nil mean different things, and
|
||||
// treating the first as the second is what let the sheet claim an expected balance of zero
|
||||
// for a whole branch.
|
||||
this.hasDayLegs = res?.todayVaultIn != null;
|
||||
this.openingFloat = Number(res?.todayVaultIn || 0);
|
||||
this.cashDeposits = Number(res?.depositTotalWithDd ?? res?.depositTotal ?? 0);
|
||||
this.cashWithdrawals = Number(res?.withdrawalTotal || 0);
|
||||
this.settledOut = Number(res?.todaySettledOut || 0);
|
||||
this.interCashierIn = Number(res?.todayInterCashierIn || 0);
|
||||
this.interCashierOut = Number(res?.todayInterCashierOut || 0);
|
||||
this.scopedToCashier = res?.scopedToCashier === true;
|
||||
},
|
||||
error: () => {
|
||||
this.openingFloat = 0;
|
||||
this.cashDeposits = 0;
|
||||
this.cashWithdrawals = 0;
|
||||
this.settledOut = 0;
|
||||
this.interCashierIn = 0;
|
||||
this.interCashierOut = 0;
|
||||
this.scopedToCashier = false;
|
||||
this.hasDayLegs = false;
|
||||
}
|
||||
});
|
||||
|
||||
// The branch's expected cash for the date, which is the only basis a branch-wide reading can
|
||||
// reconcile on. Fails quietly for a plain cashier, who is not allowed to operate the day.
|
||||
this.service.branchDayClosePreview(this.date).subscribe({
|
||||
next: (res: any) => {
|
||||
this.branchOpeningCash = res?.openingCash == null ? null : Number(res.openingCash);
|
||||
this.branchDeposits = res?.deposits == null ? null : Number(res.deposits);
|
||||
this.branchWithdrawals = res?.withdrawals == null ? null : Number(res.withdrawals);
|
||||
this.branchExpectedCash = res?.expectedCash == null ? null : Number(res.expectedCash);
|
||||
},
|
||||
error: () => {
|
||||
this.branchOpeningCash = null;
|
||||
this.branchDeposits = null;
|
||||
this.branchWithdrawals = null;
|
||||
this.branchExpectedCash = null;
|
||||
}
|
||||
});
|
||||
|
||||
// One object for a cashier, an array of every cashier for a clerk or officer — the server
|
||||
// decides from the caller's own roles, so this screen does not have to ask who is reading it.
|
||||
this.service.cashierPositionReport(undefined, this.date).subscribe({
|
||||
next: (res: any) => {
|
||||
const list = Array.isArray(res) ? res : res ? [res] : [];
|
||||
this.cashierPositions = list
|
||||
.filter((p: any) => p && p.cashierId != null)
|
||||
.map((p: any) => ({
|
||||
cashierId: p.cashierId == null ? null : Number(p.cashierId),
|
||||
cashierName: p.cashierName || `Cashier ${p.cashierId}`,
|
||||
totalAmount: Number(p.totalAmount || 0)
|
||||
}))
|
||||
.sort((a: any, b: any) => b.totalAmount - a.totalAmount);
|
||||
this.drawersTotal = this.cashierPositions.length
|
||||
? this.cashierPositions.reduce((s, p) => s + p.totalAmount, 0)
|
||||
: null;
|
||||
},
|
||||
error: () => {
|
||||
// Nothing to count against, so the variance line stays blank rather than reading zero — a
|
||||
// variance of zero is a claim that somebody counted and it agreed.
|
||||
this.cashierPositions = [];
|
||||
this.drawersTotal = null;
|
||||
}
|
||||
});
|
||||
|
||||
// Vault stock, so a branch-wide count is of the whole branch and not just the counters.
|
||||
this.service.vaultPosition(undefined, this.date).subscribe({
|
||||
next: (res: any) => {
|
||||
const list = Array.isArray(res) ? res : res ? [res] : [];
|
||||
this.vaultTotal = list.reduce((s: number, v: any) => s + Number(v?.totalAmount || 0), 0);
|
||||
},
|
||||
error: () => {
|
||||
this.vaultTotal = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Section 1 ──
|
||||
|
||||
/**
|
||||
* Only the entries that actually moved money.
|
||||
*
|
||||
* A pending request is listed on the day book so nobody loses sight of it, but it has taken no
|
||||
* cash — counting it into an EOD summary would make the sheet disagree with the drawer.
|
||||
*/
|
||||
/**
|
||||
* Last night's batch: posted against yesterday, surfacing on this report.
|
||||
*
|
||||
* A voucher's timestamp is the minute the job ran, while the transaction and the journal entry
|
||||
* behind it carry the business date. So the interest run that fires just after midnight stamps
|
||||
* itself with today although the ledger says yesterday — and yesterday was counted, reported and
|
||||
* signed off hours before. Re-opening that day would invalidate a signed sheet, so the work is
|
||||
* listed here instead, under its own heading, saying which day it belongs to. It is excluded from
|
||||
* every total on this page: it is not today's money.
|
||||
*/
|
||||
get priorDayRows(): any[] {
|
||||
return this.rows.filter((r) => r.section === 'SYSTEM_PRIOR');
|
||||
}
|
||||
|
||||
get priorDayCredit(): number {
|
||||
return this.priorDayRows.reduce((s, r) => s + (r.side === 'CR' ? Number(r.amount || 0) : 0), 0);
|
||||
}
|
||||
|
||||
get priorDayDebit(): number {
|
||||
return this.priorDayRows.reduce((s, r) => s + (r.side === 'DR' ? Number(r.amount || 0) : 0), 0);
|
||||
}
|
||||
|
||||
/** The business date last night's batch worked on, for the heading. */
|
||||
get priorDayDate(): string | null {
|
||||
const withDate = this.priorDayRows.find((r) => r.effectiveDate);
|
||||
return withDate ? withDate.effectiveDate : null;
|
||||
}
|
||||
|
||||
get approvedRows(): any[] {
|
||||
return this.rows.filter(
|
||||
(r) =>
|
||||
r.section !== 'SYSTEM_PRIOR' &&
|
||||
(r.status === 'APPROVED' || r.status === 'COMPLETED' || r.status === 'WITHDRAWN')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The day split the way this system actually records it: where the entry came from.
|
||||
*
|
||||
* Not split by payment mode, which is the obvious shape for a retail till but the wrong one here.
|
||||
* A payment type is only recorded against a denomination breakdown, and a breakdown is only
|
||||
* written for physical cash — so a UPI or transfer deposit carries no payment type at all, and a
|
||||
* mode-wise table would file every non-cash entry under "not specified" while claiming to be a
|
||||
* breakdown by mode. The day book's own sections are recorded on every row, so this agrees with
|
||||
* the day book for the same date instead of quietly disagreeing with it.
|
||||
*/
|
||||
get sectionLines(): SummaryLine[] {
|
||||
const order = [
|
||||
'CASHIER',
|
||||
'CLERK',
|
||||
'SYSTEM'
|
||||
];
|
||||
const labels: Record<string, string> = {
|
||||
CASHIER: 'Cash counter',
|
||||
CLERK: 'Clerk desk',
|
||||
SYSTEM: 'System — interest, charges, journals'
|
||||
};
|
||||
const map = new Map<string, SummaryLine>();
|
||||
for (const r of this.approvedRows) {
|
||||
const key = order.includes(r.section) ? r.section : 'SYSTEM';
|
||||
let line = map.get(key);
|
||||
if (!line) {
|
||||
line = { label: labels[key] || key, credit: 0, debit: 0, count: 0 };
|
||||
map.set(key, line);
|
||||
}
|
||||
this.addTo(line, r);
|
||||
}
|
||||
return order.filter((k) => map.has(k)).map((k) => map.get(k)!);
|
||||
}
|
||||
|
||||
/**
|
||||
* What kind of entry it was — deposit, withdrawal, interest posting, charge.
|
||||
*
|
||||
* The sections above say where the day's work came from; this says what the work was, which is
|
||||
* the question somebody checking a figure against a register actually asks.
|
||||
*/
|
||||
get kindLines(): SummaryLine[] {
|
||||
const map = new Map<string, SummaryLine>();
|
||||
for (const r of this.approvedRows) {
|
||||
const key = (r.kind || 'OTHER').toString();
|
||||
let line = map.get(key);
|
||||
if (!line) {
|
||||
line = { label: this.titleCase(key), credit: 0, debit: 0, count: 0 };
|
||||
map.set(key, line);
|
||||
}
|
||||
this.addTo(line, r);
|
||||
}
|
||||
return Array.from(map.values()).sort((a, b) => b.credit + b.debit - (a.credit + a.debit));
|
||||
}
|
||||
|
||||
/** Adds one day-book row onto a summary line, on the side the row belongs to. */
|
||||
private addTo(line: SummaryLine, row: any): void {
|
||||
const amt = Number(row.amount || 0);
|
||||
if (row.side === 'CR') {
|
||||
line.credit += amt;
|
||||
} else {
|
||||
line.debit += amt;
|
||||
}
|
||||
line.count += 1;
|
||||
}
|
||||
|
||||
/** DD CASH, INTEREST_POSTING and the like, as a person would read them. */
|
||||
kindLabel(raw: string | null | undefined): string {
|
||||
if (!raw) {
|
||||
return '—';
|
||||
}
|
||||
return raw
|
||||
.toString()
|
||||
.replace(/_/g, ' ')
|
||||
.toLowerCase()
|
||||
.replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
}
|
||||
|
||||
private titleCase(raw: string): string {
|
||||
return this.kindLabel(raw);
|
||||
}
|
||||
|
||||
get txnCount(): number {
|
||||
return this.approvedRows.length;
|
||||
}
|
||||
|
||||
/** More than one drawer in view means a supervisor is reading, not a cashier. */
|
||||
get supervising(): boolean {
|
||||
return this.cashierPositions.length > 1 || (!this.scopedToCashier && this.cashierPositions.length > 0);
|
||||
}
|
||||
|
||||
get cashierPositionTotal(): number {
|
||||
return this.cashierPositions.reduce((s, p) => s + p.totalAmount, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Each person who handled the day's work, with their own credit, debit and net.
|
||||
*
|
||||
* The clerk signing the day off is verifying other people's work, so the sheet has to say whose.
|
||||
* Read off the day book's own handledBy, which every register stamps.
|
||||
*/
|
||||
get byUser(): SummaryLine[] {
|
||||
const map = new Map<string, SummaryLine>();
|
||||
for (const r of this.approvedRows) {
|
||||
const key = (r.handledBy || 'Unattributed').toString();
|
||||
let line = map.get(key);
|
||||
if (!line) {
|
||||
line = { label: key, credit: 0, debit: 0, count: 0 };
|
||||
map.set(key, line);
|
||||
}
|
||||
this.addTo(line, r);
|
||||
}
|
||||
return Array.from(map.values()).sort((a, b) => b.credit + b.debit - (a.credit + a.debit));
|
||||
}
|
||||
|
||||
get summaryCreditTotal(): number {
|
||||
return this.sectionLines.reduce((s, l) => s + l.credit, 0);
|
||||
}
|
||||
|
||||
get summaryDebitTotal(): number {
|
||||
return this.sectionLines.reduce((s, l) => s + l.debit, 0);
|
||||
}
|
||||
|
||||
// ── Section 2 ──
|
||||
|
||||
/** Everything that came into the drawer today, over the counter and from another cashier. */
|
||||
get totalReceived(): number {
|
||||
return this.cashDeposits + this.interCashierIn;
|
||||
}
|
||||
|
||||
/** Everything that left it: paid to members, handed to the vault, passed to another cashier. */
|
||||
get totalPaidOut(): number {
|
||||
return this.cashWithdrawals + this.settledOut + this.interCashierOut;
|
||||
}
|
||||
|
||||
/** What should be on the desk: the float, plus what came in, less what went out. */
|
||||
get expectedClosing(): number {
|
||||
return this.openingFloat + this.totalReceived - this.totalPaidOut;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an expected-against-held comparison can honestly be made.
|
||||
*
|
||||
* Only for a single cashier, and only when the server sent that cashier's day legs. Both halves
|
||||
* then describe the same thing: today's legs, against a drawer that carries nothing forward
|
||||
* because a cashier draws in the morning and hands back at close.
|
||||
*
|
||||
* Never branch-wide. The branch register's expected cash is *today's* arithmetic — yesterday's
|
||||
* closing, plus today's deposits, less today's withdrawals — while the vault and drawer positions
|
||||
* are running totals accumulated since the system went live. Subtracting one from the other
|
||||
* compares an afternoon against a lifetime: it reported ₹2,11,932 extra on a day when ₹100 came
|
||||
* in and ₹100 went out. There is no branch-wide running total to compare against, so the sheet
|
||||
* shows the branch its movement and each drawer's position, and states no difference at all
|
||||
* rather than a confident wrong one.
|
||||
*/
|
||||
get canReconcile(): boolean {
|
||||
return this.scopedToCashier && this.hasDayLegs;
|
||||
}
|
||||
|
||||
/** The cash the system believes this cashier is holding. */
|
||||
get actualCash(): number | null {
|
||||
return this.scopedToCashier ? this.drawersTotal : null;
|
||||
}
|
||||
|
||||
/** What today's legs say should be in the drawer. */
|
||||
get expectedCash(): number | null {
|
||||
return this.canReconcile ? this.expectedClosing : null;
|
||||
}
|
||||
|
||||
/** Held less expected, for one cashier's own day. Null wherever that comparison is not honest. */
|
||||
get variance(): number | null {
|
||||
const expected = this.expectedCash;
|
||||
if (expected === null || this.actualCash === null) {
|
||||
return null;
|
||||
}
|
||||
return this.actualCash - expected;
|
||||
}
|
||||
|
||||
/** Vaults and drawers together — what the branch is holding, per the system. */
|
||||
get branchHeldTotal(): number | null {
|
||||
if (this.drawersTotal === null && this.vaultTotal === null) {
|
||||
return null;
|
||||
}
|
||||
return Number(this.drawersTotal ?? 0) + Number(this.vaultTotal ?? 0);
|
||||
}
|
||||
|
||||
/** Today's net cash movement across the branch: what came in less what went out. */
|
||||
get branchNetMovement(): number {
|
||||
return Number(this.branchDeposits ?? 0) - Number(this.branchWithdrawals ?? 0);
|
||||
}
|
||||
|
||||
/** True only when a count exists and it agrees to the paisa. */
|
||||
get varianceOk(): boolean {
|
||||
return this.variance !== null && Math.abs(this.variance) < 0.005;
|
||||
}
|
||||
|
||||
get varianceClass(): string {
|
||||
if (this.variance === null) {
|
||||
return '';
|
||||
}
|
||||
return this.varianceOk ? 'eod-ok' : 'eod-bad';
|
||||
}
|
||||
|
||||
// ── Section 3 ──
|
||||
|
||||
/** Entries still waiting on somebody, which is what a day cannot be closed over. */
|
||||
get pendingRows(): any[] {
|
||||
return this.rows.filter((r) => r.status === 'PENDING' || r.status === 'SUBMITTED');
|
||||
}
|
||||
|
||||
/** Cash entries whose paper slip nobody has checked against the screen. */
|
||||
get unverifiedRows(): any[] {
|
||||
return this.approvedRows.filter((r) => !r.verifyStatus);
|
||||
}
|
||||
|
||||
/** A slip that was checked and did not agree with what was keyed in. */
|
||||
get mismatchRows(): any[] {
|
||||
return this.rows.filter((r) => r.verifyStatus === 'MISMATCH' || r.verifyStatus === 'DISPUTED');
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the day is ready to close.
|
||||
*
|
||||
* Nothing pending, no slip disagreeing, and — where a cash comparison was actually made — the
|
||||
* cash agreeing. A branch-wide reading makes no comparison, so it is not held to one: requiring
|
||||
* `varianceOk` there meant the sheet could never say a day was clear however clean it was.
|
||||
*/
|
||||
get allClear(): boolean {
|
||||
if (this.pendingRows.length > 0 || this.mismatchRows.length > 0) {
|
||||
return false;
|
||||
}
|
||||
return this.variance === null || this.varianceOk;
|
||||
}
|
||||
|
||||
reload(): void {
|
||||
this.load();
|
||||
}
|
||||
|
||||
print(): void {
|
||||
window.print();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
<!--
|
||||
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">MY WORK</div>
|
||||
<h1 class="pg-title">My Vouchers</h1>
|
||||
<p class="pg-sub">
|
||||
Everything you entered and everything you approved — receipts, payments and journals in one list.
|
||||
</p>
|
||||
</div>
|
||||
<div class="pg-toolbar">
|
||||
<label class="pg-field">
|
||||
<span>Period</span>
|
||||
<select [(ngModel)]="period" (change)="load()">
|
||||
<option value="DAY">Day</option>
|
||||
<option value="MONTH">Month</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="pg-field">
|
||||
<span>{{ period === 'DAY' ? 'Date' : 'Month of' }}</span>
|
||||
<input type="date" [(ngModel)]="anchor" (change)="load()" />
|
||||
</label>
|
||||
<button type="button" class="pg-btn pg-btn--primary" (click)="load()" [disabled]="loading">
|
||||
<mat-icon>refresh</mat-icon> Run
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@if (error) {
|
||||
<div class="pg-alert pg-alert--error">{{ error }}</div>
|
||||
}
|
||||
|
||||
@if (loading) {
|
||||
<div class="pg-empty">Loading…</div>
|
||||
}
|
||||
|
||||
@if (report && !loading) {
|
||||
<div class="mw-who">
|
||||
<strong>{{ report.displayName || report.username }}</strong>
|
||||
<span class="mw-who__sep">·</span>
|
||||
{{ report.from | date: 'dd MMM yyyy' }}
|
||||
@if (report.from !== report.to) {
|
||||
<span> to {{ report.to | date: 'dd MMM yyyy' }}</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- The day at a glance. Received and Payment are the two sides a branch balances; Journal is
|
||||
everything that moved between accounts without cash crossing the counter. -->
|
||||
<div class="mw-tiles">
|
||||
<div class="mw-tile">
|
||||
<div class="mw-tile__label">Received</div>
|
||||
<div class="mw-tile__value mw-tile__value--cr">₹{{ report.receivedTotal | number: '1.2-2' }}</div>
|
||||
</div>
|
||||
<div class="mw-tile">
|
||||
<div class="mw-tile__label">Payments</div>
|
||||
<div class="mw-tile__value mw-tile__value--dr">₹{{ report.paymentTotal | number: '1.2-2' }}</div>
|
||||
</div>
|
||||
<div class="mw-tile">
|
||||
<div class="mw-tile__label">Journal</div>
|
||||
<div class="mw-tile__value">₹{{ report.journalTotal | number: '1.2-2' }}</div>
|
||||
</div>
|
||||
<div class="mw-tile">
|
||||
<div class="mw-tile__label">Entered</div>
|
||||
<div class="mw-tile__value">{{ report.raisedCount }}</div>
|
||||
</div>
|
||||
<div class="mw-tile">
|
||||
<div class="mw-tile__label">Approved</div>
|
||||
<div class="mw-tile__value">{{ report.verifiedCount }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (report.count > 0) {
|
||||
<div class="mw-filters">
|
||||
<label class="pg-field">
|
||||
<span>Side</span>
|
||||
<select [(ngModel)]="sideFilter">
|
||||
<option value="">All</option>
|
||||
<option value="CR">Received</option>
|
||||
<option value="DR">Payment</option>
|
||||
<option value="JV">Journal</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="pg-field">
|
||||
<span>My part</span>
|
||||
<select [(ngModel)]="roleFilter">
|
||||
<option value="">All</option>
|
||||
<option value="RAISED">Entered</option>
|
||||
<option value="VERIFIED">Approved</option>
|
||||
<option value="RAISED_AND_VERIFIED">Entered & approved</option>
|
||||
</select>
|
||||
</label>
|
||||
@if (isFiltered) {
|
||||
<button type="button" class="pg-btn" (click)="clearFilters()"><mat-icon>clear</mat-icon> Clear</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (rows.length === 0) {
|
||||
<div class="pg-empty">
|
||||
@if (report.count === 0) {
|
||||
Nothing recorded against you for this period.
|
||||
} @else {
|
||||
No vouchers match these filters — {{ report.count }} hidden.
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<div class="mw-count">
|
||||
Showing {{ rows.length }}
|
||||
@if (isFiltered) {
|
||||
<span>of {{ report.count }}</span>
|
||||
}
|
||||
{{ rows.length === 1 ? 'voucher' : 'vouchers' }}
|
||||
<span class="mw-count__total">Total ₹{{ filteredTotal | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
|
||||
<!-- One table per part played, not one long list. Entering a request and passing somebody
|
||||
else's are different jobs answerable to different people, and each carries its own total
|
||||
so a maker's tally and a checker's can both be read off the page. -->
|
||||
@for (g of groups; track g.key) {
|
||||
<div class="pg-card mw-group" [class]="'mw-group--' + g.key">
|
||||
<div class="mw-group__head">
|
||||
<strong>{{ g.label }}</strong>
|
||||
<span class="mw-group__hint">{{ g.hint }}</span>
|
||||
<span class="mw-group__count">
|
||||
{{ g.rows.length }} {{ g.rows.length === 1 ? 'voucher' : 'vouchers' }}
|
||||
<span class="mw-group__total">₹{{ g.total | number: '1.2-2' }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="pg-table-wrap">
|
||||
<table class="pg-table mw-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Voucher</th>
|
||||
<th>Side</th>
|
||||
<th>Type</th>
|
||||
<th>Debit</th>
|
||||
<th>Credit</th>
|
||||
<th class="num">Amount</th>
|
||||
<th>My part</th>
|
||||
<th>Status</th>
|
||||
<th>Time</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (r of g.rows; track r.voucherNo) {
|
||||
<!-- The left edge repeats the section's own colour, so a row still says which part it
|
||||
is when the heading has scrolled away. -->
|
||||
<tr [class]="'mw-row mw-row--' + r.myRole">
|
||||
<td class="mw-vno">{{ r.voucherNo }}</td>
|
||||
<td>
|
||||
<span class="mw-badge" [class]="'mw-badge--' + (r.side || 'JV')">{{ sideLabel(r.side) }}</span>
|
||||
</td>
|
||||
<td class="mw-type">{{ r.voucherType }}</td>
|
||||
<td>
|
||||
{{ r.debitParty || '—' }}
|
||||
@if (r.debitAccount) {
|
||||
<span class="mw-acct">{{ r.debitAccount }}</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
{{ r.creditParty || '—' }}
|
||||
@if (r.creditAccount) {
|
||||
<span class="mw-acct">{{ r.creditAccount }}</span>
|
||||
}
|
||||
@if (r.extraLines > 0) {
|
||||
<span class="mw-more">+{{ r.extraLines }} more</span>
|
||||
}
|
||||
</td>
|
||||
<td class="num">₹{{ r.amount | number: '1.2-2' }}</td>
|
||||
<td>
|
||||
<span class="mw-part" [class]="'mw-part--' + r.myRole">
|
||||
<mat-icon class="mw-part__icon">{{ roleIcon(r.myRole) }}</mat-icon>
|
||||
{{ roleLabel(r.myRole) }}
|
||||
</span>
|
||||
<!-- Whose entry it was, when it was not this user's — that is the whole point of
|
||||
an approval row appearing here at all. -->
|
||||
@if (r.myRole === 'VERIFIED' && r.raisedBy) {
|
||||
<span class="mw-acct">by {{ r.raisedBy }}</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<span class="mw-status" [class]="'mw-status--' + statusClass(r.verificationStatus)">
|
||||
{{ statusLabel(r.verificationStatus) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="mw-time">{{ r.createdAt | date: 'HH:mm' }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,294 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
/* Who and when, above the day's figures. */
|
||||
.mw-who {
|
||||
font-size: 13px;
|
||||
color: #475569;
|
||||
margin: 0 0 12px;
|
||||
|
||||
strong {
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
&__sep {
|
||||
margin: 0 6px;
|
||||
color: #cbd5e1;
|
||||
}
|
||||
}
|
||||
|
||||
/* The day at a glance: the two sides a branch balances, then what the user's part was. */
|
||||
.mw-tiles {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
gap: 10px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.mw-tile {
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 6px;
|
||||
padding: 10px 12px;
|
||||
|
||||
&__label {
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: #64748b;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
&__value {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #0f172a;
|
||||
|
||||
/* Received green, paid-out red — the convention a passbook already uses, so the two totals
|
||||
are told apart without reading the labels. */
|
||||
&--cr {
|
||||
color: #15803d;
|
||||
}
|
||||
|
||||
&--dr {
|
||||
color: #b91c1c;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.mw-filters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.mw-count {
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
margin-bottom: 8px;
|
||||
|
||||
&__total {
|
||||
float: right;
|
||||
font-weight: 600;
|
||||
color: #0f172a;
|
||||
}
|
||||
}
|
||||
|
||||
.mw-table {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* Money cells use the shared `.num` from cashier-shared.scss, which right-aligns and
|
||||
tabular-figures the heading as well as the cell. A local class only reached the cells, leaving
|
||||
every heading left-aligned over right-aligned figures. */
|
||||
|
||||
.mw-vno {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mw-type {
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
color: #64748b;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mw-time {
|
||||
white-space: nowrap;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
/* The account number or the maker's name, under the party it belongs to. */
|
||||
.mw-acct {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
color: #94a3b8;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
}
|
||||
|
||||
.mw-more {
|
||||
display: inline-block;
|
||||
margin-left: 6px;
|
||||
font-size: 10px;
|
||||
padding: 1px 5px;
|
||||
border-radius: 8px;
|
||||
background: #f1f5f9;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.mw-badge {
|
||||
display: inline-block;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
padding: 2px 7px;
|
||||
border-radius: 10px;
|
||||
white-space: nowrap;
|
||||
|
||||
&--CR {
|
||||
background: #dcfce7;
|
||||
color: #15803d;
|
||||
}
|
||||
|
||||
&--DR {
|
||||
background: #fee2e2;
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
&--JV {
|
||||
background: #e0e7ff;
|
||||
color: #4338ca;
|
||||
}
|
||||
}
|
||||
|
||||
/* Whose work the row is, marked on the row itself.
|
||||
A coloured left edge and a faint tint, rather than a different text colour: the eye finds the
|
||||
edge while scanning a column of figures, which is how a day's list is actually read. Kept faint
|
||||
so the amounts stay the thing being read. */
|
||||
.mw-row {
|
||||
border-left: 3px solid transparent;
|
||||
|
||||
&--RAISED {
|
||||
border-left-color: #86efac;
|
||||
}
|
||||
|
||||
/* Somebody else's entry that this person passed. The row a reviewer came looking for, so it
|
||||
carries the darker of the two greens. */
|
||||
&--VERIFIED {
|
||||
border-left-color: #15803d;
|
||||
background: #f0fdf4;
|
||||
}
|
||||
|
||||
/* Entered and approved by the same person. Legitimate in a small branch, but the one combination
|
||||
an auditor stops on, so it is the only row given a warm tint. */
|
||||
&--RAISED_AND_VERIFIED {
|
||||
border-left-color: #d97706;
|
||||
background: #fffbf3;
|
||||
}
|
||||
}
|
||||
|
||||
/* One section per part played. The heading carries the section's own count and total, so a maker's
|
||||
tally and a checker's are each readable without adding rows up. */
|
||||
.mw-group {
|
||||
margin-bottom: 16px;
|
||||
|
||||
/* Two shades of one colour rather than two colours: entering and approving are stages of the
|
||||
same piece of work, and the darker edge reads as the later one. */
|
||||
border-left: 3px solid #86efac;
|
||||
|
||||
/* Same shades the row edges use, so a section and its rows read as one thing. */
|
||||
&--VERIFIED {
|
||||
border-left-color: #15803d;
|
||||
}
|
||||
|
||||
&--RAISED_AND_VERIFIED {
|
||||
border-left-color: #d97706;
|
||||
}
|
||||
|
||||
&__head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
background: #f8fafc;
|
||||
|
||||
strong {
|
||||
font-size: 13px;
|
||||
color: #0f172a;
|
||||
}
|
||||
}
|
||||
|
||||
&__hint {
|
||||
font-size: 11px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
&__count {
|
||||
margin-left: auto;
|
||||
font-size: 11px;
|
||||
color: #64748b;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__total {
|
||||
margin-left: 8px;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
}
|
||||
}
|
||||
|
||||
/* Where the voucher stands. Three states, because there are three things a reader does: nothing
|
||||
(done), chase it (pending), or look into it (bad). */
|
||||
.mw-status {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
|
||||
/* Approved, posted — settled but nothing handed over. Lighter than 'paid' but still dark enough
|
||||
to read as body text; a pale green on white fails at this size. */
|
||||
&--done {
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
/* Cash actually gone. Darker and filled, the same way the day book marks it. */
|
||||
&--paid {
|
||||
background: #15803d;
|
||||
color: #fff;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
&--pending {
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
&--bad {
|
||||
color: #b91c1c;
|
||||
}
|
||||
}
|
||||
|
||||
/* The part played, as a small pill with its own glyph. */
|
||||
.mw-part {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
padding: 2px 8px 2px 6px;
|
||||
border-radius: 10px;
|
||||
white-space: nowrap;
|
||||
|
||||
&__icon {
|
||||
font-size: 13px;
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
line-height: 13px;
|
||||
}
|
||||
|
||||
&--RAISED {
|
||||
background: #dcfce7;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
&--VERIFIED {
|
||||
background: #15803d;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
&--RAISED_AND_VERIFIED {
|
||||
background: #fef3c7;
|
||||
color: #b45309;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
/**
|
||||
* 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';
|
||||
|
||||
/** What the signed-in user's part in a voucher was. */
|
||||
export type MyRole = 'RAISED' | 'VERIFIED' | 'RAISED_AND_VERIFIED';
|
||||
|
||||
/** Which register the voucher was numbered in: receipt, payment, or journal. */
|
||||
export type VoucherSide = 'CR' | 'DR' | 'JV';
|
||||
|
||||
interface MyWorkRow {
|
||||
voucherNo: string;
|
||||
side: VoucherSide | null;
|
||||
voucherType: string | null;
|
||||
amount: number | null;
|
||||
createdAt: string | null;
|
||||
raisedBy: string | null;
|
||||
branchName: string | null;
|
||||
verificationStatus: string | null;
|
||||
verifiedByName: string | null;
|
||||
verifiedAt: string | null;
|
||||
debitParty: string | null;
|
||||
debitAccount: string | null;
|
||||
creditParty: string | null;
|
||||
creditAccount: string | null;
|
||||
/** Sides beyond the one debit and one credit shown — a deposit that also took a charge. */
|
||||
extraLines: number;
|
||||
myRole: MyRole;
|
||||
}
|
||||
|
||||
interface MyWorkReport {
|
||||
from: string;
|
||||
to: string;
|
||||
username: string;
|
||||
displayName: string;
|
||||
vouchers: MyWorkRow[];
|
||||
count: number;
|
||||
raisedCount: number;
|
||||
verifiedCount: number;
|
||||
receivedTotal: number;
|
||||
paymentTotal: number;
|
||||
journalTotal: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* One person's day at the branch: what they entered and what they approved, in one list.
|
||||
*
|
||||
* The Received and Payment registers answer "what moved today" across everyone. This answers the
|
||||
* question each member of staff asks at a close — "what did I do today" — which those two cannot,
|
||||
* because a checker's approval raises no voucher of its own and leaves its only trace on the
|
||||
* verification row.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-my-work',
|
||||
templateUrl: './my-work.component.html',
|
||||
styleUrls: [
|
||||
'../cashier-shared.scss',
|
||||
'./my-work.component.scss'
|
||||
],
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
MatIconModule
|
||||
]
|
||||
})
|
||||
export class MyWorkComponent implements OnInit {
|
||||
private service = inject(CashierService);
|
||||
|
||||
/** Day is the working default; a month is what a close or an audit asks for. */
|
||||
period: 'DAY' | 'MONTH' = 'DAY';
|
||||
anchor = this.todayIso();
|
||||
|
||||
/** Narrow to one side, or to the part the user played. Both applied in the browser. */
|
||||
sideFilter: '' | VoucherSide = '';
|
||||
roleFilter: '' | MyRole = '';
|
||||
|
||||
report: MyWorkReport | null = null;
|
||||
loading = false;
|
||||
error: string | null = null;
|
||||
|
||||
ngOnInit(): void {
|
||||
this.load();
|
||||
}
|
||||
|
||||
load(): void {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
const { from, to } = this.range();
|
||||
this.service
|
||||
.myVoucherWork(from, to)
|
||||
.pipe(
|
||||
catchError((err: any) => {
|
||||
this.error =
|
||||
err?.error?.errors?.[0]?.defaultUserMessage ??
|
||||
err?.error?.developerMessage ??
|
||||
'Your work for this period could not be loaded.';
|
||||
return of(null);
|
||||
})
|
||||
)
|
||||
.subscribe((res: MyWorkReport | null) => {
|
||||
this.loading = false;
|
||||
this.report = res;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The rows after the two on-screen filters.
|
||||
*
|
||||
* Filtered here rather than at the server: the day's own work is a small list, and narrowing it
|
||||
* without a round trip is what makes the filters feel like part of reading the page.
|
||||
*/
|
||||
get rows(): MyWorkRow[] {
|
||||
const all = this.report?.vouchers ?? [];
|
||||
return all.filter(
|
||||
(r) => (!this.sideFilter || r.side === this.sideFilter) && (!this.roleFilter || r.myRole === this.roleFilter)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The rows split by the part the user played, each group with its own total.
|
||||
*
|
||||
* Entering a request and passing somebody else's are different jobs answerable to different
|
||||
* people, and a branch reads them separately: "what did I put through" is a maker's own tally,
|
||||
* "what did I let through" is a checker's. One list mixed them, so neither total could be read
|
||||
* off the page — a manager who entered one voucher and approved twenty saw twenty-one rows and
|
||||
* had to sort them by eye.
|
||||
*
|
||||
* An empty group is dropped rather than shown as a heading over nothing, so a clerk who never
|
||||
* approves anything sees one section and not a permanently empty second one.
|
||||
*/
|
||||
get groups(): { key: MyRole; label: string; hint: string; rows: MyWorkRow[]; total: number }[] {
|
||||
const order: { key: MyRole; label: string; hint: string }[] = [
|
||||
{ key: 'RAISED', label: 'Entered by me', hint: 'Requests and postings you put through' },
|
||||
{ key: 'VERIFIED', label: 'Approved by me', hint: "Other people's work you passed" },
|
||||
{
|
||||
key: 'RAISED_AND_VERIFIED',
|
||||
label: 'Entered & approved by me',
|
||||
hint: 'Both roles on the same voucher — kept separate for the record'
|
||||
}
|
||||
];
|
||||
return order
|
||||
.map((g) => {
|
||||
const rows = this.rows.filter((r) => r.myRole === g.key);
|
||||
return { ...g, rows, total: rows.reduce((sum, r) => sum + Number(r.amount ?? 0), 0) };
|
||||
})
|
||||
.filter((g) => g.rows.length > 0);
|
||||
}
|
||||
|
||||
/** True when a filter is hiding part of what was loaded, so the count can say so. */
|
||||
get isFiltered(): boolean {
|
||||
return !!this.sideFilter || !!this.roleFilter;
|
||||
}
|
||||
|
||||
clearFilters(): void {
|
||||
this.sideFilter = '';
|
||||
this.roleFilter = '';
|
||||
}
|
||||
|
||||
/** The total of the rows on screen, which is not the day's total once a filter is on. */
|
||||
get filteredTotal(): number {
|
||||
return this.rows.reduce((sum, r) => sum + Number(r.amount ?? 0), 0);
|
||||
}
|
||||
|
||||
sideLabel(side: VoucherSide | null): string {
|
||||
if (side === 'CR') {
|
||||
return 'Received';
|
||||
}
|
||||
if (side === 'DR') {
|
||||
return 'Payment';
|
||||
}
|
||||
return 'Journal';
|
||||
}
|
||||
|
||||
roleLabel(role: MyRole): string {
|
||||
if (role === 'RAISED') {
|
||||
return 'Entered';
|
||||
}
|
||||
if (role === 'VERIFIED') {
|
||||
return 'Approved';
|
||||
}
|
||||
return 'Entered & approved';
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the voucher stands, in words a branch uses.
|
||||
*
|
||||
* A voucher with no status at all is not unknown — it is a posting that never went through a
|
||||
* request: interest the nightly run credited, a charge taken from a balance. Those are done the
|
||||
* moment they exist, so they read as Posted rather than as a blank a reader has to interpret.
|
||||
*/
|
||||
statusLabel(status: string | null): string {
|
||||
const s = (status ?? '').toUpperCase();
|
||||
if (!s) {
|
||||
return 'Posted';
|
||||
}
|
||||
if (s === 'WITHDRAWN') {
|
||||
return 'Paid out';
|
||||
}
|
||||
if (s === 'APPROVED') {
|
||||
return 'Approved';
|
||||
}
|
||||
if (s === 'PENDING') {
|
||||
return 'Pending';
|
||||
}
|
||||
if (s === 'REJECTED') {
|
||||
return 'Rejected';
|
||||
}
|
||||
if (s === 'CLOSED') {
|
||||
return 'Closed';
|
||||
}
|
||||
// OK / MISMATCH come from a physical cash verification rather than a request.
|
||||
return s.charAt(0) + s.slice(1).toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Groups the statuses by what a reader does about them.
|
||||
*
|
||||
* 'paid' is kept apart from 'done' because approved and paid out are different facts: one is a
|
||||
* promise, the other is money gone. Sharing a colour hid exactly the thing somebody opens this
|
||||
* page to check.
|
||||
*/
|
||||
statusClass(status: string | null): string {
|
||||
const s = (status ?? '').toUpperCase();
|
||||
if (s === 'PENDING') {
|
||||
return 'pending';
|
||||
}
|
||||
if (s === 'REJECTED' || s === 'MISMATCH') {
|
||||
return 'bad';
|
||||
}
|
||||
if (s === 'WITHDRAWN' || s === 'COMPLETED' || s === 'CLOSED') {
|
||||
return 'paid';
|
||||
}
|
||||
return 'done';
|
||||
}
|
||||
|
||||
/**
|
||||
* A glyph per role, so the part played reads before the words do.
|
||||
*
|
||||
* A pen for an entry the user made, a tick for one they approved, and a warning for both on the
|
||||
* same voucher — which a small branch does legitimately, and an auditor should still see marked.
|
||||
*/
|
||||
roleIcon(role: MyRole): string {
|
||||
if (role === 'RAISED') {
|
||||
return 'edit';
|
||||
}
|
||||
if (role === 'VERIFIED') {
|
||||
return 'check_circle';
|
||||
}
|
||||
return 'warning';
|
||||
}
|
||||
|
||||
/** The period the anchor date names: that day, or the whole calendar month around it. */
|
||||
private range(): { from: string; to: string } {
|
||||
if (this.period === 'DAY') {
|
||||
return { from: this.anchor, to: this.anchor };
|
||||
}
|
||||
const [
|
||||
y,
|
||||
m
|
||||
] = this.anchor.split('-').map(Number);
|
||||
const first = `${y}-${String(m).padStart(2, '0')}-01`;
|
||||
// Day 0 of the next month is the last day of this one, which avoids a table of month lengths
|
||||
// and gets February right in a leap year.
|
||||
const last = new Date(y, m, 0);
|
||||
const lastIso = `${last.getFullYear()}-${String(last.getMonth() + 1).padStart(2, '0')}-${String(
|
||||
last.getDate()
|
||||
).padStart(2, '0')}`;
|
||||
return { from: first, to: lastIso };
|
||||
}
|
||||
|
||||
private todayIso(): string {
|
||||
const d = new Date();
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
}
|
||||
@@ -170,6 +170,15 @@
|
||||
<th>Credit</th>
|
||||
<th class="num">Amount</th>
|
||||
<th>User</th>
|
||||
<!-- Two dates, not one, on the system register.
|
||||
A voucher's timestamp is the minute it was written; the posting behind it carries
|
||||
the business date. A nightly run finishing after midnight writes today's voucher
|
||||
for yesterday's work, so a reader opening this screen today is looking at last
|
||||
night's postings — and without the ledger date they would take the timestamp for
|
||||
the day the money moved. -->
|
||||
@if (systemOnly) {
|
||||
<th>Ledger date</th>
|
||||
}
|
||||
<th>Time</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -209,6 +218,14 @@
|
||||
}
|
||||
<td class="num">{{ row.amount | number: '1.2-2' }}</td>
|
||||
<td>{{ row.username || '—' }}</td>
|
||||
@if (systemOnly) {
|
||||
<td class="vr-ledgerdate" [class.vr-ledgerdate--prior]="row.postedAfterClose">
|
||||
{{ row.ledgerDate ? (row.ledgerDate | date: 'dd MMM yyyy') : '—' }}
|
||||
@if (row.postedAfterClose) {
|
||||
<span class="vr-priorflag" title="Posted after that day had closed">after close</span>
|
||||
}
|
||||
</td>
|
||||
}
|
||||
<td>{{ row.createdAt | date: 'dd MMM, HH:mm' }}</td>
|
||||
</tr>
|
||||
}
|
||||
|
||||
@@ -70,3 +70,25 @@
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* The ledger date on the system register. Marked when it differs from the day the voucher was
|
||||
written: that is the whole reason the column exists, and a date the reader has to compare against
|
||||
the Time column by eye is a date they will misread. */
|
||||
.vr-ledgerdate {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.vr-ledgerdate--prior {
|
||||
color: #5b21b6;
|
||||
}
|
||||
|
||||
.vr-priorflag {
|
||||
display: block;
|
||||
font-size: 9.5px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: #7c3aed;
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ import { CashierService } from '../cashier.service';
|
||||
* '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';
|
||||
export type VoucherSide = 'CR' | 'DR' | 'ALL';
|
||||
|
||||
/** One voucher as the register shows it: the number, the two sides, and who raised it. */
|
||||
interface VoucherRow {
|
||||
@@ -28,6 +28,10 @@ interface VoucherRow {
|
||||
voucherType: string | null;
|
||||
amount: number | null;
|
||||
createdAt: string | null;
|
||||
/** The business date the posting belongs to, which is not always the day it was written. */
|
||||
ledgerDate?: string | null;
|
||||
/** True when the voucher was written after the day it posts against had already closed. */
|
||||
postedAfterClose?: boolean;
|
||||
userId: number | null;
|
||||
username: string | null;
|
||||
branchName: string | null;
|
||||
@@ -105,6 +109,25 @@ export class VoucherReportComponent implements OnInit {
|
||||
*/
|
||||
cashOnly = false;
|
||||
|
||||
/**
|
||||
* Whether this register is the counter's own — only the people who hold a drawer.
|
||||
*
|
||||
* These two screens reconcile the counter: what came in over it and what went out. Everyone
|
||||
* else's work is their own to account for, and each person reads it on My Vouchers. Mixed in here
|
||||
* it buried the cashier rows the registers exist for and left the totals matching no drawer.
|
||||
*/
|
||||
cashiersOnly = false;
|
||||
|
||||
/**
|
||||
* Whether this register is the system's own postings rather than anybody's work.
|
||||
*
|
||||
* Interest a nightly run credited, a charge taken from a balance, an escheat, a journal keyed
|
||||
* straight to the ledger. These belong to no member of staff, so My Vouchers cannot show them —
|
||||
* it is scoped to the reader — and the two counter registers deliberately exclude them. That left
|
||||
* no screen answering "what did the system post last night".
|
||||
*/
|
||||
systemOnly = false;
|
||||
|
||||
report: VoucherReport | null = null;
|
||||
loading = false;
|
||||
error: string | null = null;
|
||||
@@ -112,6 +135,8 @@ export class VoucherReportComponent implements OnInit {
|
||||
ngOnInit(): void {
|
||||
this.side = (this.route.snapshot.data['side'] as VoucherSide) ?? 'CR';
|
||||
this.cashOnly = this.route.snapshot.data['cashOnly'] === true;
|
||||
this.cashiersOnly = this.route.snapshot.data['cashiersOnly'] === true;
|
||||
this.systemOnly = this.route.snapshot.data['systemOnly'] === true;
|
||||
this.load();
|
||||
}
|
||||
|
||||
@@ -120,10 +145,23 @@ export class VoucherReportComponent implements OnInit {
|
||||
}
|
||||
|
||||
get title(): string {
|
||||
if (this.systemOnly) {
|
||||
return 'System Vouchers';
|
||||
}
|
||||
return this.isReceived ? 'Received Report' : 'Payment Report';
|
||||
}
|
||||
|
||||
get subtitle(): string {
|
||||
if (this.systemOnly) {
|
||||
return 'What the system posted on its own — interest, charges, escheats and journals. Nobody raised these, so they appear on no one’s own list.';
|
||||
}
|
||||
// Said plainly, because a register that silently omits the reader's own entries is worse than
|
||||
// one that explains why: their work is on My Vouchers, and the line points them there.
|
||||
if (this.cashiersOnly) {
|
||||
return this.isReceived
|
||||
? 'Cash taken in at the counter by each cashier. Other staff read their own work on My Vouchers.'
|
||||
: 'Cash paid out at the counter by each cashier. Other staff read their own work on My Vouchers.';
|
||||
}
|
||||
if (this.cashOnly) {
|
||||
return this.isReceived
|
||||
? 'Cash taken in over a day or a month, and who took it — book entries left out.'
|
||||
@@ -136,6 +174,9 @@ export class VoucherReportComponent implements OnInit {
|
||||
|
||||
/** The word for one row on this side, for counts and the empty state. */
|
||||
get noun(): string {
|
||||
if (this.systemOnly) {
|
||||
return 'posting';
|
||||
}
|
||||
return this.isReceived ? 'receipt' : 'payment';
|
||||
}
|
||||
|
||||
@@ -150,7 +191,9 @@ export class VoucherReportComponent implements OnInit {
|
||||
to,
|
||||
this.usernameFilter.trim() || undefined,
|
||||
this.typeFilter.trim() || undefined,
|
||||
this.cashOnly
|
||||
this.cashOnly,
|
||||
this.cashiersOnly,
|
||||
this.systemOnly
|
||||
)
|
||||
.pipe(
|
||||
catchError((err: any) => {
|
||||
@@ -199,7 +242,7 @@ export class VoucherReportComponent implements OnInit {
|
||||
*/
|
||||
get rows(): VoucherRow[] {
|
||||
const all = this.report?.vouchers ?? [];
|
||||
return all.filter((row) => this.sideOf(row) === this.side);
|
||||
return this.side === 'ALL' ? all : all.filter((row) => this.sideOf(row) === this.side);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -184,6 +184,44 @@
|
||||
Today's Activity
|
||||
</a>
|
||||
}
|
||||
<!-- Outside the clerkAccountLoaded guard: the voucher register is the day's work, not one
|
||||
account's, so it must be reachable before any account has been pulled up. -->
|
||||
<a
|
||||
class="cashier-nav__item cashier-nav__item--sec"
|
||||
[class.cashier-nav__item--active]="currentUrl.includes('/clerk/my-work')"
|
||||
[routerLink]="['/clerk/my-work']"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M9 11l3 3L22 4" />
|
||||
<path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11" />
|
||||
</svg>
|
||||
My Vouchers
|
||||
</a>
|
||||
<a
|
||||
class="cashier-nav__item cashier-nav__item--sec"
|
||||
[class.cashier-nav__item--active]="currentUrl.includes('/clerk/received-report')"
|
||||
[routerLink]="['/clerk/received-report']"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M4 3h16v18l-3-2-2 2-3-2-3 2-2-2-3 2z" />
|
||||
<line x1="8" y1="9" x2="16" y2="9" />
|
||||
<line x1="8" y1="13" x2="14" y2="13" />
|
||||
</svg>
|
||||
Received Vouchers
|
||||
</a>
|
||||
<a
|
||||
class="cashier-nav__item cashier-nav__item--sec"
|
||||
[class.cashier-nav__item--active]="currentUrl.includes('/clerk/payment-report')"
|
||||
[routerLink]="['/clerk/payment-report']"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M4 3h16v18l-3-2-2 2-3-2-3 2-2-2-3 2z" />
|
||||
<line x1="8" y1="9" x2="16" y2="9" />
|
||||
<line x1="8" y1="13" x2="14" y2="13" />
|
||||
<line x1="12" y1="5" x2="12" y2="7" />
|
||||
</svg>
|
||||
Payment Vouchers
|
||||
</a>
|
||||
<span class="cashier-nav__esc-hint" (click)="onEscapeKey()"> <kbd>ESC</kbd> Show Header </span>
|
||||
</nav>
|
||||
} @else {
|
||||
@@ -245,6 +283,9 @@
|
||||
>
|
||||
<fa-icon icon="history"></fa-icon> Cash History
|
||||
</a>
|
||||
<a class="cashier-nav__item" [routerLink]="['/cashier/my-work']" routerLinkActive="cashier-nav__item--active">
|
||||
<fa-icon icon="user-check"></fa-icon> My Vouchers
|
||||
</a>
|
||||
<a
|
||||
class="cashier-nav__item"
|
||||
[routerLink]="['/cashier/received-report']"
|
||||
@@ -628,6 +669,14 @@
|
||||
<i class="fa fa-calculator menu-item-icon"></i> Accounting
|
||||
</button>
|
||||
<mat-divider></mat-divider>
|
||||
<!-- Listed here as well as under Branch Ops: an officer looking for the day's figures reaches
|
||||
for Reports, and the same screen answers both. -->
|
||||
<button mat-menu-item routerLink="/branch-ops/day-book" tabindex="0">
|
||||
<i class="fa fa-book menu-item-icon"></i> Day Book & Officer Verification
|
||||
</button>
|
||||
<button mat-menu-item routerLink="/branch-ops/end-of-day" tabindex="0">
|
||||
<i class="fa fa-clipboard-check menu-item-icon"></i> End of the Day Report
|
||||
</button>
|
||||
<button mat-menu-item routerLink="/reports/form-97-98" tabindex="0">
|
||||
<i class="fa fa-file-invoice menu-item-icon"></i> Form 97/98 Transaction Report
|
||||
</button>
|
||||
@@ -1041,6 +1090,17 @@
|
||||
<button mat-menu-item tabindex="0" [routerLink]="['/branch-ops/vault-position']">
|
||||
<fa-icon icon="lock" class="menu-item-icon"></fa-icon> Vault Position
|
||||
</button>
|
||||
<!-- The day book carries the Officer Verification Sheet — each person's credit, debit and net
|
||||
for the day. It was reachable only from the Clerk and Cashier menus, which is the one
|
||||
place a principal officer signing the day off does not look. -->
|
||||
<button mat-menu-item tabindex="0" [routerLink]="['/branch-ops/day-book']">
|
||||
<fa-icon icon="book" class="menu-item-icon"></fa-icon> Day Book & Verification
|
||||
</button>
|
||||
<!-- The signed sheet, as against the day book's working list: the day's modes, the drawer
|
||||
against the books, and what is still open. This is what goes in the file. -->
|
||||
<button mat-menu-item tabindex="0" [routerLink]="['/branch-ops/end-of-day']">
|
||||
<fa-icon icon="clipboard-check" class="menu-item-icon"></fa-icon> End of the Day Report
|
||||
</button>
|
||||
</mat-menu>
|
||||
|
||||
<!-- Clerk was a bare link straight into the fullscreen dashboard, which left the clerk's other
|
||||
@@ -1050,8 +1110,11 @@
|
||||
<button mat-menu-item tabindex="0" routerLink="/clerk/dashboard" (click)="hideShellLayout = true">
|
||||
<fa-icon icon="id-card" class="menu-item-icon"></fa-icon> Clerk Dashboard
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/day-book']">
|
||||
<fa-icon icon="book" class="menu-item-icon"></fa-icon> Day Book
|
||||
<!-- The clerk reads the day as a sheet to sign off, not as a working list of vouchers: the
|
||||
day's summary, the counter counted against the books, and what is still open. The day
|
||||
book's own row-by-row view stays reachable from Branch Ops and from the Cashier menu. -->
|
||||
<button mat-menu-item tabindex="0" [routerLink]="['/clerk/end-of-day']">
|
||||
<fa-icon icon="clipboard-check" class="menu-item-icon"></fa-icon> End of the Day Report
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0" [routerLink]="['/clerk/withdrawal-requests']">
|
||||
<fa-icon icon="money-bill-wave" class="menu-item-icon"></fa-icon> My Withdrawal Requests
|
||||
@@ -1062,6 +1125,30 @@
|
||||
<button mat-menu-item tabindex="0" [routerLink]="['/clerk/dividend-dashboard']">
|
||||
<fa-icon icon="chart-pie" class="menu-item-icon"></fa-icon> Dividend Dashboard
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0" [routerLink]="['/clerk/my-work']">
|
||||
<fa-icon icon="user-check" class="menu-item-icon"></fa-icon> My Vouchers
|
||||
</button>
|
||||
<!-- Sits under My Vouchers because it answers the same question for the one worker who has no
|
||||
own list: the system. A nightly run belongs to nobody, so it appears on no person's page. -->
|
||||
<button mat-menu-item tabindex="0" [routerLink]="['/clerk/system-vouchers']">
|
||||
<fa-icon icon="robot" class="menu-item-icon"></fa-icon> System Vouchers
|
||||
</button>
|
||||
<!-- A clerk supervises the counter, so they need to see what each cashier is holding and what
|
||||
went through their drawer. Both screens already return every cashier for a non-cashier
|
||||
caller (CashierRoleHelper scopes only a cashier to themselves) — they were simply not
|
||||
reachable from this menu. -->
|
||||
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/cashier-position']">
|
||||
<fa-icon icon="wallet" class="menu-item-icon"></fa-icon> Cashier Position
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/closing-report']">
|
||||
<fa-icon icon="file-invoice" class="menu-item-icon"></fa-icon> Closing Cash Report
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0" [routerLink]="['/clerk/received-report']">
|
||||
<fa-icon icon="receipt" class="menu-item-icon"></fa-icon> Received Vouchers
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0" [routerLink]="['/clerk/payment-report']">
|
||||
<fa-icon icon="receipt" class="menu-item-icon"></fa-icon> Payment Vouchers
|
||||
</button>
|
||||
</mat-menu>
|
||||
|
||||
<mat-menu #cashierMenu="matMenu" [overlapTrigger]="false" class="nav-dropdown">
|
||||
|
||||
@@ -24,27 +24,51 @@ export class ThemingService {
|
||||
theme = new BehaviorSubject('light-theme'); // <- initial theme
|
||||
|
||||
constructor() {
|
||||
// Initially check if dark mode is enabled on system
|
||||
this.darkModeOn = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
|
||||
// If dark mode is enabled then directly switch to the dark-theme
|
||||
// The user's own choice, kept in localStorage by the theme toggle, is the only thing that
|
||||
// decides the theme.
|
||||
//
|
||||
// The OS preference used to decide it instead, and a media-query listener kept it in step with
|
||||
// Windows. That is what turned a page black after printing: Chrome re-evaluates
|
||||
// prefers-color-scheme around the print dialog, the listener fired on the way back, and the app
|
||||
// flipped to dark against the user's saved choice — on every screen, and only after a print,
|
||||
// which is exactly how it was reported. A member of staff who set the app light does not expect
|
||||
// printing a report to change it, and the toggle they used had no way to win against the OS.
|
||||
//
|
||||
// Nothing here reads the OS any more, so there is no listener to fire and nothing to fight the
|
||||
// toggle. isDarkMode() still reports the OS preference for the one caller that offers it as a
|
||||
// default on first use.
|
||||
this.darkModeOn = !!this.storedPreference();
|
||||
this.setDarkMode(this.darkModeOn);
|
||||
|
||||
// Watch for changes of the preference
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addListener((e) => {
|
||||
const turnOn = e.matches;
|
||||
this.theme.next(turnOn ? 'dark-theme' : 'light-theme');
|
||||
|
||||
// Trigger refresh of UI
|
||||
this.ref.tick();
|
||||
});
|
||||
}
|
||||
|
||||
/** The saved choice, or null when the user has never set one. */
|
||||
private storedPreference(): boolean | null {
|
||||
try {
|
||||
const raw = localStorage.getItem('mifosXThemeDarkEnabled');
|
||||
return raw === null ? null : JSON.parse(raw);
|
||||
} catch {
|
||||
// A blocked or corrupt localStorage must not stop the app from rendering; light is the
|
||||
// default the rest of the stylesheet already assumes.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the app is currently in dark mode.
|
||||
*
|
||||
* Reports the app's own state, not the OS preference. It used to read the media query and assign
|
||||
* the result to darkModeOn on the way past — so merely asking the question overwrote the user's
|
||||
* saved choice, and setInitialDarkMode() then applied whatever the OS happened to say.
|
||||
*/
|
||||
isDarkMode(): boolean {
|
||||
this.darkModeOn = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
return this.darkModeOn;
|
||||
}
|
||||
|
||||
/** The OS preference, offered as a starting point when the user has never chosen for themselves. */
|
||||
isSystemDarkMode(): boolean {
|
||||
return !!(window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
}
|
||||
|
||||
setDarkMode(isDarkMode: boolean) {
|
||||
this.darkModeOn = isDarkMode;
|
||||
if (isDarkMode) {
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
/* tslint:disable */
|
||||
export default {
|
||||
'mifos_x': {
|
||||
'version': '260902',
|
||||
'hash': '63134f17'
|
||||
'version': '260908',
|
||||
'hash': 'fbce73fd'
|
||||
},
|
||||
'allow_switching_backend_instance': true
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user