394 lines
17 KiB
HTML
394 lines
17 KiB
HTML
<!--
|
||
Copyright since 2025 Mifos Initiative
|
||
|
||
This Source Code Form is subject to the terms of the Mozilla Public
|
||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||
-->
|
||
|
||
<div class="pg">
|
||
<header class="pg-head">
|
||
<div>
|
||
<div class="pg-eyebrow">CASHIER · DAY BOOK</div>
|
||
<h1 class="pg-title">Day Book</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.
|
||
</p>
|
||
</div>
|
||
<div class="pg-toolbar">
|
||
<button type="button" class="pg-btn" (click)="shiftDate(-1)" title="Previous day">
|
||
<mat-icon>chevron_left</mat-icon>
|
||
</button>
|
||
<label class="pg-field">
|
||
<span>Date</span>
|
||
<input type="date" [value]="date" (change)="date = $any($event.target).value; load()" />
|
||
</label>
|
||
<button type="button" class="pg-btn" (click)="shiftDate(1)" title="Next day">
|
||
<mat-icon>chevron_right</mat-icon>
|
||
</button>
|
||
<button type="button" class="pg-btn" (click)="goToday()">Today</button>
|
||
<button type="button" class="pg-btn pg-btn--primary" (click)="load()" [disabled]="loading">
|
||
<mat-icon>refresh</mat-icon> Reload
|
||
</button>
|
||
</div>
|
||
</header>
|
||
|
||
<!-- Filters -->
|
||
<div class="db-filters">
|
||
<label class="pg-field">
|
||
<span>Side</span>
|
||
<select [(ngModel)]="side" (change)="load()">
|
||
<option value="">All</option>
|
||
<option value="CR">CR · Credit (money in)</option>
|
||
<option value="DR">DR · Debit (money out)</option>
|
||
</select>
|
||
</label>
|
||
<label class="pg-field">
|
||
<span>Status</span>
|
||
<select [(ngModel)]="status" (change)="load()">
|
||
<option value="">All</option>
|
||
<option value="APPROVED">Approved</option>
|
||
<option value="PENDING">Pending</option>
|
||
<option value="REJECTED">Rejected</option>
|
||
</select>
|
||
</label>
|
||
<label class="pg-field db-filters__search">
|
||
<span>Search</span>
|
||
<input type="text" [(ngModel)]="search" placeholder="Voucher no, account, party, payment type, narration…" />
|
||
</label>
|
||
<button type="button" class="pg-btn" (click)="exportCsv()" [disabled]="filteredRows.length === 0">
|
||
<mat-icon>download</mat-icon> CSV
|
||
</button>
|
||
</div>
|
||
|
||
@if (errorMsg) {
|
||
<div class="pg-alert pg-alert--error">{{ errorMsg }}</div>
|
||
}
|
||
|
||
<!-- Exceptions surface at the top of the day, not only as a badge buried in their own row: a
|
||
flag raised for investigation is useless if someone has to scroll to find it. -->
|
||
@if (!loading && exceptionRows.length > 0) {
|
||
<div class="db-exceptions">
|
||
<div class="db-exceptions__head">
|
||
<mat-icon>warning</mat-icon>
|
||
<strong>
|
||
{{ exceptionRows.length }} voucher{{ exceptionRows.length === 1 ? '' : 's' }} awaiting investigation
|
||
</strong>
|
||
@if (resolvedExceptionCount > 0) {
|
||
<span class="db-muted">({{ resolvedExceptionCount }} already closed)</span>
|
||
}
|
||
<button type="button" class="pg-btn pg-btn--warn" (click)="toggleExceptionsOnly()">
|
||
{{ exceptionsOnly ? 'Show all vouchers' : 'Show only these' }}
|
||
</button>
|
||
</div>
|
||
@for (e of exceptionRows; track e.voucherNo) {
|
||
<div class="db-exceptions__item">
|
||
<span class="pg-chip">{{ e.voucherNo }}</span>
|
||
<span class="db-exceptions__amt">
|
||
System ₹{{ e.amount | number: '1.2-2' }}
|
||
@if (e.slipAmount != null && e.slipAmount !== e.amount) {
|
||
· Slip ₹{{ e.slipAmount | number: '1.2-2' }}
|
||
<strong class="db-exceptions__diff"
|
||
>(diff ₹{{ e.slipAmount - (e.amount ?? 0) | number: '1.2-2' }})</strong
|
||
>
|
||
}
|
||
</span>
|
||
<span class="db-exceptions__why">{{ e.verifyRemarks }}</span>
|
||
<span class="db-muted">— {{ e.verifiedByName }}, {{ timeOf(e.verifiedAt) }}</span>
|
||
</div>
|
||
}
|
||
</div>
|
||
}
|
||
|
||
@if (loading) {
|
||
<div class="pg-empty">Loading…</div>
|
||
}
|
||
|
||
@if (!loading && loaded && rows.length === 0) {
|
||
<div class="pg-empty">
|
||
<mat-icon>receipt_long</mat-icon>
|
||
<p>No vouchers were raised on {{ date }}.</p>
|
||
</div>
|
||
}
|
||
|
||
@if (!loading && filteredRows.length > 0) {
|
||
<!-- Totals bar. Reflects what is on screen, so a search or side filter still reconciles. -->
|
||
<div class="closing-summary">
|
||
<div class="closing-summary__item closing-summary__item--in">
|
||
<span class="closing-summary__label">Credit · CR (money in)</span>
|
||
<span class="closing-summary__value">₹{{ shownCredit | number: '1.2-2' }}</span>
|
||
</div>
|
||
<div class="closing-summary__sep">−</div>
|
||
<div class="closing-summary__item closing-summary__item--out">
|
||
<span class="closing-summary__label">Debit · DR (money out)</span>
|
||
<span class="closing-summary__value">₹{{ shownDebit | number: '1.2-2' }}</span>
|
||
</div>
|
||
<div class="closing-summary__sep">=</div>
|
||
<div
|
||
class="closing-summary__item"
|
||
[class.closing-summary__item--net-pos]="shownCredit - shownDebit >= 0"
|
||
[class.closing-summary__item--net-neg]="shownCredit - shownDebit < 0"
|
||
>
|
||
<span class="closing-summary__label">Net</span>
|
||
<span class="closing-summary__value">₹{{ shownCredit - shownDebit | number: '1.2-2' }}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<p class="db-note">
|
||
Totals count approved vouchers only — pending and rejected rows are listed but never move the money.
|
||
</p>
|
||
|
||
<div class="pg-card">
|
||
<table class="pg-table">
|
||
<thead>
|
||
<tr>
|
||
<th>Voucher No</th>
|
||
<th>Dr/Cr</th>
|
||
<th>Type</th>
|
||
<th>Account</th>
|
||
<th>Party</th>
|
||
<th class="num">Amount</th>
|
||
<th>Payment</th>
|
||
<th>Status</th>
|
||
<th>By</th>
|
||
<th>Approved By</th>
|
||
<th>Slip Check</th>
|
||
<th>Time</th>
|
||
<th></th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
@for (r of filteredRows; track r.voucherNo ?? r.submittedAt) {
|
||
<tr [class.db-row--cr]="r.side === 'CR'" [class.db-row--dr]="r.side === 'DR'">
|
||
<td>
|
||
<span class="pg-chip">{{ r.voucherNo ?? '—' }}</span>
|
||
</td>
|
||
<td>
|
||
<span class="db-side" [class.db-side--cr]="r.side === 'CR'" [class.db-side--dr]="r.side === 'DR'">
|
||
{{ r.side }}
|
||
</span>
|
||
</td>
|
||
<td>{{ r.kind }}</td>
|
||
<td>
|
||
{{ r.accountNo ?? '—' }}
|
||
@if (r.accountType) {
|
||
<span class="db-muted">· {{ r.accountType }}</span>
|
||
}
|
||
</td>
|
||
<td>{{ r.party ?? '—' }}</td>
|
||
<td class="num">₹{{ r.amount | number: '1.2-2' }}</td>
|
||
<td>{{ r.paymentTypeName ?? '—' }}</td>
|
||
<td>
|
||
<span [class]="statusClass(r.status)">{{ r.status }}</span>
|
||
</td>
|
||
<td>{{ r.handledBy ?? '—' }}</td>
|
||
<td>
|
||
{{ r.approvedBy ?? '—' }}
|
||
@if (r.approvedBy && r.actionedAt) {
|
||
<span class="db-muted">· {{ timeOf(r.actionedAt) }}</span>
|
||
}
|
||
</td>
|
||
<td>
|
||
@if (r.verifyStatus === 'VERIFIED') {
|
||
<span class="db-badge db-badge--ok" [title]="'Verified by ' + (r.verifiedByName ?? '')"
|
||
>✓ MATCHED</span
|
||
>
|
||
} @else if (r.verifyStatus === 'EXCEPTION' && r.resolutionStatus) {
|
||
<!-- Closed: the flag is history now, so it reads as settled rather than pending. -->
|
||
<span
|
||
class="db-badge db-badge--ok"
|
||
[title]="resolutionLabel(r.resolutionStatus) + ' — ' + (r.resolutionNotes ?? '')"
|
||
>
|
||
✓ RESOLVED
|
||
</span>
|
||
} @else if (r.verifyStatus === 'EXCEPTION') {
|
||
<span
|
||
class="db-badge db-badge--rejected"
|
||
[title]="'Slip ₹' + r.slipAmount + ' vs system ₹' + r.amount + ' — ' + (r.verifyRemarks ?? '')"
|
||
>
|
||
⚠ EXCEPTION
|
||
</span>
|
||
} @else {
|
||
<span class="db-muted">Not checked</span>
|
||
}
|
||
</td>
|
||
<td>{{ timeOf(r.submittedAt) }}</td>
|
||
<td class="db-actions">
|
||
<button type="button" class="db-icon-btn" (click)="openVerify(r)" title="Check against the paper slip">
|
||
<mat-icon>fact_check</mat-icon>
|
||
</button>
|
||
<button type="button" class="db-icon-btn" (click)="openAttach(r)" title="Attach or view scanned slip">
|
||
<mat-icon>attach_file</mat-icon>
|
||
@if (r.attachmentCount > 0) {
|
||
<span class="db-icon-btn__count">{{ r.attachmentCount }}</span>
|
||
}
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
|
||
<!-- A flagged voucher spells out its reason in the table itself. A tooltip is not
|
||
enough: it cannot be printed, exported, or read without a mouse. -->
|
||
@if (r.verifyStatus === 'EXCEPTION' && verifyRow !== r) {
|
||
<tr class="db-exc-row" [class.db-exc-row--resolved]="r.resolutionStatus">
|
||
<td colspan="13">
|
||
<mat-icon>{{ r.resolutionStatus ? 'task_alt' : 'warning' }}</mat-icon>
|
||
<strong>Exception</strong>
|
||
@if (r.slipAmount != null && r.slipAmount !== r.amount) {
|
||
— slip ₹{{ r.slipAmount | number: '1.2-2' }} vs system ₹{{ r.amount | number: '1.2-2' }} (difference
|
||
₹{{ r.slipAmount - (r.amount ?? 0) | number: '1.2-2' }})
|
||
}
|
||
— {{ r.verifyRemarks }}
|
||
<span class="db-muted">· raised by {{ r.verifiedByName }} at {{ timeOf(r.verifiedAt) }}</span>
|
||
<!-- Both halves of the record: what the cashier found, and how it was settled. -->
|
||
@if (r.resolutionStatus) {
|
||
<div class="db-exc-row__resolution">
|
||
<strong>Closed — {{ resolutionLabel(r.resolutionStatus) }}:</strong>
|
||
{{ r.resolutionNotes }}
|
||
<span class="db-muted">· by {{ r.resolvedByName }} at {{ timeOf(r.resolvedAt) }}</span>
|
||
</div>
|
||
}
|
||
</td>
|
||
</tr>
|
||
}
|
||
|
||
<!-- Verification panel, opened inline under its own voucher so the two amounts sit
|
||
next to the row they describe. -->
|
||
@if (verifyRow === r) {
|
||
<tr class="db-panel-row">
|
||
<td colspan="13">
|
||
<div class="db-panel">
|
||
<div class="db-panel__head">
|
||
<strong>Slip check — {{ r.voucherNo }}</strong>
|
||
<button type="button" class="db-icon-btn" (click)="closeVerify()" title="Close">
|
||
<mat-icon>close</mat-icon>
|
||
</button>
|
||
</div>
|
||
<div class="db-panel__body">
|
||
<div class="db-cmp">
|
||
<span class="db-cmp__label">System recorded</span>
|
||
<span class="db-cmp__value">₹{{ r.amount | number: '1.2-2' }}</span>
|
||
</div>
|
||
<label class="pg-field">
|
||
<span>Amount written on slip</span>
|
||
<input type="number" step="0.01" [(ngModel)]="verifySlipAmount" />
|
||
</label>
|
||
@if (verifySlipBlank) {
|
||
<div class="db-cmp">
|
||
<span class="db-cmp__label">Difference</span>
|
||
<span class="db-cmp__value">—</span>
|
||
</div>
|
||
} @else if (verifyMismatch) {
|
||
<div class="db-cmp db-cmp--bad">
|
||
<span class="db-cmp__label">Difference</span>
|
||
<span class="db-cmp__value">₹{{ verifyDifference | number: '1.2-2' }}</span>
|
||
</div>
|
||
} @else {
|
||
<div class="db-cmp db-cmp--good">
|
||
<span class="db-cmp__label">Match</span>
|
||
<span class="db-cmp__value">Amounts agree</span>
|
||
</div>
|
||
}
|
||
</div>
|
||
<label class="pg-field db-panel__remarks">
|
||
<span>{{ verifyMismatch ? 'What did you find? (required)' : 'Remarks (optional)' }}</span>
|
||
<input
|
||
type="text"
|
||
[(ngModel)]="verifyRemarks"
|
||
placeholder="e.g. slip written as 500, system posted 400 — teller keyed it wrong"
|
||
/>
|
||
</label>
|
||
@if (verifyMismatch) {
|
||
<p class="db-panel__warn">
|
||
Saving this marks the voucher as an <strong>exception</strong> for investigation — it does not
|
||
change the posted amount.
|
||
</p>
|
||
}
|
||
<div class="db-panel__actions">
|
||
<button
|
||
type="button"
|
||
class="pg-btn pg-btn--primary"
|
||
(click)="saveVerify()"
|
||
[disabled]="verifySaving || verifySlipBlank"
|
||
>
|
||
{{ verifyMismatch ? 'Mark Exception' : 'Mark Verified' }}
|
||
</button>
|
||
<!-- Raising an exception on an already-matched voucher stays available: a
|
||
slip cleared in haste, or a difference noticed later, has to be
|
||
recordable without erasing the earlier verdict. -->
|
||
@if (!verifyMismatch) {
|
||
<button
|
||
type="button"
|
||
class="pg-btn pg-btn--warn"
|
||
(click)="raiseException()"
|
||
[disabled]="verifySaving"
|
||
>
|
||
Raise Exception
|
||
</button>
|
||
}
|
||
<button type="button" class="pg-btn" (click)="closeVerify()">Cancel</button>
|
||
</div>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
}
|
||
|
||
<!-- Attachment panel for this voucher. -->
|
||
@if (attachRow === r) {
|
||
<tr class="db-panel-row">
|
||
<td colspan="13">
|
||
<div class="db-panel">
|
||
<div class="db-panel__head">
|
||
<strong>Attachments — {{ r.voucherNo }}</strong>
|
||
<button type="button" class="db-icon-btn" (click)="closeAttach()" title="Close">
|
||
<mat-icon>close</mat-icon>
|
||
</button>
|
||
</div>
|
||
<label class="db-upload">
|
||
<input type="file" (change)="onFileSelected($event)" [disabled]="attachUploading" />
|
||
<span class="db-muted">Scanned slip, cheque image or ID proof · max 10 MB</span>
|
||
</label>
|
||
@if (attachUploading) {
|
||
<p class="db-muted">Uploading…</p>
|
||
}
|
||
@if (attachLoading) {
|
||
<p class="db-muted">Loading…</p>
|
||
}
|
||
@if (!attachLoading && attachments.length === 0) {
|
||
<p class="db-muted">Nothing attached to this voucher yet.</p>
|
||
}
|
||
@for (a of attachments; track a.id) {
|
||
<div class="db-file">
|
||
<mat-icon>description</mat-icon>
|
||
<span class="db-file__name">{{ a.fileName }}</span>
|
||
<span class="db-muted">{{ a.fileSize }} bytes · {{ a.uploadedByName }}</span>
|
||
<button type="button" class="db-icon-btn" (click)="downloadAttachment(a)" title="Download">
|
||
<mat-icon>download</mat-icon>
|
||
</button>
|
||
<button type="button" class="db-icon-btn" (click)="deleteAttachment(a)" title="Delete">
|
||
<mat-icon>delete</mat-icon>
|
||
</button>
|
||
</div>
|
||
}
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
}
|
||
}
|
||
</tbody>
|
||
<tfoot>
|
||
<tr>
|
||
<td colspan="5">Showing {{ filteredRows.length }} of {{ rows.length }} voucher(s)</td>
|
||
<td class="num">₹{{ shownCredit - shownDebit | number: '1.2-2' }}</td>
|
||
<td colspan="7"></td>
|
||
</tr>
|
||
</tfoot>
|
||
</table>
|
||
</div>
|
||
}
|
||
|
||
@if (!loading && loaded && rows.length > 0 && filteredRows.length === 0) {
|
||
<div class="pg-empty">No voucher matches “{{ search }}”.</div>
|
||
}
|
||
</div>
|