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,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 &amp; 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')}`;
}
}