feat(cashier): add end-of-day report, my work, and cashier updates

This commit is contained in:
2026-09-08 10:38:59 +05:30
parent fbce73fd17
commit 035b11fa96
20 changed files with 3146 additions and 124 deletions
@@ -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&rsquo;s figures&hellip;</div>
}
@if (loaded) {
<div class="eod-sheet">
<!-- ── Header ── -->
<div class="eod-sheet__head">
<h2 class="eod-sheet__title">Daily EOD &amp; 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">&mdash;</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&rsquo;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&rsquo;s movement;
the second is everything the branch has accumulated to date. Each cashier&rsquo;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 &mdash; 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 &mdash; {{ 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 &mdash; 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 &mdash; 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 &mdash; 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&rsquo;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&rsquo;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 &mdash; {{ 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 &middot; {{ 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) &mdash; 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' }} &mdash; listed in their own
section, not in today&rsquo;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">&nbsp;</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 &mdash; 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();
}
}