feat: implement cashier dashboard, dividend dashboards, share accounts views, and checker tasks

This commit is contained in:
2026-08-25 11:20:42 +05:30
parent 7ef75c902d
commit 0b79b0b0d3
77 changed files with 7396 additions and 426 deletions
@@ -0,0 +1,393 @@
<!--
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>