feat: implement cashier dashboard, dividend dashboards, share accounts views, and checker tasks

This commit is contained in:
2026-08-25 11:20:42 +05:30
parent 7ef75c902d
commit 0b79b0b0d3
77 changed files with 7396 additions and 426 deletions
@@ -0,0 +1,109 @@
<!--
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-card">
<div class="pos-section-label">Share Savings Debit — Awaiting Approval</div>
<div class="inv-form-grid" style="padding: 12px 16px">
<label class="inv-form-field" style="align-self: end">
<button type="button" class="pg-btn" [disabled]="loading" (click)="load()">Refresh</button>
</label>
</div>
<table class="pg-table">
<thead>
<tr>
<th>Member</th>
<th>Share Account</th>
<th>Savings Account</th>
<th class="num">Shares</th>
<th class="num">Entry Fee</th>
<th class="num">To Debit</th>
<th>Requested By</th>
<th></th>
</tr>
</thead>
<tbody>
@if (loading) {
<tr>
<td colspan="8" class="pg-empty">Loading…</td>
</tr>
} @else if (loaded && rows.length === 0) {
<tr>
<td colspan="8" class="pg-empty">No savings-debit requests are waiting for approval.</td>
</tr>
} @else {
@for (row of rows; track row.id) {
<tr>
<td>{{ row.client_name }}</td>
<td>{{ row.share_account_no }}</td>
<td>
{{ row.savings_account_no }}
<!-- The balance can move between the clerk raising this and a manager reaching it,
and the server refuses a short account — better seen before clicking. -->
<div [class.ssd-warn]="isShort(row)" class="ssd-sub">
Balance {{ row.savings_balance | number: '1.2-2' }}
</div>
</td>
<td class="num">{{ row.share_amount | number: '1.2-2' }}</td>
<td class="num">{{ row.charge_amount | number: '1.2-2' }}</td>
<td class="num">
<strong>{{ row.total_amount | number: '1.2-2' }}</strong>
</td>
<td>
{{ row.clerk_name }}
<div class="ssd-sub">{{ row.submitted_at | slice: 0 : 10 }}</div>
</td>
<td class="ssd-actions">
<button
type="button"
class="pg-btn"
[disabled]="actioningId === row.id || isShort(row)"
(click)="approve(row)"
>
{{ actioningId === row.id ? 'Working…' : 'Approve' }}
</button>
<button type="button" class="pg-btn" [disabled]="actioningId === row.id" (click)="toggleReject(row)">
{{ expandedRejectId === row.id ? 'Cancel' : 'Reject' }}
</button>
</td>
</tr>
@if (isShort(row)) {
<tr>
<td colspan="8" class="ssd-warn-row">
Savings holds {{ row.savings_balance | number: '1.2-2' }} but
{{ row.total_amount | number: '1.2-2' }} is required. Take a deposit first, or reject this and settle by
cash collection.
</td>
</tr>
}
@if (actionError[row.id]) {
<tr>
<td colspan="8" class="ssd-warn-row">{{ actionError[row.id] }}</td>
</tr>
}
@if (expandedRejectId === row.id) {
<tr>
<td colspan="8">
<div class="inv-form-grid" style="padding: 8px 0">
<label class="inv-form-field">
<span>Reason for rejection</span>
<input type="text" [(ngModel)]="rejectReason[row.id]" maxlength="500" />
</label>
<label class="inv-form-field" style="align-self: end">
<button type="button" class="pg-btn" [disabled]="actioningId === row.id" (click)="reject(row)">
Confirm Rejection
</button>
</label>
</div>
</td>
</tr>
}
}
}
</tbody>
</table>
</div>
@@ -0,0 +1,32 @@
/**
* 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/.
*/
/* A savings account that no longer covers the request, and any error the server returned. Both
read as blocking conditions on the row, so they share the same treatment. */
.ssd-warn {
color: #b42318;
}
.ssd-warn-row {
padding: 8px 12px;
background: #fdecea;
color: #b42318;
font-size: 12.5px;
}
.ssd-sub {
font-size: 12px;
color: #64748b;
}
.ssd-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
white-space: nowrap;
}
@@ -0,0 +1,120 @@
/**
* 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 { catchError } from 'rxjs/operators';
import { of } from 'rxjs';
import { CashierService } from '../../../cashier/cashier.service';
/**
* Approval Center tab — share applications a clerk has asked to settle out of the member's own
* savings, awaiting a manager.
*
* Approving is the moment the member's balance actually changes: it withdraws the share value plus
* entry fee from their savings and issues the shares. Nothing has moved before that, which is why
* this step exists at all — the cash route needs no checker because the cashier's receipt is
* already a second pair of eyes, whereas here nobody has counted anything.
*
* A manager cannot approve a request they raised themselves; the server rejects that, and the
* message is surfaced on the row rather than swallowed.
*/
@Component({
selector: 'mifosx-share-savings-debit-requests',
templateUrl: './share-savings-debit-requests.component.html',
styleUrls: [
'../../../cashier/cashier-shared.scss',
'../../../clerk/clerk-shared.scss',
'./share-savings-debit-requests.component.scss'
],
standalone: true,
imports: [
CommonModule,
FormsModule
]
})
export class ShareSavingsDebitRequestsComponent implements OnInit {
private cashierService = inject(CashierService);
rows: any[] = [];
loading = false;
loaded = false;
actioningId: number | null = null;
actionError: Record<number, string> = {};
/** Which row has its reject box open, and what reason has been typed into it. */
expandedRejectId: number | null = null;
rejectReason: Record<number, string> = {};
ngOnInit(): void {
this.load();
}
load(): void {
this.loading = true;
this.cashierService
.getShareSavingsDebitRequests('PENDING')
.pipe(catchError(() => of([])))
.subscribe((res: any) => {
this.rows = res ?? [];
this.loading = false;
this.loaded = true;
});
}
approve(row: any): void {
this.actioningId = row.id;
delete this.actionError[row.id];
this.cashierService.approveShareSavingsDebit(row.id).subscribe({
next: () => {
this.actioningId = null;
this.load();
},
error: (err: any) => {
this.actioningId = null;
this.actionError[row.id] =
err?.error?.defaultUserMessage || err?.error?.message || 'Could not approve this request.';
}
});
}
toggleReject(row: any): void {
this.expandedRejectId = this.expandedRejectId === row.id ? null : row.id;
}
reject(row: any): void {
this.actioningId = row.id;
delete this.actionError[row.id];
this.cashierService.rejectShareSavingsDebit(row.id, this.rejectReason[row.id]?.trim()).subscribe({
next: () => {
this.actioningId = null;
this.expandedRejectId = null;
this.load();
},
error: (err: any) => {
this.actioningId = null;
this.actionError[row.id] =
err?.error?.defaultUserMessage || err?.error?.message || 'Could not reject this request.';
}
});
}
/**
* Whether the savings account still covers what the request asks for.
*
* The balance can move between a clerk raising the request and a manager reaching it, and the
* server refuses a short account — showing it on the row means the manager sees why before
* clicking rather than after.
*/
isShort(row: any): boolean {
return Number(row.savings_balance ?? 0) < Number(row.total_amount ?? 0);
}
}
@@ -153,6 +153,15 @@
<fa-icon icon="coins" class="ac-tab__ic"></fa-icon>
<span>Dividend Requests</span>
</a>
<a
class="ac-tab"
routerLink="./share-savings-debit-requests"
routerLinkActive="ac-tab--active"
*mifosxHasPermission="'ALL_FUNCTIONS_READ'"
>
<fa-icon icon="money-check-alt" class="ac-tab__ic"></fa-icon>
<span>Share Savings Debit</span>
</a>
<a
class="ac-tab"
routerLink="./deposit-requests"
+6
View File
@@ -19,6 +19,7 @@ import { CheckerInboxComponent } from './checker-inbox-and-tasks-tabs/checker-in
import { WithdrawalRequestsComponent } from './checker-inbox-and-tasks-tabs/withdrawal-requests/withdrawal-requests.component';
import { ShareWithdrawalRequestsComponent } from './checker-inbox-and-tasks-tabs/share-withdrawal-requests/share-withdrawal-requests.component';
import { DividendRequestsComponent } from './checker-inbox-and-tasks-tabs/dividend-requests/dividend-requests.component';
import { ShareSavingsDebitRequestsComponent } from './checker-inbox-and-tasks-tabs/share-savings-debit-requests/share-savings-debit-requests.component';
import { DepositRequestsComponent } from './checker-inbox-and-tasks-tabs/deposit-requests/deposit-requests.component';
import { DisbursementRequestsComponent } from './checker-inbox-and-tasks-tabs/disbursement-requests/disbursement-requests.component';
import { ClientApprovalComponent } from './checker-inbox-and-tasks-tabs/client-approval/client-approval.component';
@@ -171,6 +172,11 @@ const routes: Routes = [
component: DividendRequestsComponent,
data: { title: 'Dividend Requests' }
},
{
path: 'share-savings-debit-requests',
component: ShareSavingsDebitRequestsComponent,
data: { title: 'Share Savings Debit Requests' }
},
{
path: 'deposit-requests',
component: DepositRequestsComponent,