/** * 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'; /** 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, MatDialogModule ] }) 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 = new Date(); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; } }