feat(shares,tasks): centralized Additional Shares approval queue, remove unused Share Savings Debit

- Add Approval Center tab "Share Addition Requests": lists every pending additional-share tranche
  across all accounts (backed by the new /v1/shareadditionrequests endpoint), with a detail popup
  showing the board resolution and the route-specific proof of funding (voucher(s) for cash, or the
  linked savings account for savings). Approve/Reject call the same native
  approveadditionalshares/rejectadditionalshares commands the account-page buttons already used.
- Remove "Debit from Savings" dialog/button and the "Share Savings Debit" Checker Inbox tab -- zero
  rows ever recorded on either tenant since it was built.
- Remove "Approve Additional Shares"/"Reject Additional Shares" from the account-page menu now that
  every tranche is reviewed from the centralized queue instead.
- Harden SharesButtonsConfiguration.removeButton(): a not-found name fed to splice(-1, 1) was quietly
  deleting the LAST button in the array instead of doing nothing -- removing the two menu entries
  above would otherwise have silently broken the rest of the Active-account button bar.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-30 21:42:33 +05:30
co-authored by Claude Sonnet 5
parent 44b23ef110
commit e89b1cfc23
17 changed files with 767 additions and 655 deletions
@@ -0,0 +1,126 @@
<!--
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/.
-->
<h2 mat-dialog-title>Additional Share Request</h2>
<mat-dialog-content class="sard-content">
@if (loading) {
<div class="sard-loading">
<mat-spinner diameter="28"></mat-spinner>
<span>Loading request…</span>
</div>
} @else if (!detail) {
<div class="sard-error">{{ errorMsg }}</div>
} @else {
<div class="sard-grid">
<div class="sard-row">
<span>Client</span>
<span>{{ detail.clientName }}</span>
</div>
<div class="sard-row">
<span>Share account</span>
<span class="mono">{{ detail.accountNo }} — {{ detail.productName }}</span>
</div>
<div class="sard-row">
<span>Office</span>
<span>{{ detail.officeName }}</span>
</div>
<div class="sard-row">
<span>Requested shares</span>
<span>{{ detail.totalShares }} @ {{ detail.unitPrice | number: '1.2-2' }}</span>
</div>
<div class="sard-row sard-row--total">
<span>Amount</span>
<span>{{ detail.amount | number: '1.2-2' }}</span>
</div>
@if (detail.chargeAmount > 0) {
<div class="sard-row">
<span>Entry fee</span>
<span>{{ detail.chargeAmount | number: '1.2-2' }}</span>
</div>
}
<div class="sard-row">
<span>Board resolution</span>
<span>{{ detail.resolutionNumber || '—' }}</span>
</div>
<div class="sard-row">
<span>Meeting date</span>
<span>{{ detail.meetingDate || '—' }}</span>
</div>
<div class="sard-row">
<span>Requested on</span>
<span>{{ detail.transactionDate }}</span>
</div>
</div>
<div class="sard-funding">
<div class="sard-funding-label">How it was funded</div>
@if (detail.fundingSource === 'CASH') {
@if (voucherCount > 0) {
<table class="sard-voucher-table">
<thead>
<tr>
<th class="ta-left">Voucher / Receipt</th>
<th class="ta-left">Date</th>
<th class="ta-left">Collected by</th>
<th class="ta-right">Amount</th>
</tr>
</thead>
<tbody>
@for (v of detail.vouchers; track v.id) {
<tr>
<td class="ta-left mono">{{ v.voucherNo || v.receiptNo }}</td>
<td class="ta-left">{{ v.collectionDate }}</td>
<td class="ta-left">{{ v.collectedByName || '—' }}</td>
<td class="ta-right amount-cell">{{ v.amount | number: '1.2-2' }}</td>
</tr>
}
</tbody>
</table>
} @else {
<div class="sard-funding-empty">Cash-funded, but no receipt is linked to this request.</div>
}
} @else if (detail.fundingSource === 'SAVINGS') {
@if (detail.savingsAccount) {
<div class="sard-row">
<span>Savings account</span>
<span class="mono">{{ detail.savingsAccount.accountNo }}</span>
</div>
<div class="sard-row">
<span>Current balance</span>
<span>{{ detail.savingsAccount.balance | number: '1.2-2' }}</span>
</div>
<div class="sard-funding-hint">
This amount was already debited from savings when the tranche was submitted.
</div>
} @else {
<div class="sard-funding-empty">Savings-funded, but no linked savings account could be found.</div>
}
} @else {
<div class="sard-funding-empty">No funding route was recorded for this request.</div>
}
</div>
@if (errorMsg) {
<div class="sard-error">{{ errorMsg }}</div>
}
}
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-button (click)="cancel()" [disabled]="actioning">Close</button>
@if (detail) {
<button mat-stroked-button color="warn" [disabled]="actioning" (click)="reject()">
{{ actioning ? 'Working…' : 'Reject' }}
</button>
<button mat-raised-button color="primary" [disabled]="actioning" (click)="approve()">
{{ actioning ? 'Working…' : 'Approve' }}
</button>
}
</mat-dialog-actions>
@@ -0,0 +1,123 @@
/**
* 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/.
*/
$primary: #1a3a6b;
$border: #d8e0ec;
$muted: #6b7a90;
$danger: #b3261e;
.sard-content {
min-width: 480px;
}
.sard-loading {
display: flex;
align-items: center;
gap: 10px;
padding: 16px 0;
font-size: 13px;
color: $muted;
}
.sard-grid {
display: flex;
flex-direction: column;
gap: 2px;
margin-bottom: 16px;
}
.sard-row {
display: flex;
justify-content: space-between;
gap: 12px;
padding: 5px 0;
font-size: 13px;
border-bottom: 1px solid #f0f3f8;
> span:first-child {
color: $muted;
}
> span:last-child {
color: $primary;
font-weight: 500;
text-align: right;
}
}
.sard-row--total {
> span:last-child {
font-size: 15px;
font-weight: 600;
}
}
.sard-funding {
border: 1px solid $border;
border-radius: 6px;
padding: 12px;
background: #fbfcfe;
}
.sard-funding-label {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.4px;
color: $muted;
margin-bottom: 8px;
}
.sard-funding-empty,
.sard-funding-hint {
font-size: 12px;
color: $muted;
}
.sard-voucher-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
th {
color: $muted;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.4px;
padding: 6px 8px;
text-align: left;
}
td {
padding: 7px 8px;
border-top: 1px solid #eceff4;
}
}
.mono {
font-family: 'Courier New', monospace;
}
.ta-left {
text-align: left;
}
.ta-right {
text-align: right;
}
.amount-cell {
font-variant-numeric: tabular-nums;
font-weight: 500;
}
.sard-error {
color: $danger;
font-size: 13px;
margin-top: 12px;
}
@@ -0,0 +1,128 @@
/**
* 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 { MAT_DIALOG_DATA, MatDialogRef, MatDialogModule } from '@angular/material/dialog';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { catchError } from 'rxjs/operators';
import { of } from 'rxjs';
import { CashierService } from 'app/cashier/cashier.service';
import { SharesService } from 'app/shares/shares.service';
import { SettingsService } from 'app/settings/settings.service';
/**
* Full detail for one additional-share request, opened from the Approval Center list.
*
* Everything a checker needs to decide without leaving the queue: the board resolution, the tranche
* value, and — the piece a bare list row cannot show — the actual proof of funding. A cash tranche
* shows every receipt behind it (a member paying in instalments has more than one), a savings
* tranche shows the account already debited and its balance, so the approval is never a guess about
* where the money came from.
*/
@Component({
selector: 'mifosx-share-addition-request-detail-dialog',
templateUrl: './share-addition-request-detail-dialog.component.html',
styleUrls: ['./share-addition-request-detail-dialog.component.scss'],
standalone: true,
imports: [
CommonModule,
MatDialogModule,
MatButtonModule,
MatIconModule,
MatProgressSpinnerModule
]
})
export class ShareAdditionRequestDetailDialogComponent implements OnInit {
private cashierService = inject(CashierService);
private sharesService = inject(SharesService);
private settingsService = inject(SettingsService);
loading = true;
errorMsg = '';
actioning = false;
detail: any = null;
public dialogRef = inject(MatDialogRef<ShareAdditionRequestDetailDialogComponent>);
public data = inject<{ transactionId: number }>(MAT_DIALOG_DATA);
ngOnInit(): void {
this.cashierService
.getShareAdditionRequest(this.data.transactionId)
.pipe(
catchError((err) => {
this.errorMsg = this.readError(err, 'Could not load this request.');
this.loading = false;
return of(null);
})
)
.subscribe((res: any) => {
if (res) {
this.detail = res;
}
this.loading = false;
});
}
/** How many receipts fund this tranche — the lead one plus any further instalments. */
get voucherCount(): number {
return this.detail?.vouchers?.length || 0;
}
approve(): void {
this.act('approveadditionalshares');
}
reject(): void {
this.act('rejectadditionalshares');
}
private act(command: string): void {
if (this.actioning || !this.detail) {
return;
}
this.actioning = true;
this.errorMsg = '';
const data = {
requestedShares: [{ id: this.detail.transactionId }],
dateFormat: this.settingsService.dateFormat,
locale: this.settingsService.language.code
};
this.sharesService
.executeSharesAccountCommand(this.detail.shareAccountId, command, data)
.pipe(
catchError((err) => {
this.errorMsg = this.readError(err, 'The request could not be actioned.');
this.actioning = false;
return of(null);
})
)
.subscribe((res: any) => {
if (res) {
this.dialogRef.close(true);
} else {
this.actioning = false;
}
});
}
cancel(): void {
this.dialogRef.close(false);
}
private readError(err: any, fallback: string): string {
return (
err?.error?.errors?.[0]?.defaultUserMessage ||
err?.error?.defaultUserMessage ||
err?.error?.developerMessage ||
fallback
);
}
}
@@ -0,0 +1,83 @@
<!--
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="sar-container">
<p class="sar-intro">
Additional-share tranches waiting for approval, across every share account. Click a row for the full request — the
board resolution, how it was funded, and the voucher or savings account behind it.
</p>
@if (errorMsg) {
<div class="sar-error">
<mat-icon>error_outline</mat-icon>
<span>{{ errorMsg }}</span>
</div>
}
@if (loading) {
<div class="sar-loading">
<mat-spinner diameter="28"></mat-spinner>
<span>Loading pending requests…</span>
</div>
} @else if (requests.length === 0) {
<div class="sar-empty">
<mat-icon>info_outline</mat-icon>
<span>No additional-share requests are waiting for approval.</span>
</div>
} @else {
<div class="sar-table-wrap">
<table class="sar-table">
<thead>
<tr>
<th class="ta-left">Client</th>
<th class="ta-left">Account</th>
<th class="ta-right">Shares</th>
<th class="ta-right">Amount</th>
<th class="ta-left">Funding</th>
<th class="ta-left">Resolution</th>
<th class="ta-left">Date</th>
<th class="ta-center">Actions</th>
</tr>
</thead>
<tbody>
@for (req of requests; track req.transactionId) {
<tr (click)="viewDetail(req)">
<td class="ta-left">{{ req.clientName }}</td>
<td class="ta-left mono">{{ req.accountNo }}</td>
<td class="ta-right">{{ req.totalShares }}</td>
<td class="ta-right amount-cell">{{ req.amount | number: '1.2-2' }}</td>
<td class="ta-left">{{ fundingLabel(req) }}</td>
<td class="ta-left">{{ req.resolutionNumber || '—' }}</td>
<td class="ta-left">{{ req.transactionDate }}</td>
<td class="ta-center sar-actions" (click)="$event.stopPropagation()">
<button
mat-stroked-button
color="primary"
type="button"
[disabled]="actioningId === req.transactionId"
(click)="approve(req)"
>
Approve
</button>
<button
mat-stroked-button
color="warn"
type="button"
[disabled]="actioningId === req.transactionId"
(click)="reject(req)"
>
Reject
</button>
</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
@@ -0,0 +1,117 @@
/**
* 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/.
*/
$primary: #1a3a6b;
$border: #d8e0ec;
$muted: #6b7a90;
$danger: #b3261e;
.sar-container {
padding: 8px 0;
}
.sar-intro {
font-size: 13px;
color: $muted;
margin: 0 0 14px;
}
.sar-loading,
.sar-empty {
display: flex;
align-items: center;
gap: 10px;
padding: 16px;
font-size: 13px;
color: $muted;
background: #f4f7fb;
border: 1px solid $border;
border-radius: 6px;
}
.sar-error {
display: flex;
align-items: center;
gap: 8px;
background: #fdecea;
color: $danger;
border: 1px solid #f5c6c2;
border-radius: 6px;
padding: 10px 12px;
font-size: 13px;
margin-bottom: 12px;
}
.sar-table-wrap {
overflow-x: auto;
}
.sar-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
border: 1px solid $border;
border-radius: 6px;
th {
background: #eef2f8;
color: $primary;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.4px;
padding: 9px 12px;
white-space: nowrap;
}
td {
padding: 10px 12px;
border-bottom: 1px solid #eceff4;
color: #1f2937;
white-space: nowrap;
}
tbody tr {
cursor: pointer;
&:hover {
background: #f4f7fb;
}
}
tbody tr:last-child td {
border-bottom: none;
}
}
.mono {
font-family: 'Courier New', monospace;
}
.ta-left {
text-align: left;
}
.ta-right {
text-align: right;
}
.ta-center {
text-align: center;
}
.amount-cell {
font-variant-numeric: tabular-nums;
font-weight: 500;
}
.sar-actions {
display: flex;
gap: 8px;
justify-content: center;
}
@@ -0,0 +1,165 @@
/**
* 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 { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatDialog } from '@angular/material/dialog';
import { catchError } from 'rxjs/operators';
import { of } from 'rxjs';
import { CashierService } from 'app/cashier/cashier.service';
import { SharesService } from 'app/shares/shares.service';
import { SettingsService } from 'app/settings/settings.service';
import { ShareAdditionRequestDetailDialogComponent } from './share-addition-request-detail-dialog/share-addition-request-detail-dialog.component';
/**
* Approval Center — Share Addition Requests tab.
*
* Every additional-share tranche across every member, waiting on the same
* {@code approveadditionalshares}/{@code rejectadditionalshares} commands that used to be reachable only from a
* single account's own page. A checker working through pending items no longer has to know which account to open —
* every request queued across the whole membership is one list here, and clicking a row opens the full detail (the
* board resolution, how it was funded, and — for cash — which voucher, or — for savings — which account was already
* debited) before deciding.
*/
@Component({
selector: 'mifosx-share-addition-requests',
templateUrl: './share-addition-requests.component.html',
styleUrls: ['./share-addition-requests.component.scss'],
standalone: true,
imports: [
CommonModule,
MatButtonModule,
MatIconModule,
MatProgressSpinnerModule
]
})
export class ShareAdditionRequestsComponent implements OnInit {
private cashierService = inject(CashierService);
private sharesService = inject(SharesService);
private settingsService = inject(SettingsService);
private dialog = inject(MatDialog);
loading = true;
errorMsg = '';
requests: any[] = [];
actioningId: number | null = null;
ngOnInit(): void {
this.load();
}
load(): void {
this.loading = true;
this.errorMsg = '';
this.cashierService
.getShareAdditionRequests()
.pipe(
catchError((err) => {
this.errorMsg = this.readError(err, 'Could not load pending additional-share requests.');
return of([]);
})
)
.subscribe((res: any) => {
this.requests = Array.isArray(res) ? res : [];
this.loading = false;
});
}
/** How many receipts fund a cash tranche — the lead one plus any further instalments. */
voucherCount(req: any): number {
if (!req?.advanceCollectionId) {
return 0;
}
const extra = (req.additionalCollectionIds || '')
.split(',')
.map((part: string) => part.trim())
.filter((part: string) => part.length > 0);
return 1 + extra.length;
}
fundingLabel(req: any): string {
switch (req?.fundingSource) {
case 'CASH': {
const count = this.voucherCount(req);
return count > 1 ? `Cash (${count} vouchers)` : 'Cash';
}
case 'SAVINGS':
return 'Savings';
default:
return '—';
}
}
/**
* Opens the full-detail popup for one request. Approving or rejecting from inside it refreshes
* this list on close, the same way the account-page tables refresh after acting on a row.
*/
viewDetail(req: any): void {
const dialogRef = this.dialog.open(ShareAdditionRequestDetailDialogComponent, {
width: '640px',
data: { transactionId: req.transactionId }
});
dialogRef.afterClosed().subscribe((actioned: boolean) => {
if (actioned) {
this.load();
}
});
}
/** Approves directly from the list row, for a request the checker does not need the detail popup for. */
approve(req: any): void {
if (this.actioningId) {
return;
}
this.actioningId = req.transactionId;
this.runCommand(req, 'approveadditionalshares');
}
reject(req: any): void {
if (this.actioningId) {
return;
}
this.actioningId = req.transactionId;
this.runCommand(req, 'rejectadditionalshares');
}
private runCommand(req: any, command: string): void {
const data = {
requestedShares: [{ id: req.transactionId }],
dateFormat: this.settingsService.dateFormat,
locale: this.settingsService.language.code
};
this.sharesService
.executeSharesAccountCommand(req.shareAccountId, command, data)
.pipe(
catchError((err) => {
this.errorMsg = this.readError(err, 'The request could not be actioned.');
this.actioningId = null;
return of(null);
})
)
.subscribe((res: any) => {
this.actioningId = null;
if (res) {
this.load();
}
});
}
private readError(err: any, fallback: string): string {
return (
err?.error?.errors?.[0]?.defaultUserMessage ||
err?.error?.defaultUserMessage ||
err?.error?.developerMessage ||
fallback
);
}
}
@@ -1,109 +0,0 @@
<!--
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>
@@ -1,32 +0,0 @@
/**
* 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;
}
@@ -1,120 +0,0 @@
/**
* 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);
}
}
@@ -166,12 +166,12 @@
</a>
<a
class="ac-tab"
routerLink="./share-savings-debit-requests"
routerLink="./share-addition-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>
<span>Share Addition Requests</span>
</a>
<a
class="ac-tab"
+4 -4
View File
@@ -19,7 +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 { ShareAdditionRequestsComponent } from './checker-inbox-and-tasks-tabs/share-addition-requests/share-addition-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';
@@ -183,9 +183,9 @@ const routes: Routes = [
data: { title: 'Dividend Requests' }
},
{
path: 'share-savings-debit-requests',
component: ShareSavingsDebitRequestsComponent,
data: { title: 'Share Savings Debit Requests' }
path: 'share-addition-requests',
component: ShareAdditionRequestsComponent,
data: { title: 'Share Addition Requests' }
},
{
path: 'deposit-requests',