/** * 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(); @Output() actionCompleted = new EventEmitter(); 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']); } }