Transaction Entry (clerk): - Route a two-row Fund Transfer of Dr SHR / Cr GL to the share-withdrawal request with settlementMode GL, so a clerk can settle a share withdrawal into any GL account instead of the member's linked savings account. - Offer GL as an account type for Demand Draft funding, and submit it as a GL debit (no client or savings/current account is involved). My Work: - Add a voucher detail dialog so a clerk can open a raised voucher from the request list and see its GL lines. Closing Cash Report: - Fix the total row so it reflects the corrected opening balance. DD Leaflet Registry and DD Inventory: - Redesign both pages onto the shared usr-* visual language: gradient header badge, status filter chips with counts, card-wrapped sticky-header table, and one primary action per row with the rest behind an overflow menu. Filtering, pagination and every action condition are unchanged. - Register faHandPaper, needed by the new Stop Payment menu item. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
364 lines
12 KiB
TypeScript
364 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';
|
|
|
|
/** 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')}`;
|
|
}
|
|
}
|