feat(cashier): add end-of-day report, my work, and cashier updates
This commit is contained in:
@@ -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')}`;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user