feat: implement share account actions, view components, and pending checker tasks

This commit is contained in:
2026-08-27 10:23:25 +05:30
parent 1273bdbe0e
commit 83e58376cb
32 changed files with 1699 additions and 93 deletions
@@ -0,0 +1,107 @@
<!--
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/.
-->
<div class="psa-tab-content">
@if (showData) {
<div class="psa-action-bar">
<mat-form-field appearance="outline" class="psa-search-field">
<mat-icon matPrefix>search</mat-icon>
<input
#filterInput
matInput
placeholder="Search by account number, member or product"
(keyup)="applyFilter(filterInput.value)"
/>
</mat-form-field>
</div>
<div class="table-container">
<table mat-table [dataSource]="dataSource" class="modern-table">
<ng-container matColumnDef="accountNo">
<th mat-header-cell *matHeaderCellDef>Account No.</th>
<td mat-cell *matCellDef="let account" class="psa-mono">{{ account.accountNo }}</td>
</ng-container>
<ng-container matColumnDef="clientName">
<th mat-header-cell *matHeaderCellDef>Member</th>
<td mat-cell *matCellDef="let account">
{{ account.clientName }}
@if (account.officeName) {
<div class="psa-sub">{{ account.officeName }}</div>
}
</td>
</ng-container>
<ng-container matColumnDef="productName">
<th mat-header-cell *matHeaderCellDef>Product</th>
<td mat-cell *matCellDef="let account">{{ account.productName }}</td>
</ng-container>
<ng-container matColumnDef="requestedShares">
<th mat-header-cell *matHeaderCellDef class="ta-right">Shares</th>
<td mat-cell *matCellDef="let account" class="ta-right psa-num">{{ account.requestedShares }}</td>
</ng-container>
<ng-container matColumnDef="requestedAmount">
<th mat-header-cell *matHeaderCellDef class="ta-right">Amount</th>
<td mat-cell *matCellDef="let account" class="ta-right psa-num">
{{ account.requestedAmount | number: '1.2-2' }}
</td>
</ng-container>
<!-- The board resolution the subscription was authorised under. A checker approving share
capital is confirming that authority exists, so it belongs in the row. -->
<ng-container matColumnDef="resolution">
<th mat-header-cell *matHeaderCellDef>Resolution</th>
<td mat-cell *matCellDef="let account">
{{ account.resolutionNumber || '—' }}
@if (account.resolutionDate) {
<div class="psa-sub">{{ formatDate(account.resolutionDate) }}</div>
}
</td>
</ng-container>
<ng-container matColumnDef="submittedDate">
<th mat-header-cell *matHeaderCellDef>Submitted</th>
<td mat-cell *matCellDef="let account">
{{ formatDate(account.submittedDate) }}
@if (account.submittedByUsername) {
<div class="psa-sub">by {{ account.submittedByUsername }}</div>
}
</td>
</ng-container>
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef>Actions</th>
<td mat-cell *matCellDef="let account">
<button mat-stroked-button class="psa-btn psa-btn-view" (click)="viewAccount(account)">
<fa-icon icon="eye" class="m-r-10"></fa-icon>View
</button>
<button mat-stroked-button class="psa-btn psa-btn-approve" (click)="approve(account)">
<fa-icon icon="check" class="m-r-10"></fa-icon>Approve
</button>
<button mat-stroked-button class="psa-btn psa-btn-reject" (click)="reject(account)">
<fa-icon icon="ban" class="m-r-10"></fa-icon>Reject
</button>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns" class="table-row"></tr>
</table>
</div>
}
@if (!showData) {
<div class="empty-state">
<fa-icon icon="hourglass-half" class="empty-icon"></fa-icon>
<p class="empty-title">No pending share accounts</p>
<p class="empty-text">There are no share accounts awaiting approval at this time.</p>
</div>
}
</div>
@@ -0,0 +1,128 @@
/**
* 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/.
*/
$border: #e2e8f0;
$text-muted: #64748b;
.psa-tab-content {
padding: 16px 0;
}
.psa-action-bar {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 16px;
flex-wrap: wrap;
}
.psa-search-field {
flex: 1;
min-width: 200px;
max-width: 340px;
}
.psa-btn {
font-size: 11.5px;
font-weight: 600;
border-radius: 8px !important;
height: 32px;
padding: 0 10px;
margin-right: 6px;
fa-icon {
margin-right: 4px;
font-size: 11px;
}
}
.psa-btn-view {
border-color: #2563eb;
color: #2563eb;
&:hover {
background: #eff6ff;
}
}
.psa-btn-approve {
border-color: #16a34a;
color: #16a34a;
&:hover {
background: #f0fdf4;
}
}
.psa-btn-reject {
border-color: #dc2626;
color: #dc2626;
&:hover {
background: #fef2f2;
}
}
.psa-mono {
font-family: 'Courier New', monospace;
}
/* Tabular figures so share counts and amounts line up digit for digit down their columns — a queue
is scanned, and two subscriptions of very different sizes should look different at a glance. */
.psa-num {
font-variant-numeric: tabular-nums;
font-weight: 500;
white-space: nowrap;
}
/* The second line in a cell — branch under the member, meeting date under the resolution number,
who submitted it under the date. Kept with the value it qualifies rather than given a column of
its own, which would make an already wide table wider for something read together anyway. */
.psa-sub {
font-size: 11px;
color: $text-muted;
margin-top: 2px;
}
.ta-right {
text-align: right;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 24px;
background: #fff;
border: 1px dashed $border;
border-radius: 14px;
text-align: center;
margin: 16px 0;
.empty-icon {
font-size: 42px;
color: #cbd5e1;
margin-bottom: 14px;
}
.empty-title {
margin: 0 0 4px;
font-size: 15px;
font-weight: 700;
color: #0d1b2a;
}
.empty-text {
margin: 0;
font-size: 12.5px;
color: $text-muted;
max-width: 380px;
line-height: 1.5;
}
}
@@ -0,0 +1,192 @@
/**
* 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, inject } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { DecimalPipe } from '@angular/common';
import {
MatTableDataSource,
MatTable,
MatColumnDef,
MatHeaderCellDef,
MatHeaderCell,
MatCellDef,
MatCell,
MatHeaderRowDef,
MatHeaderRow,
MatRowDef,
MatRow
} from '@angular/material/table';
import { MatDialog } from '@angular/material/dialog';
import { MatIcon } from '@angular/material/icon';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { SharesService } from 'app/shares/shares.service';
import { SettingsService } from 'app/settings/settings.service';
import { Dates } from 'app/core/utils/dates';
import { ConfirmationDialogComponent } from 'app/shared/confirmation-dialog/confirmation-dialog.component';
import { FormDialogComponent } from 'app/shared/form-dialog/form-dialog.component';
import { FormfieldBase } from 'app/shared/form-dialog/formfield/model/formfield-base';
import { InputBase } from 'app/shared/form-dialog/formfield/model/input-base';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/**
* Checker-facing list of share accounts submitted and awaiting approval.
*
* Mirrors PendingShareAccountEditsComponent's list + approve/reject pattern, but for whole accounts
* rather than edits to one. Shows what the decision turns on — the member, the product, how many
* shares they have subscribed for and what that comes to, and the board resolution behind it — so a
* checker can act from the list instead of opening every account to find out what it is.
*/
@Component({
selector: 'mifosx-pending-share-accounts',
templateUrl: './pending-share-accounts.component.html',
styleUrls: ['./pending-share-accounts.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
FaIconComponent,
MatTable,
MatColumnDef,
MatHeaderCellDef,
MatHeaderCell,
MatCellDef,
MatCell,
MatHeaderRowDef,
MatHeaderRow,
MatRowDef,
MatRow,
MatIcon,
DecimalPipe
]
})
export class PendingShareAccountsComponent {
private route = inject(ActivatedRoute);
private router = inject(Router);
private dialog = inject(MatDialog);
private sharesService = inject(SharesService);
private settingsService = inject(SettingsService);
private dateUtils = inject(Dates);
dataSource: MatTableDataSource<any>;
showData = false;
displayedColumns: string[] = [
'accountNo',
'clientName',
'productName',
'requestedShares',
'requestedAmount',
'resolution',
'submittedDate',
'actions'
];
constructor() {
this.route.data.subscribe((data: { pendingShareAccountsData: any }) => {
const items = data.pendingShareAccountsData || [];
this.showData = items.length > 0;
this.dataSource = new MatTableDataSource(items);
});
}
applyFilter(filterValue: string = '') {
this.dataSource.filter = filterValue.trim().toLowerCase();
}
/**
* A date from the API rendered readably, whether it arrives as a string or as Fineract's
* {@code [yyyy, m, d]} array.
*/
formatDate(date: any): string {
if (!date) {
return '';
}
const d = Array.isArray(date) ? new Date(date[0], date[1] - 1, date[2]) : new Date(date);
return isNaN(d.getTime()) ? String(date) : d.toLocaleDateString('en-IN');
}
/** Opens the account itself, for a checker who wants more than the row shows. */
viewAccount(account: any): void {
this.router.navigate([
'/clients',
account.clientId,
'shares-accounts',
account.shareAccountId
]);
}
approve(account: any): void {
const dialogRef = this.dialog.open(ConfirmationDialogComponent, {
data: {
heading: 'Approve Share Account',
dialogContext:
`Approve share account "${account.accountNo}" for ${account.clientName} — ` +
`${account.requestedShares} shares at ${account.requestedAmount}?`,
type: 'basic'
}
});
dialogRef.afterClosed().subscribe((response: any) => {
if (response?.confirm) {
// The approval date is the business date rather than the checker's choice: this queue is for
// clearing a submission, and a date field per row would be four extra clicks to type what
// the system already knows.
const data = {
approvedDate: this.dateUtils.formatDate(this.settingsService.businessDate, this.settingsService.dateFormat),
dateFormat: this.settingsService.dateFormat,
locale: this.settingsService.language.code
};
this.sharesService
.executeSharesAccountCommand(account.shareAccountId, 'approve', data)
.subscribe(() => this.reload());
}
});
}
reject(account: any): void {
const formfields: FormfieldBase[] = [
new InputBase({
controlName: 'rejectReason',
label: 'Reason for rejection',
value: '',
type: 'text',
required: false
})
];
const dialogRef = this.dialog.open(FormDialogComponent, {
data: {
title: `Reject Share Account ${account.accountNo}`,
formfields
}
});
dialogRef.afterClosed().subscribe((response: any) => {
if (response?.data) {
// The reason is recorded as a note rather than dropped: a rejected subscription is the one a
// member is most likely to come back and ask about.
const data: any = {
rejectedDate: this.dateUtils.formatDate(this.settingsService.businessDate, this.settingsService.dateFormat),
dateFormat: this.settingsService.dateFormat,
locale: this.settingsService.language.code
};
const reason = response.data.value?.rejectReason?.trim();
if (reason) {
data.note = reason;
}
this.sharesService
.executeSharesAccountCommand(account.shareAccountId, 'reject', data)
.subscribe(() => this.reload());
}
});
}
reload(): void {
const url = this.router.url;
this.router
.navigateByUrl('/checker-inbox-and-tasks', { skipLocationChange: true })
.then(() => this.router.navigate([url]));
}
}
@@ -90,6 +90,17 @@
<fa-icon icon="edit" class="ac-tab__ic"></fa-icon>
<span>Pending Account Edits</span>
</a>
<!-- New share accounts awaiting approval. Next to Pending Share Account Edits, since the two are
the share equivalents of Pending Account and Pending Account Edits. -->
<a
class="ac-tab"
routerLink="./pending-share-accounts"
routerLinkActive="ac-tab--active"
*mifosxHasPermission="'ALL_FUNCTIONS_READ'"
>
<fa-icon icon="hourglass-half" class="ac-tab__ic"></fa-icon>
<span>Pending Share Accounts</span>
</a>
<a
class="ac-tab"
routerLink="./pending-share-account-edits"
@@ -0,0 +1,29 @@
/**
* 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 { Injectable, inject } from '@angular/core';
import { Resolve } from '@angular/router';
import { Observable, of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { SharesService } from 'app/shares/shares.service';
/**
* Resolves the share accounts awaiting a checker's approval, for the Pending Share Accounts screen.
*
* An empty list on failure rather than a propagated error: this feeds one tab among many, and a
* failing endpoint should leave that tab empty rather than block the whole Approvals screen from
* loading.
*/
@Injectable()
export class GetPendingShareAccounts implements Resolve<any> {
private sharesService = inject(SharesService);
resolve(): Observable<any> {
return this.sharesService.getPendingShareAccounts().pipe(catchError(() => of([])));
}
}
+12 -1
View File
@@ -33,6 +33,7 @@ import { PendingSavingsProductsComponent } from './checker-inbox-and-tasks-tabs/
import { ChequebookRequestsComponent } from './checker-inbox-and-tasks-tabs/chequebook-requests/chequebook-requests.component';
import { PendingSavingsAccountEditsComponent } from './checker-inbox-and-tasks-tabs/pending-savings-account-edits/pending-savings-account-edits.component';
import { PendingShareAccountEditsComponent } from './checker-inbox-and-tasks-tabs/pending-share-account-edits/pending-share-account-edits.component';
import { PendingShareAccountsComponent } from './checker-inbox-and-tasks-tabs/pending-share-accounts/pending-share-accounts.component';
import { PendingClientEditsComponent } from './checker-inbox-and-tasks-tabs/pending-client-edits/pending-client-edits.component';
import { ViewCheckerInboxComponent } from './view-checker-inbox/view-checker-inbox.component';
@@ -51,6 +52,7 @@ import { GetPendingSavingsProducts } from './common-resolvers/getPendingSavingsP
import { GetPendingSavingsAccountEdits } from './common-resolvers/getPendingSavingsAccountEdits.resolver';
import { GetPendingCurrentAccountEdits } from './common-resolvers/getPendingCurrentAccountEdits.resolver';
import { GetPendingShareAccountEdits } from './common-resolvers/getPendingShareAccountEdits.resolver';
import { GetPendingShareAccounts } from './common-resolvers/getPendingShareAccounts.resolver';
/** Tasks Routes */
const routes: Routes = [
@@ -144,6 +146,14 @@ const routes: Routes = [
path: 'pending-current-account-edits',
redirectTo: 'pending-savings-account-edits'
},
{
path: 'pending-share-accounts',
component: PendingShareAccountsComponent,
data: { title: 'Pending Share Accounts' },
resolve: {
pendingShareAccountsData: GetPendingShareAccounts
}
},
{
path: 'pending-share-account-edits',
component: PendingShareAccountEditsComponent,
@@ -264,7 +274,8 @@ const routes: Routes = [
GetPendingSavingsProducts,
GetPendingSavingsAccountEdits,
GetPendingCurrentAccountEdits,
GetPendingShareAccountEdits
GetPendingShareAccountEdits,
GetPendingShareAccounts
]
})
export class TasksRoutingModule {}