The Clerk Dashboard's standalone Share Withdrawal Request form never offered a GL settlement destination -- it always submitted SAVINGS, silently, while the Cashier's Transaction Entry screen already let a clerk pick any postable GL account on its CR row. Added the same "Settlement Destination" choice (Savings / GL Account) with a searchable GL selector here too, so both entry points to the same backend endpoint behave the same way. Both share-withdrawal confirm dialogs (savings settlement and GL settlement, in transaction-entry.component.ts) now call the backend's new preview-validate endpoint before showing the confirm dialog, so a clerk sees the same checklist a manager will see on approval -- a missing GL mapping, an unverified KYC, an open AML alert -- before committing to the request, not after a manager has already spent time reviewing it. A failing check is shown, not blocked: real hard rules are still enforced server-side at approve(). Guarded with a 5s timeout and a fallback message so a slow or unavailable check can never leave the dialog looking stuck. error-handler.interceptor.ts: a document/signature attachment whose row survives but whose file is missing from the content store now resolves to a silent 404 (matching the existing image-fetch handling) instead of surfacing a raw error. Background GET failures (500/501/503) on calls a component already recovers from (photo, attachment, notes, standing instructions, ...) now surface as a corner toast instead of a blocking modal that has to be dismissed -- a write action's failure (POST/PUT/DELETE) still gets the centred, must-acknowledge dialog, since that is the one thing the user was waiting on. Also surfaces the backend's real message on a 500 instead of a hardcoded generic sentence. Backend counterpart: ClerkShareWithdrawalRequestApiResource (preview-validate endpoint, shared buildShareWithdrawalChecks()) and DocumentReadPlatformServiceImpl (missing-file 404) land separately. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
485 lines
18 KiB
TypeScript
485 lines
18 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, UntypedFormControl } 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';
|
|
import { GlAccountSelectorComponent } from '../../shared/accounting/gl-account-selector/gl-account-selector.component';
|
|
import { AccountingService } from '../../accounting/accounting.service';
|
|
import { GLAccount } from '../../shared/models/general.model';
|
|
|
|
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,
|
|
GlAccountSelectorComponent
|
|
]
|
|
})
|
|
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 accountingService = inject(AccountingService);
|
|
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;
|
|
|
|
/** Where the redeemed value goes. 'SAVINGS' (the long-standing default) credits the member's
|
|
* linked savings account. 'GL' lets the clerk pick any postable GL account directly — same
|
|
* option the Cashier's Transaction Entry screen already offers on its CR row, now available
|
|
* here too so both entry points behave the same way. Naming the product's own Share Sundry GL
|
|
* here is still fine: the backend recognises it and books straight to Sundry with no further
|
|
* settlement leg (see ClerkShareWithdrawalRequestApiResource#submit). */
|
|
settlementMode: 'SAVINGS' | 'GL' = 'SAVINGS';
|
|
settlementGlAccountId: number | null = null;
|
|
glAccountsData: GLAccount[] = [];
|
|
toGlAccountControl = new UntypedFormControl('');
|
|
|
|
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 || '—';
|
|
}
|
|
|
|
/** The chosen GL account's display name, for the confirm dialog and the checklist. Falls back to
|
|
* the raw id while the GL account list is still loading. */
|
|
get selectedSettlementGlName(): string {
|
|
if (!this.settlementGlAccountId) {
|
|
return '—';
|
|
}
|
|
return (
|
|
this.glAccountsData.find((g) => g.id === this.settlementGlAccountId)?.name || `GL #${this.settlementGlAccountId}`
|
|
);
|
|
}
|
|
|
|
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;
|
|
if (this.settlementMode === 'GL' && !this.settlementGlAccountId) 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`
|
|
},
|
|
this.settlementMode === 'GL'
|
|
? {
|
|
label: 'Settlement GL Account selected',
|
|
pass: !!this.settlementGlAccountId,
|
|
detail: this.settlementGlAccountId ? this.selectedSettlementGlName : 'Missing'
|
|
}
|
|
: {
|
|
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 ?? []));
|
|
|
|
this.toGlAccountControl.valueChanges.subscribe((id: any) => {
|
|
this.settlementGlAccountId = id ? Number(id) : null;
|
|
});
|
|
|
|
if (this.requestId && this.mode === 'review') {
|
|
this.loadReviewRequest(this.requestId);
|
|
}
|
|
}
|
|
|
|
/** Flips the settlement destination. Switching away from GL clears whatever was picked so a
|
|
* stale GL id can never ride along on a SAVINGS submission. */
|
|
onSettlementModeChange(mode: 'SAVINGS' | 'GL'): void {
|
|
this.settlementMode = mode;
|
|
if (mode === 'GL') {
|
|
this.loadGlAccounts();
|
|
} else {
|
|
this.settlementGlAccountId = null;
|
|
this.toGlAccountControl.setValue('');
|
|
}
|
|
}
|
|
|
|
/** Fetched on first use rather than with the page, since most withdrawals settle to savings. */
|
|
private loadGlAccounts(): void {
|
|
if (this.glAccountsData.length) {
|
|
return;
|
|
}
|
|
this.accountingService.getGlAccounts().subscribe((accounts: GLAccount[]) => {
|
|
this.glAccountsData = accounts ?? [];
|
|
});
|
|
}
|
|
|
|
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 ?? '';
|
|
this.settlementMode = req.settlement_mode === 'GL' ? 'GL' : 'SAVINGS';
|
|
this.settlementGlAccountId = req.settlement_gl_account_id ? Number(req.settlement_gl_account_id) : null;
|
|
if (this.settlementMode === 'GL') {
|
|
this.loadGlAccounts();
|
|
}
|
|
|
|
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,
|
|
settlementMode: this.settlementMode,
|
|
...(this.settlementMode === 'GL' ? { settlementGlAccountId: this.settlementGlAccountId } : {})
|
|
};
|
|
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']);
|
|
}
|
|
}
|