Files
cbsnexora/src/app/clerk/share-withdrawal-request/share-withdrawal-request.component.ts
T
nitesh 01e86173c1
build-run / Run Lint, Build and Deploy (push) Canceled after 0s
Publish Image in Docker Hub / build (push) Canceled after 0s
Playwright E2E Tests / Run Playwright Tests (push) Canceled after 0s
Tests / testRigor (push) Canceled after 0s
feat(shares): close share account directly on withdrawal approval; drop separate closure-request flow
Replaces the auto-submit-closure-request behavior with an immediate
account close, in the same approval action, when a withdrawal brings
shares to zero — matching the backend's new single-step close-on-
approval flow. Removes the now-unneeded Share Closure Request feature
entirely: dedicated create/list components (Clerk Dashboard + Approval
Center), routes, nav links, and the "eligible for closure" banner on
the Share Account view.

Also adds a clear "this will close the account" notice wherever a
withdrawal would zero out the account's shares — the standalone Share
Withdrawal Request page (submit + review) and Transaction Entry's
Share Redemption Transfer confirm dialog — styled as an amber warning
callout rather than a plain info line. Trims that confirm dialog to
just the required details (dropped the redundant ECS A/C No. line and
the validation-checklist block) and compacts its overall size.
2026-08-06 19:27:51 +05:30

417 lines
14 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, EventEmitter, Input, OnInit, Output, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { MatIconModule } from '@angular/material/icon';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { catchError } from 'rxjs/operators';
import { of } from 'rxjs';
import { SharesService } from '../../shares/shares.service';
import { ClerkShareWithdrawalService } from '../../cashier/clerk-share-withdrawal.service';
import { CashierService } from '../../cashier/cashier.service';
import { SearchSelectComponent, SearchSelectOption } from '../../shared/search-select/search-select.component';
export type ShareWdMode = 'create' | 'review';
interface ActivityEntry {
timestamp: string;
action: string;
user: string;
note?: string;
}
/**
* Share Withdrawal (redeem shares) request — maker screen in the Clerk Dashboard. A clerk finds
* the member's Share Account, enters how many shares to withdraw plus the governing Board
* Resolution Number / Date of Meeting, and submits for Bank Manager approval. This is the ONLY
* entry point a share withdrawal should go through — approval server-side executes the real
* "redeemshares" command (see ClerkShareWithdrawalRequestApiResource on the backend).
*/
@Component({
selector: 'mifosx-share-withdrawal-request',
templateUrl: './share-withdrawal-request.component.html',
styleUrls: [
'../../cashier/cashier-shared.scss',
'../clerk-shared.scss',
'./share-withdrawal-request.component.scss'
],
standalone: true,
imports: [
CommonModule,
FormsModule,
RouterModule,
MatIconModule,
MatSnackBarModule,
SearchSelectComponent
]
})
export class ShareWithdrawalRequestComponent implements OnInit {
private route = inject(ActivatedRoute);
private router = inject(Router);
private sharesService = inject(SharesService);
private wdService = inject(ClerkShareWithdrawalService);
private cashierService = inject(CashierService);
private snackBar = inject(MatSnackBar);
/** When embedded (e.g. opened as a popup from a list screen instead of routed to directly), mode
* and requestId come from these inputs rather than the route, and success/close actions emit
* events instead of navigating away. */
@Input() embedded = false;
@Input() overrideMode: ShareWdMode | null = null;
@Input() overrideRequestId: number | null = null;
@Output() closeRequested = new EventEmitter<void>();
@Output() actionCompleted = new EventEmitter<void>();
mode: ShareWdMode = 'create';
requestId: number | null = null;
submitting = false;
loadingAccount = false;
selectedAccountId = '';
accountOptions: SearchSelectOption[] = [];
account: any = null;
requestedShares: number | null = null;
requestedDate = '';
resolutionNumber = '';
meetingDate = '';
description = '';
paymentTypes: any[] = [];
settlementPaymentTypeId: number | null = null;
activityLog: ActivityEntry[] = [];
reviewRequest: any = null;
showRejectDialog = false;
rejectionReason = '';
showConfirmDialog = false;
showValidateModal = false;
validating = false;
validateAllPassed = false;
backendChecks: { label: string; pass: boolean; detail: string }[] | null = null;
validateSteps: { label: string; detail: string; pass: boolean; status: 'pending' | 'active' | 'done' | 'failed' }[] =
[];
get remainingShares(): number {
const held = Number(this.account?.summary?.totalApprovedShares ?? this.account?.totalApprovedShares ?? 0);
return held - (this.requestedShares || 0);
}
get unitPrice(): number {
return Number(this.account?.currentMarketPrice ?? 0);
}
get withdrawalValue(): number {
return (this.requestedShares || 0) * this.unitPrice;
}
get paymentTypeOptions(): any[] {
return this.paymentTypes.filter((pt: any) => !pt.isCashPayment);
}
get selectedSettlementPaymentType(): any {
return this.paymentTypes.find((pt: any) => pt.id === this.settlementPaymentTypeId);
}
get settlementPaymentTypeName(): string {
if (this.isReview) {
return this.reviewRequest?.settlement_payment_type_name || '—';
}
return this.selectedSettlementPaymentType?.name || '—';
}
get isFormValid(): boolean {
if (!this.account) return false;
if (!this.requestedShares || this.requestedShares <= 0) return false;
const held = Number(this.account?.summary?.totalApprovedShares ?? this.account?.totalApprovedShares ?? 0);
if (this.requestedShares > held) return false;
if (!this.requestedDate) return false;
if (!this.resolutionNumber.trim()) return false;
if (!this.meetingDate) return false;
if (!this.settlementPaymentTypeId) return false;
return true;
}
/** Shown to the clerk before submit and to the manager before approval, so both can verify the same checks. */
get verificationChecks(): { label: string; pass: boolean; detail: string }[] {
const held = Number(this.account?.summary?.totalApprovedShares ?? this.account?.totalApprovedShares ?? 0);
const shares = this.requestedShares || 0;
return [
{
label: 'Board Resolution Number provided',
pass: !!this.resolutionNumber?.trim(),
detail: this.resolutionNumber?.trim() || 'Missing'
},
{ label: 'Date of Meeting provided', pass: !!this.meetingDate, detail: this.meetingDate || 'Missing' },
{
label: 'Shares requested within shares held',
pass: shares > 0 && shares <= held,
detail: `${shares} of ${held} shares held`
},
{
label: 'Linked savings account confirmed',
pass: !!this.account?.savingsAccountNumber,
detail: this.account?.savingsAccountNumber || 'No linked savings account — settlement cannot run automatically'
},
{
label: 'Settlement Payment Type selected (non-cash)',
pass: this.settlementPaymentTypeName !== '—',
detail: this.settlementPaymentTypeName
}
];
}
get isReview(): boolean {
return this.mode === 'review';
}
get pageTitle(): string {
return this.isReview ? 'Review Share Withdrawal Request' : 'New Share Withdrawal Request';
}
get pageSubtitle(): string {
return this.isReview
? "Approve or reject the clerk's share withdrawal request."
: 'Submit a share withdrawal (redeem shares) request for Bank Manager approval.';
}
ngOnInit(): void {
this.mode = this.overrideMode ?? (this.route.snapshot.data?.['mode'] as ShareWdMode) ?? 'create';
const idParam = this.overrideRequestId ?? this.route.snapshot.params?.['id'];
this.requestId = idParam ? Number(idParam) : null;
const today = new Date();
this.requestedDate = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
this.meetingDate = this.requestedDate;
this.sharesService
.getShareAccountSummaries()
.pipe(catchError(() => of([])))
.subscribe((rows: any[]) => {
this.accountOptions = (rows || [])
.filter((r) => (r.status?.value || '').toLowerCase() === 'active')
.map((r) => ({
value: String(r.id),
label: `${r.accountNo} — ${r.clientName} (${r.totalApprovedShares} shares)`,
searchText: `${r.accountNo} ${r.clientName}`
}));
});
this.cashierService
.getPaymentTypes()
.pipe(catchError(() => of([])))
.subscribe((types: any[]) => (this.paymentTypes = types ?? []));
if (this.requestId && this.mode === 'review') {
this.loadReviewRequest(this.requestId);
}
}
onAccountSelected(accountId: string): void {
this.selectedAccountId = accountId;
if (!accountId) {
this.account = null;
return;
}
this.loadingAccount = true;
this.sharesService
.getSharesAccountData(accountId, false)
.pipe(catchError(() => of(null)))
.subscribe((acc: any) => {
this.account = acc;
this.loadingAccount = false;
});
}
private loadReviewRequest(id: number): void {
this.wdService.getById(id).subscribe({
next: (req: any) => {
this.reviewRequest = req;
this.requestedShares = Number(req.requested_shares);
this.requestedDate = req.requested_date
? String(req.requested_date).split('T')[0].split(' ')[0]
: this.requestedDate;
this.resolutionNumber = req.resolution_number ?? '';
this.meetingDate = req.meeting_date ? String(req.meeting_date).split('T')[0].split(' ')[0] : this.meetingDate;
this.description = req.description ?? '';
if (req.activity_log) {
try {
this.activityLog = JSON.parse(req.activity_log);
} catch {
this.activityLog = [];
}
}
this.sharesService
.getSharesAccountData(String(req.share_account_id), false)
.pipe(catchError(() => of(null)))
.subscribe((acc: any) => (this.account = acc));
},
error: () => this.snackBar.open('Failed to load share withdrawal request.', 'Close', { duration: 5000 })
});
}
/** "Review before Save" per requirement — opens the confirm dialog instead of submitting directly. */
openConfirmDialog(): void {
if (!this.isFormValid || this.submitting) {
return;
}
this.showConfirmDialog = true;
}
cancelConfirmDialog(): void {
this.showConfirmDialog = false;
}
confirmSubmit(): void {
this.showConfirmDialog = false;
this.submitting = true;
const payload = {
shareAccountId: Number(this.selectedAccountId),
accountNo: this.account?.accountNo,
clientId: this.account?.clientId,
clientName: this.account?.clientName,
requestedShares: this.requestedShares,
requestedDate: this.requestedDate,
resolutionNumber: this.resolutionNumber.trim(),
meetingDate: this.meetingDate,
description: this.description,
settlementPaymentTypeId: this.settlementPaymentTypeId
};
this.wdService.submit(payload).subscribe({
next: () => {
this.snackBar.open('Share withdrawal request submitted for approval.', 'Close', { duration: 4000 });
if (this.embedded) {
this.actionCompleted.emit();
} else {
this.router.navigate(['/clerk/dashboard']);
}
},
error: (err) => {
this.submitting = false;
this.snackBar.open(err?.error?.defaultUserMessage || 'Could not submit the request.', 'Close', {
duration: 6000
});
}
});
}
openValidateModal(): void {
if (!this.reviewRequest) return;
this.showValidateModal = true;
this.runBackendValidation();
}
closeValidateModal(): void {
if (this.validating) return;
this.showValidateModal = false;
}
/** Re-checks live backend state (account still active, GL mapping still configured, etc.) rather
* than relying on data already loaded in the browser — catches drift since submission. Results
* are revealed one step at a time (see revealValidateSteps()) for the pipeline-style animation. */
runBackendValidation(): void {
if (!this.reviewRequest || this.validating) return;
this.validating = true;
this.backendChecks = null;
this.validateSteps = [];
this.wdService.validate(this.reviewRequest.id).subscribe({
next: (res: any) => {
const checks = (res?.checks ?? []) as { label: string; pass: boolean; detail: string }[];
this.backendChecks = checks;
this.validateAllPassed = !!res?.allPassed;
this.validateSteps = checks.map((c) => ({ ...c, status: 'pending' as const }));
this.revealValidateSteps(0);
},
error: (err) => {
this.validating = false;
this.snackBar.open(err?.error?.defaultUserMessage || 'Could not run backend validation.', 'Close', {
duration: 6000
});
}
});
}
private revealValidateSteps(index: number): void {
if (index >= this.validateSteps.length) {
this.validating = false;
return;
}
this.validateSteps[index].status = 'active';
setTimeout(() => {
this.validateSteps[index].status = this.validateSteps[index].pass ? 'done' : 'failed';
this.revealValidateSteps(index + 1);
}, 320);
}
approveReview(): void {
if (!this.reviewRequest) return;
this.wdService.approve(this.reviewRequest.id).subscribe({
next: (res: any) => {
let message =
res?.settlementAmount != null
? `Share withdrawal approved. ₹${Number(res.settlementAmount).toLocaleString('en-IN', { minimumFractionDigits: 2 })} credited to the linked savings account.`
: (res?.settlementWarning ?? 'Share withdrawal approved and processed.');
if (res?.accountClosed) {
message += ' All shares withdrawn — the share account has been closed.';
}
this.snackBar.open(message, 'Close', { duration: 8000 });
if (this.embedded) {
this.actionCompleted.emit();
} else {
this.router.navigate(['/checker-inbox-and-tasks/share-withdrawal-requests']);
}
},
error: (err) =>
this.snackBar.open(err?.error?.defaultUserMessage || 'Could not approve the request.', 'Close', {
duration: 6000
})
});
}
openRejectDialog(): void {
this.showRejectDialog = true;
this.rejectionReason = '';
}
confirmRejectReview(): void {
if (!this.reviewRequest || !this.rejectionReason.trim()) return;
this.wdService.reject(this.reviewRequest.id, this.rejectionReason.trim()).subscribe({
next: () => {
this.showRejectDialog = false;
this.snackBar.open('Share withdrawal request rejected.', 'Close', { duration: 4000 });
if (this.embedded) {
this.actionCompleted.emit();
} else {
this.router.navigate(['/checker-inbox-and-tasks/share-withdrawal-requests']);
}
},
error: (err) =>
this.snackBar.open(err?.error?.defaultUserMessage || 'Could not reject the request.', 'Close', {
duration: 6000
})
});
}
goBack(): void {
if (this.embedded) {
this.closeRequested.emit();
return;
}
this.router.navigate(this.isReview ? ['/checker-inbox-and-tasks/share-withdrawal-requests'] : ['/clerk/dashboard']);
}
}