Files
cbsnexora/src/app/cashier/my-work/my-work.component.ts
T
nitesh f0c06988c0
build-run / Run Lint, Build and Deploy (push) Canceled after 0s
Publish Image in Docker Hub / build (push) Canceled after 0s
Playwright E2E Tests / Run Playwright Tests (push) Canceled after 0s
Tests / testRigor (push) Canceled after 0s
fix(cashier): My Vouchers filters, Fund Transfer GL support, and disbursement-list rename
my-work.component (My Vouchers):
- The five summary tiles (Received/Payments/Journal/Entered/Approved) were
  bound directly to the server's day-total fields, so they stayed fixed at
  the combined "entered + approved" figure no matter what the Side/My part
  dropdowns were set to. They're now pinned to this user's own entered rows
  only (enteredRows), independent of both filters, so "how much did I
  personally put through today" reads correctly regardless of what the table
  below happens to be filtered to. The table and its dropdowns are unchanged
  and still default to showing everything.

transaction-entry.component (Fund Transfer):
- onSave()'s plain bank-transfer branch (SD/CA/CB only) was catching every
  'Fund Transfer' row before the GL-aware Disbursement/Collection branch
  below it could see them, so a Fund Transfer against a GL account was always
  rejected with "Invalid Account Type" even though that branch already
  supports it under the same label. Fund Transfer rows that include a GL leg
  now skip the plain-transfer branch and fall through correctly.

my-disbursement-requests.component (Disbursements & Transfer list):
- getCreditEntries() only read the stored credit_entries JSON, which holds
  the full picture for Collection (its GL leg is one of the entries) but not
  for Disbursement/Fund Transfer, whose GL is the request's own top-level
  source (gl_account_no/dr_amount) and was never in that array. The DR/CR
  breakdown for those two types showed only the credit side, looking like
  half the entry was missing. The GL leg is now synthesised back in as the
  first row for those two types, matching what actually posted.
- Renamed the clerk's "My Disbursements" / "My Disbursement Requests" screen
  to "Disbursements & Transfer" throughout (nav tab, dropdown menu, route
  title/breadcrumb, cashier counter link, page header) — the register covers
  Disbursement, Collection, Fund Transfer and Bank Transfer requests, and the
  old name read as disbursement-only. "My Requests" (Withdrawal Slip
  requests, a separate register) is renamed to "Withdrawal Request" for the
  same reason: neither name said what was actually inside.
2026-09-10 12:40:32 +05:30

331 lines
11 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 { 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, 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 };
}
private todayIso(): string {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
}