Files
cbsnexora/src/app/cashier/my-work/my-work.component.ts
T
niteshandClaude Sonnet 5.5 ab8ed7e67e feat(vouchers): voucher correction screens and a correction status on the voucher registers
A cash deposit posted to the wrong account is moved to the right one in four steps: unpass the voucher, a manager
approves, the cashier enters the correct account, a manager approves again. Nothing changes on any account until
the final approval.

- Voucher Correction page with four-step strip and popups: today's vouchers to unpass (admin or clerk), the
  unpassed vouchers waiting for their correct account (cashier) with the wrong and correct account side by side
  and the balance check, and the caller's own requests. A cashier whose customer has used the money closes the
  request and deposits separately.
- Reached from the cashier bar, the clerk bar and the Cashier menu; the manager reviews both steps under
  Approvals > Voucher Corrections, laid out like the other approval tabs. Routes for Cash Transfers approvals are
  added to the same tab bar.
- A Correction column on My Vouchers, the day book and the voucher register (and in the voucher detail popup)
  says whether a voucher is unpassed, revoked in favour of another, a correction, or closed, with the reason.
- The deposit taken off the wrong account no longer shows on its passbook or transaction list.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 12:28:27 +05:30

372 lines
12 KiB
TypeScript

/**
* 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 { MatDialog, MatDialogModule } from '@angular/material/dialog';
import { catchError, finalize, of } from 'rxjs';
import { CashierService } from '../cashier.service';
import { VoucherDetailDialogComponent } from './voucher-detail-dialog.component';
import { businessToday } from 'app/core/utils/business-today';
import { VoucherStatusComponent, VoucherStatus } from '../voucher-status/voucher-status.component';
/** 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;
/** Where the voucher stands after a correction: active, revoked, under correction, or corrected. */
voucherStatus?: VoucherStatus;
revokedByVoucherNo?: string | null;
replacesVoucherNo?: string | null;
correctionNote?: string | null;
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,
MatDialogModule,
VoucherStatusComponent
]
})
export class MyWorkComponent implements OnInit {
private service = inject(CashierService);
private dialog = inject(MatDialog);
/** Which voucher's detail is currently being fetched, so the row that was clicked can show its
* own spinner instead of a page-wide one for what is a small, single-row lookup. */
loadingDetailFor: string | null = null;
/** 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, and only to
* the table below — the five summary tiles are pinned to Entered work regardless (see
* enteredRows), so this can default to showing everything without muddying them.
*/
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);
}
/**
* What this person themselves put through today — never narrowed by the Side or My part
* filters, which exist only to browse the table below.
*
* The five summary tiles are pinned to this set on purpose. They answer one question, "what did
* I personally do today", and that answer cannot depend on which slice of the list a person
* happens to have the table filtered to at the moment — a Received figure that changed whenever
* someone picked "Approved" to go look at what they'd passed would not be their own total any
* more, it would be whatever the table was showing.
*/
get enteredRows(): MyWorkRow[] {
const all = this.report?.vouchers ?? [];
return all.filter((r) => r.myRole === 'RAISED' || r.myRole === 'RAISED_AND_VERIFIED');
}
get receivedTotal(): number {
return this.enteredRows.filter((r) => r.side === 'CR').reduce((sum, r) => sum + Number(r.amount ?? 0), 0);
}
get paymentTotal(): number {
return this.enteredRows.filter((r) => r.side === 'DR').reduce((sum, r) => sum + Number(r.amount ?? 0), 0);
}
get journalTotal(): number {
return this.enteredRows
.filter((r) => r.side !== 'CR' && r.side !== 'DR')
.reduce((sum, r) => sum + Number(r.amount ?? 0), 0);
}
get raisedCount(): number {
return this.enteredRows.length;
}
get verifiedCount(): number {
const all = this.report?.vouchers ?? [];
return all.filter((r) => r.myRole === 'VERIFIED' || r.myRole === 'RAISED_AND_VERIFIED').length;
}
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 };
}
/** Opens the full slip for one voucher — every debit and credit line, not just the first side of
* each a table row has room to show. */
openDetail(voucherNo: string): void {
if (this.loadingDetailFor) {
return;
}
this.loadingDetailFor = voucherNo;
this.service
.voucherDetail(voucherNo)
.pipe(
catchError(() => of(null)),
finalize(() => (this.loadingDetailFor = null))
)
.subscribe((detail: any) => {
if (!detail) {
return;
}
this.dialog.open(VoucherDetailDialogComponent, {
data: { detail },
width: '600px',
autoFocus: false
});
});
}
private todayIso(): string {
const d = businessToday();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
}