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:
-75
@@ -1,75 +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/.
|
||||
-->
|
||||
|
||||
<h2 mat-dialog-title>Debit from Savings</h2>
|
||||
|
||||
<mat-dialog-content class="dfs-content">
|
||||
@if (loading) {
|
||||
<div class="dfs-loading">
|
||||
<mat-spinner diameter="28"></mat-spinner>
|
||||
<span>Checking the member's savings account…</span>
|
||||
</div>
|
||||
} @else if (errorMsg && !eligibility) {
|
||||
<div class="dfs-error">{{ errorMsg }}</div>
|
||||
} @else if (eligibility) {
|
||||
<!-- What the settlement costs, split the way it will be posted: share value to Share Capital,
|
||||
entry fee to fee income. -->
|
||||
<div class="dfs-breakdown">
|
||||
<div class="dfs-row">
|
||||
<span>Share value</span>
|
||||
<span>{{ eligibility.shareAmount | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
@if (eligibility.chargeAmount > 0) {
|
||||
<div class="dfs-row">
|
||||
<span>Entry fee</span>
|
||||
<span>{{ eligibility.chargeAmount | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
}
|
||||
<div class="dfs-row dfs-row--total">
|
||||
<span>To debit from savings</span>
|
||||
<span>{{ eligibility.totalRequired | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
@if (eligibility.savingsAccountNo) {
|
||||
<div class="dfs-row dfs-row--detail">
|
||||
<span>Savings account {{ eligibility.savingsAccountNo }}</span>
|
||||
<span>Balance {{ eligibility.savingsBalance | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (!eligibility.eligible) {
|
||||
<div class="dfs-error">{{ eligibility.reason }}</div>
|
||||
} @else {
|
||||
<label class="dfs-field">
|
||||
<span>Note (optional)</span>
|
||||
<input type="text" [(ngModel)]="note" maxlength="500" placeholder="Anything to record against this request" />
|
||||
</label>
|
||||
<!-- Says plainly that submitting does not move money, so the clerk does not tell the member
|
||||
their shares are settled when a manager still has to approve it. -->
|
||||
<p class="dfs-hint">
|
||||
Submitting records a request for a manager to approve. The member's savings are debited only once it is
|
||||
approved.
|
||||
</p>
|
||||
@if (errorMsg) {
|
||||
<div class="dfs-error">{{ errorMsg }}</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
</mat-dialog-content>
|
||||
|
||||
<mat-dialog-actions align="end">
|
||||
<button mat-stroked-button (click)="cancel()">Cancel</button>
|
||||
<button
|
||||
mat-raised-button
|
||||
color="primary"
|
||||
[disabled]="loading || submitting || !eligibility?.eligible"
|
||||
(click)="submit()"
|
||||
>
|
||||
{{ submitting ? 'Submitting…' : 'Submit for Approval' }}
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
-98
@@ -1,98 +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/.
|
||||
*/
|
||||
|
||||
.dfs-content {
|
||||
min-width: 420px;
|
||||
}
|
||||
|
||||
.dfs-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px 0;
|
||||
color: #475569;
|
||||
font-size: 13.5px;
|
||||
}
|
||||
|
||||
/* What the member is being charged, laid out as label/amount pairs so the total reads down the
|
||||
column the way the posting splits it. */
|
||||
.dfs-breakdown {
|
||||
margin: 8px 0 16px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 6px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.dfs-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 3px 0;
|
||||
font-size: 13.5px;
|
||||
color: #334155;
|
||||
|
||||
> span:last-child {
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&--total {
|
||||
margin-top: 6px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid #cbd5e1;
|
||||
font-weight: 600;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
&--detail {
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
}
|
||||
}
|
||||
|
||||
.dfs-field {
|
||||
display: block;
|
||||
margin-bottom: 10px;
|
||||
|
||||
> span {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
font-size: 12.5px;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 4px;
|
||||
font-size: 13.5px;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: #2563eb;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dfs-hint {
|
||||
margin: 0 0 4px;
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.dfs-error {
|
||||
margin: 8px 0 0;
|
||||
padding: 9px 12px;
|
||||
border-radius: 6px;
|
||||
background: #fdecea;
|
||||
color: #b42318;
|
||||
font-size: 13px;
|
||||
}
|
||||
-121
@@ -1,121 +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, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
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';
|
||||
|
||||
/**
|
||||
* Settles a share application out of the member's own savings account.
|
||||
*
|
||||
* The cash route (Apply Collection) starts at the counter, with money the cashier has already
|
||||
* received sitting in Share Sundry against a receipt. A member who banks with the society has no
|
||||
* reason to withdraw cash and hand it straight back, so this debits their savings directly for the
|
||||
* share value plus any entry fee.
|
||||
*
|
||||
* Nothing moves when this dialog is submitted: it records a request for a manager to approve. The
|
||||
* cash route needs no checker because the cashier's receipt is already a second pair of eyes; here
|
||||
* nobody has counted anything, so the approval is the first moment the member's balance changes.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-debit-from-savings-dialog',
|
||||
templateUrl: './debit-from-savings-dialog.component.html',
|
||||
styleUrls: ['./debit-from-savings-dialog.component.scss'],
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
MatDialogModule,
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatProgressSpinnerModule
|
||||
]
|
||||
})
|
||||
export class DebitFromSavingsDialogComponent implements OnInit {
|
||||
private cashierService = inject(CashierService);
|
||||
|
||||
loading = true;
|
||||
submitting = false;
|
||||
errorMsg = '';
|
||||
|
||||
/**
|
||||
* What the settlement would cost and whether the savings account can cover it. `eligible` false
|
||||
* carries a `reason` naming the blocker — no linked savings, already settled, short balance.
|
||||
*/
|
||||
eligibility: {
|
||||
eligible: boolean;
|
||||
reason: string | null;
|
||||
shareAmount: number;
|
||||
chargeAmount: number;
|
||||
totalRequired: number;
|
||||
savingsAccountNo: string | null;
|
||||
savingsBalance: number | null;
|
||||
} | null = null;
|
||||
|
||||
note = '';
|
||||
|
||||
public dialogRef = inject(MatDialogRef<DebitFromSavingsDialogComponent>);
|
||||
public data = inject<{ shareAccountId: number; clientId: number; accountNo: string }>(MAT_DIALOG_DATA);
|
||||
|
||||
ngOnInit(): void {
|
||||
this.cashierService
|
||||
.getShareSavingsDebitEligibility(this.data.shareAccountId)
|
||||
.pipe(catchError(() => of(null)))
|
||||
.subscribe((res: any) => {
|
||||
this.loading = false;
|
||||
if (!res) {
|
||||
this.errorMsg = 'Could not check whether this account can be settled from savings.';
|
||||
return;
|
||||
}
|
||||
this.eligibility = {
|
||||
eligible: !!res.eligible,
|
||||
reason: res.reason ?? null,
|
||||
shareAmount: Number(res.shareAmount) || 0,
|
||||
chargeAmount: Number(res.chargeAmount) || 0,
|
||||
totalRequired: Number(res.totalRequired) || 0,
|
||||
savingsAccountNo: res.savingsAccountNo ?? null,
|
||||
savingsBalance: res.savingsBalance == null ? null : Number(res.savingsBalance)
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
submit(): void {
|
||||
if (!this.eligibility?.eligible || this.submitting) {
|
||||
return;
|
||||
}
|
||||
this.submitting = true;
|
||||
this.errorMsg = '';
|
||||
this.cashierService
|
||||
.submitShareSavingsDebit({
|
||||
shareAccountId: this.data.shareAccountId,
|
||||
note: this.note?.trim() || undefined
|
||||
})
|
||||
.subscribe({
|
||||
next: (res: any) => {
|
||||
this.submitting = false;
|
||||
this.dialogRef.close(res);
|
||||
},
|
||||
error: (err: any) => {
|
||||
this.submitting = false;
|
||||
this.errorMsg =
|
||||
err?.error?.defaultUserMessage || err?.error?.message || 'Could not submit the savings-debit request.';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
this.dialogRef.close();
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,6 @@ import { MatDialog } from '@angular/material/dialog';
|
||||
import { DeleteDialogComponent } from 'app/shared/delete-dialog/delete-dialog.component';
|
||||
import { ShareCertificateDialogComponent } from './share-certificate-dialog/share-certificate-dialog.component';
|
||||
import { ApplyCollectionDialogComponent } from './apply-collection-dialog/apply-collection-dialog.component';
|
||||
import { DebitFromSavingsDialogComponent } from './debit-from-savings-dialog/debit-from-savings-dialog.component';
|
||||
|
||||
/** Custom Services */
|
||||
import { SharesService } from '../shares.service';
|
||||
@@ -144,20 +143,6 @@ export class SharesAccountViewComponent implements OnInit {
|
||||
});
|
||||
}
|
||||
if (status === 'Active') {
|
||||
const purchasedShares: any[] = this.sharesAccountData.purchasedShares;
|
||||
let sharesPendingForApproval = false;
|
||||
purchasedShares.forEach((share: any) => {
|
||||
if (
|
||||
share.status.code === 'purchasedSharesStatusType.applied' &&
|
||||
share.type.code === 'purchasedSharesType.purchased'
|
||||
) {
|
||||
sharesPendingForApproval = true;
|
||||
}
|
||||
});
|
||||
if (!sharesPendingForApproval) {
|
||||
this.buttonConfig.removeButton('Approve Additional Shares');
|
||||
this.buttonConfig.removeButton('Reject Additional Shares');
|
||||
}
|
||||
// An Active account can carry "approved" shares nobody has actually paid for yet -- account
|
||||
// creation, approval and activation post no GL of their own; only applying a Share Advance
|
||||
// Collection does (see isSettled). A certificate for shares still unsettled would certify
|
||||
@@ -194,9 +179,6 @@ export class SharesAccountViewComponent implements OnInit {
|
||||
case 'Apply Collection':
|
||||
this.applyCollection();
|
||||
break;
|
||||
case 'Debit from Savings':
|
||||
this.debitFromSavings();
|
||||
break;
|
||||
case 'Share Certificate':
|
||||
this.viewShareCertificate();
|
||||
break;
|
||||
@@ -247,29 +229,12 @@ export class SharesAccountViewComponent implements OnInit {
|
||||
* Neither the account's status nor the purchase transaction's status can stand in for this: an
|
||||
* account goes Approved then Activated before anything is settled, and the purchase reads
|
||||
* Approved from the moment the account is approved. Only `collectionApplied` marks the point at
|
||||
* which the money was actually taken — by cash collection or by debiting savings.
|
||||
* which the money was actually taken by cash collection.
|
||||
*/
|
||||
get isSettled(): boolean {
|
||||
return !!this.sharesAccountData?.collectionApplied;
|
||||
}
|
||||
|
||||
/**
|
||||
* Settles this account out of the member's linked savings instead of cash at the counter.
|
||||
*
|
||||
* Only records a request — a manager's approval is what debits the member and issues the shares —
|
||||
* so unlike Apply Collection there is nothing to refresh here until that approval happens.
|
||||
*/
|
||||
private debitFromSavings() {
|
||||
this.dialog.open(DebitFromSavingsDialogComponent, {
|
||||
width: '560px',
|
||||
data: {
|
||||
shareAccountId: this.sharesAccountData.id,
|
||||
clientId: this.sharesAccountData.clientId,
|
||||
accountNo: this.sharesAccountData.accountNo
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes Shares Account.
|
||||
*/
|
||||
|
||||
@@ -43,32 +43,17 @@ export class SharesButtonsConfiguration {
|
||||
icon: 'hand-holding-usd',
|
||||
taskPermissionName: 'CREATE_JOURNALENTRY'
|
||||
},
|
||||
{
|
||||
// The same settlement for a member who banks here: debits their linked savings for the
|
||||
// share value plus entry fee instead of taking cash at the counter. Raises a request
|
||||
// rather than posting, because no cashier has counted anything to check it against.
|
||||
name: 'Debit from Savings',
|
||||
icon: 'money-check-alt',
|
||||
taskPermissionName: 'CREATE_JOURNALENTRY'
|
||||
},
|
||||
{
|
||||
name: 'Apply Additional Shares',
|
||||
icon: 'arrow-right',
|
||||
taskPermissionName: 'APPLYADDITIONAL_SHAREACCOUNT'
|
||||
},
|
||||
{
|
||||
name: 'Approve Additional Shares',
|
||||
icon: 'arrow-right',
|
||||
taskPermissionName: 'APPROVEADDITIONAL_SHAREACCOUNT'
|
||||
},
|
||||
{
|
||||
name: 'Reject Additional Shares',
|
||||
icon: 'arrow-left',
|
||||
taskPermissionName: 'REJECTADDITIONAL_SHAREACCOUNT'
|
||||
}
|
||||
// 'Redeem Shares' is intentionally not offered here — a share withdrawal must go
|
||||
// through the Clerk Dashboard maker-checker flow (Clerk Dashboard > My Share Requests),
|
||||
// never a direct one-step action from this view.
|
||||
// 'Approve Additional Shares' / 'Reject Additional Shares' are intentionally not offered
|
||||
// here — every pending tranche across every account is reviewed and actioned from the
|
||||
// Approval Center's Share Addition Requests queue instead, so a checker never has to know
|
||||
// which account to open. 'Redeem Shares' is likewise not offered here — a share withdrawal
|
||||
// must go through the Clerk Dashboard maker-checker flow (Clerk Dashboard > My Share
|
||||
// Requests), never a direct one-step action from this view.
|
||||
];
|
||||
break;
|
||||
case 'Submitted and pending approval':
|
||||
@@ -147,7 +132,11 @@ export class SharesButtonsConfiguration {
|
||||
removeButton(name: string) {
|
||||
const buttonNames = this.buttonsArray.map((entry) => entry.name);
|
||||
const index = buttonNames.indexOf(name);
|
||||
this.buttonsArray.splice(index, 1);
|
||||
// -1 (not found) fed straight to splice would delete the LAST button instead of doing nothing —
|
||||
// splice treats a negative index as "from the end", not "no match".
|
||||
if (index > -1) {
|
||||
this.buttonsArray.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
removeOption(name: string) {
|
||||
|
||||
Reference in New Issue
Block a user