- Apply Collection dialog: replace product-config auto-resolved GL account with a required GL + voucher search (mirrored for Share and Entry Fee receipts). The entry-fee receipt is tracked separately from the share receipts and sent as its own field so its amount is never swept into the Sundry-side total or double-posted — it only verifies the fee was paid, since that money already sits wherever the cashier deposited it. - Shows the account's required total (share value + entry fee) and live coverage up front, instead of only after a failed apply. - Apply Additional Shares: same mandatory GL + voucher pattern for its by-voucher lookup, plus a GL account picker fed by the new /shareadvancecollection/glaccounts endpoint. - shares-account-charges-step: fixed a race where the currency-control subscription (meant only for the edit-an-existing-application flow) wiped out product charges that ngOnChanges had just seeded from the same product-selection event, leaving new accounts with no charges. - shares-account-view: hide "Share Certificate" until collection_applied is true, so a certificate can't be printed for shares nobody has actually paid for yet. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
300 lines
10 KiB
TypeScript
300 lines
10 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/.
|
|
*/
|
|
|
|
/** Angular Imports */
|
|
import { Component, OnInit, inject } from '@angular/core';
|
|
import { ActivatedRoute, Router, RouterLinkActive, RouterLink, RouterOutlet } from '@angular/router';
|
|
import { MatDialog } from '@angular/material/dialog';
|
|
|
|
/** Custom Dialogs */
|
|
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';
|
|
|
|
/** Custom Buttons Configuration */
|
|
import { SharesButtonsConfiguration } from './shares-buttons.config';
|
|
import {
|
|
MatCard,
|
|
MatCardHeader,
|
|
MatCardTitleGroup,
|
|
MatCardMdImage,
|
|
MatCardTitle,
|
|
MatCardContent
|
|
} from '@angular/material/card';
|
|
import { MatTooltip } from '@angular/material/tooltip';
|
|
import { NgClass } from '@angular/common';
|
|
import { LongTextComponent } from '../../shared/long-text/long-text.component';
|
|
import { AccountNumberComponent } from '../../shared/account-number/account-number.component';
|
|
import { MatIconButton } from '@angular/material/button';
|
|
import { MatMenuTrigger, MatMenu, MatMenuItem } from '@angular/material/menu';
|
|
import { MatIcon } from '@angular/material/icon';
|
|
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
|
import { MatTabNav, MatTabLink, MatTabNavPanel } from '@angular/material/tabs';
|
|
import { StatusLookupPipe } from '../../pipes/status-lookup.pipe';
|
|
import { FormatNumberPipe } from '../../pipes/format-number.pipe';
|
|
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
|
|
|
/**
|
|
* Shares Account View
|
|
*/
|
|
@Component({
|
|
selector: 'mifosx-shares-account-view',
|
|
templateUrl: './shares-account-view.component.html',
|
|
styleUrls: ['./shares-account-view.component.scss'],
|
|
imports: [
|
|
...STANDALONE_SHARED_IMPORTS,
|
|
MatCardHeader,
|
|
MatCardTitleGroup,
|
|
MatCardMdImage,
|
|
MatTooltip,
|
|
MatCardTitle,
|
|
NgClass,
|
|
LongTextComponent,
|
|
AccountNumberComponent,
|
|
MatIconButton,
|
|
MatMenuTrigger,
|
|
MatIcon,
|
|
FaIconComponent,
|
|
MatMenu,
|
|
MatMenuItem,
|
|
MatTabNav,
|
|
MatTabLink,
|
|
RouterLinkActive,
|
|
MatTabNavPanel,
|
|
RouterOutlet,
|
|
StatusLookupPipe,
|
|
FormatNumberPipe
|
|
]
|
|
})
|
|
export class SharesAccountViewComponent implements OnInit {
|
|
private route = inject(ActivatedRoute);
|
|
private router = inject(Router);
|
|
private sharesService = inject(SharesService);
|
|
dialog = inject(MatDialog);
|
|
|
|
/** Shares Account Data */
|
|
sharesAccountData: any;
|
|
/** Button Configurations */
|
|
buttonConfig: SharesButtonsConfiguration;
|
|
|
|
entityType: string;
|
|
|
|
/**
|
|
* Fetches shares account data from `resolve`
|
|
* @param {ActivatedRoute} route Activated Route
|
|
* @param {Router} router Router
|
|
* @param {SharesService} sharesService Shares Service
|
|
* @param {MatDialog} dialog Mat Dialog
|
|
*/
|
|
constructor() {
|
|
this.route.data.subscribe((data: { sharesAccountData: any }) => {
|
|
this.sharesAccountData = data.sharesAccountData;
|
|
});
|
|
}
|
|
|
|
/** Whether this account has a details edit awaiting checker approval. */
|
|
hasPendingEdit = false;
|
|
|
|
ngOnInit() {
|
|
this.setConditionalButtons();
|
|
if (this.router.url.includes('clients')) {
|
|
this.entityType = 'Client';
|
|
} else if (this.router.url.includes('groups')) {
|
|
this.entityType = 'Group';
|
|
} else if (this.router.url.includes('centers')) {
|
|
this.entityType = 'Center';
|
|
}
|
|
this.sharesService.getPendingShareAccountEdit(this.sharesAccountData.id).subscribe({
|
|
next: (edit: any) => {
|
|
this.hasPendingEdit = !!edit && edit.status === 'PENDING';
|
|
if (this.hasPendingEdit) {
|
|
this.buttonConfig.removeOption('Edit Details');
|
|
}
|
|
},
|
|
error: () => {
|
|
this.hasPendingEdit = false;
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Adds options to button config. conditionaly.
|
|
*/
|
|
setConditionalButtons() {
|
|
const status = this.sharesAccountData.status.value;
|
|
this.buttonConfig = new SharesButtonsConfiguration(status);
|
|
if (this.sharesAccountData.charges) {
|
|
const charges: any[] = this.sharesAccountData.charges;
|
|
charges.forEach((charge: any) => {
|
|
if (charge.name === 'Annual fee - INR') {
|
|
this.buttonConfig.addOption({
|
|
name: 'Apply Anuual Fees',
|
|
taskPermissionName: 'APPLYANNUALFEE_SAVINGSACCOUNT'
|
|
});
|
|
}
|
|
});
|
|
}
|
|
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
|
|
// capital that was never issued.
|
|
if (!this.isSettled) {
|
|
this.buttonConfig.removeOption('Share Certificate');
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Performs button action
|
|
* @param {string} name Action name
|
|
*/
|
|
doAction(name: string) {
|
|
switch (name) {
|
|
case 'Approve':
|
|
case 'Reject':
|
|
case 'Close':
|
|
case 'Activate':
|
|
case 'Undo Approval':
|
|
case 'Apply Additional Shares':
|
|
case 'Redeem Shares':
|
|
case 'Approve Additional Shares':
|
|
case 'Reject Additional Shares':
|
|
this.router.navigate([`actions/${name}`], { relativeTo: this.route });
|
|
break;
|
|
case 'Modify Application':
|
|
this.router.navigate(['edit'], { relativeTo: this.route });
|
|
break;
|
|
case 'Edit Details':
|
|
this.router.navigate(['edit-details'], { relativeTo: this.route });
|
|
break;
|
|
case 'Apply Collection':
|
|
this.applyCollection();
|
|
break;
|
|
case 'Debit from Savings':
|
|
this.debitFromSavings();
|
|
break;
|
|
case 'Share Certificate':
|
|
this.viewShareCertificate();
|
|
break;
|
|
case 'Delete':
|
|
this.deleteSharesAccount();
|
|
break;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Settles the member's Share Advance receipt against this account: the cash moves out of Share
|
|
* Sundry into share capital, and the purchase entry becomes visible in the transactions and
|
|
* passbook views.
|
|
*/
|
|
private applyCollection() {
|
|
const dialogRef = this.dialog.open(ApplyCollectionDialogComponent, {
|
|
width: '560px',
|
|
data: {
|
|
shareAccountId: this.sharesAccountData.id,
|
|
clientId: this.sharesAccountData.clientId,
|
|
accountNo: this.sharesAccountData.accountNo
|
|
}
|
|
});
|
|
dialogRef.afterClosed().subscribe((result: any) => {
|
|
if (result) {
|
|
// Account data comes from a route resolver, so re-running the route is what refreshes the
|
|
// newly revealed transaction and the now-paid charges.
|
|
this.router.navigateByUrl('/', { skipLocationChange: true }).then(() => {
|
|
this.router.navigate([
|
|
'/clients',
|
|
this.sharesAccountData.clientId,
|
|
'shares-accounts',
|
|
this.sharesAccountData.id
|
|
]);
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Whether the member's money has actually been taken and the shares issued.
|
|
*
|
|
* Until then the account is only an application: nothing has been debited and the member holds
|
|
* nothing. The Transactions, Passbook and Charges tabs all describe money that has moved, so
|
|
* showing them at that point reads as though the member has paid and holds shares when they have
|
|
* done neither.
|
|
*
|
|
* 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.
|
|
*/
|
|
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.
|
|
*/
|
|
private deleteSharesAccount() {
|
|
const deleteSharesAccountDialogRef = this.dialog.open(DeleteDialogComponent, {
|
|
data: { deleteContext: `shares account with id: ${this.sharesAccountData.id}` }
|
|
});
|
|
deleteSharesAccountDialogRef.afterClosed().subscribe((response: any) => {
|
|
if (response.delete) {
|
|
this.sharesService.deleteSharesAccount(this.sharesAccountData.id).subscribe(() => {
|
|
this.router.navigate(['../../'], { relativeTo: this.route });
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
private viewShareCertificate() {
|
|
this.dialog.open(ShareCertificateDialogComponent, {
|
|
data: {
|
|
sharesAccountData: this.sharesAccountData,
|
|
clientId: this.sharesAccountData.clientId
|
|
},
|
|
width: '850px',
|
|
maxWidth: '95vw'
|
|
});
|
|
}
|
|
}
|