feat: add Welcome Kit dispatch dialog and Share Holders view
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

This commit is contained in:
Anshul Rathore
2026-07-31 14:06:27 +05:30
parent f687e1808d
commit a560cd6533
15 changed files with 1051 additions and 12 deletions
@@ -0,0 +1,130 @@
<!--
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/.
-->
<mat-card class="container">
<div class="table-header">
<div class="table-header-row">
<div class="table-title-group">
<h2 class="table-title">
<div class="t-logomark"><span></span><span></span><span></span><span></span></div>
Share Holders
</h2>
<span class="table-count" *ngIf="totalRows">{{ totalRows }} records</span>
</div>
<div class="table-buttons"></div>
</div>
<div class="table-filters">
<mat-form-field class="table-search" appearance="outline">
<input matInput placeholder="Search by client name or account number" [formControl]="searchControl" />
</mat-form-field>
<mat-form-field class="table-filter" appearance="outline">
<mat-select [(value)]="branchFilter" (selectionChange)="applyFilters()" placeholder="Branch">
<mat-option value="">All Branches</mat-option>
<mat-option *ngFor="let office of offices" [value]="office.id">
{{ office.name }}
</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field class="table-filter" appearance="outline">
<mat-select [(value)]="productFilter" (selectionChange)="applyFilters()" placeholder="Share Product">
<mat-option value="">All Products</mat-option>
<mat-option *ngFor="let product of products" [value]="product.id">
{{ product.name }}
</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field class="table-filter" appearance="outline">
<mat-select [(value)]="statusFilter" (selectionChange)="applyFilters()" placeholder="Status">
<mat-option value="">All Status</mat-option>
<mat-option value="active">Active</mat-option>
<mat-option value="approved">Approved</mat-option>
<mat-option value="submitted and pending approval">Pending</mat-option>
<mat-option value="closed">Closed</mat-option>
<mat-option value="rejected">Rejected</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field class="table-filter" appearance="outline" (click)="picker.open()">
<mat-date-range-input [formGroup]="dateRangeForm" [rangePicker]="picker">
<input matStartDate formControlName="start" placeholder="Start date" (dateChange)="applyFilters()" />
<input matEndDate formControlName="end" placeholder="End date" (dateChange)="applyFilters()" />
</mat-date-range-input>
<mat-datepicker-toggle matSuffix [for]="picker"></mat-datepicker-toggle>
<mat-date-range-picker #picker></mat-date-range-picker>
</mat-form-field>
<button mat-icon-button matTooltip="Clear Filters" (click)="clearFilters()">
<mat-icon>clear</mat-icon>
</button>
</div>
</div>
<div class="table-container">
<mat-progress-bar *ngIf="isLoading" mode="indeterminate" class="table-loader"></mat-progress-bar>
<table mat-table [dataSource]="dataSource" matSort class="modern-table">
<!-- Member Name Column (clientName) -->
<ng-container matColumnDef="clientName">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Client Name</th>
<td mat-cell *matCellDef="let element">{{ element.clientName || 'N/A' }}</td>
</ng-container>
<!-- Folio Number Column (accountNo) -->
<ng-container matColumnDef="accountNo">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Account No</th>
<td mat-cell *matCellDef="let element">{{ element.accountNo || 'N/A' }}</td>
</ng-container>
<!-- Class Column (productName) -->
<ng-container matColumnDef="productName">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Share Product Name</th>
<td mat-cell *matCellDef="let element">{{ element.productName || 'N/A' }}</td>
</ng-container>
<!-- Approved Shares Column -->
<ng-container matColumnDef="totalApprovedShares">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Approved Shares</th>
<td mat-cell *matCellDef="let element">{{ element.totalApprovedShares || 0 }}</td>
</ng-container>
<!-- Pending Shares Column -->
<ng-container matColumnDef="totalPendingForApprovalShares">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Pending Shares</th>
<td mat-cell *matCellDef="let element">{{ element.totalPendingForApprovalShares || 0 }}</td>
</ng-container>
<!-- Currency Column -->
<ng-container matColumnDef="currency">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Currency</th>
<td mat-cell *matCellDef="let element">{{ element.currency?.code || 'N/A' }}</td>
</ng-container>
<!-- Activated Date Column -->
<ng-container matColumnDef="activatedDate">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Activated On</th>
<td mat-cell *matCellDef="let element">
{{ element.activatedDate ? (element.activatedDate | dateFormat) : 'Not Activated' }}
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<!-- We can route to /shares/{accountId} or /clients/{clientId}/shares-accounts/{accountId}/general depending on preference -->
<tr
mat-row
*matRowDef="let row; columns: displayedColumns"
[routerLink]="['/clients', row.clientId, 'shares-accounts', row.id, 'general']"
></tr>
</table>
<mat-paginator [pageSizeOptions]="[10, 25, 50, 100]" showFirstLastButtons></mat-paginator>
</div>
</mat-card>
@@ -0,0 +1,127 @@
/**
* 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/.
*/
.container {
margin: 1.5rem;
padding: 0;
border-radius: var(--radius-xl);
background: var(--bg-card);
box-shadow: var(--shadow-sm);
}
.table-header {
padding: 1.5rem;
border-bottom: 1px solid var(--border-light);
.table-header-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
flex-wrap: wrap;
gap: 1rem;
}
.table-title-group {
display: flex;
align-items: center;
gap: 1rem;
.table-title {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
color: var(--text-main);
display: flex;
align-items: center;
gap: 0.75rem;
letter-spacing: -0.01em;
.t-logomark {
width: 24px;
height: 24px;
position: relative;
display: flex;
flex-wrap: wrap;
gap: 2px;
padding: 2px;
background: var(--bg-hover);
border-radius: 6px;
span {
width: 8px;
height: 8px;
border-radius: 2px;
background: var(--primary-main);
opacity: 0.8;
&:nth-child(2) {
opacity: 0.6;
}
&:nth-child(3) {
opacity: 0.4;
}
&:nth-child(4) {
opacity: 0.2;
}
}
}
}
.table-count {
padding: 0.25rem 0.75rem;
background: var(--bg-hover);
color: var(--text-muted);
border-radius: var(--radius-full);
font-size: 0.75rem;
font-weight: 500;
letter-spacing: 0.02em;
}
}
.table-filters {
display: flex;
gap: 1rem;
align-items: center;
flex-wrap: wrap;
mat-form-field {
width: 240px;
margin-bottom: -1.25em;
}
}
}
.table-container {
position: relative;
overflow: auto;
border-radius: 0 0 var(--radius-xl) var(--radius-xl);
.table-loader {
position: absolute;
top: 0;
left: 0;
right: 0;
z-index: 10;
}
}
.modern-table {
width: 100%;
background: transparent;
tr.mat-row {
cursor: pointer;
transition: background-color 0.2s ease;
&:hover {
background-color: var(--bg-hover);
}
}
}
@@ -0,0 +1,199 @@
/**
* 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, OnInit, ViewChild, inject } from '@angular/core';
import { MatTableDataSource, MatTableModule } from '@angular/material/table';
import { MatPaginator, MatPaginatorModule } from '@angular/material/paginator';
import { MatSort, MatSortModule } from '@angular/material/sort';
import { MatCardModule } from '@angular/material/card';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { MatDatepickerModule } from '@angular/material/datepicker';
import { MatNativeDateModule } from '@angular/material/core';
import { MatIconModule } from '@angular/material/icon';
import { MatButtonModule } from '@angular/material/button';
import { MatTooltipModule } from '@angular/material/tooltip';
import { FormsModule, ReactiveFormsModule, FormGroup, FormControl } from '@angular/forms';
import { CommonModule } from '@angular/common';
import { RouterModule } from '@angular/router';
import { forkJoin, of } from 'rxjs';
import { catchError, debounceTime, distinctUntilChanged, filter, switchMap, tap, startWith } from 'rxjs/operators';
import { DateFormatPipe } from '../../pipes/date-format.pipe';
import { SharesService } from '../shares.service';
@Component({
selector: 'mifosx-share-holders',
templateUrl: './share-holders.component.html',
styleUrls: ['./share-holders.component.scss'],
standalone: true,
imports: [
CommonModule,
RouterModule,
MatCardModule,
MatTableModule,
MatPaginatorModule,
MatSortModule,
MatProgressBarModule,
MatFormFieldModule,
MatInputModule,
MatSelectModule,
MatDatepickerModule,
MatNativeDateModule,
MatIconModule,
MatButtonModule,
MatTooltipModule,
FormsModule,
ReactiveFormsModule,
DateFormatPipe
]
})
export class ShareHoldersComponent implements OnInit {
displayedColumns: string[] = [
'clientName',
'accountNo',
'productName',
'totalApprovedShares',
'totalPendingForApprovalShares',
'currency',
'activatedDate'
];
dataSource = new MatTableDataSource<any>();
isLoading = true;
totalRows = 0;
// Filter Data
offices: any[] = [];
products: any[] = [];
// Filter State
searchValue = '';
branchFilter = '';
productFilter = '';
statusFilter = '';
dateRangeForm = new FormGroup({
start: new FormControl<Date | null>(null),
end: new FormControl<Date | null>(null)
});
@ViewChild(MatPaginator, { static: true }) paginator!: MatPaginator;
@ViewChild(MatSort, { static: true }) sort!: MatSort;
searchControl = new FormControl('');
private sharesService = inject(SharesService);
ngOnInit(): void {
this.dataSource.paginator = this.paginator;
this.dataSource.sort = this.sort;
this.setupFilterPredicate();
this.searchControl.valueChanges
.pipe(
startWith(''),
debounceTime(400),
distinctUntilChanged(),
filter((term: string | null) => !term || term.trim().length === 0 || term.trim().length >= 2),
tap((term: string | null) => {
this.searchValue = term || '';
this.isLoading = true;
}),
switchMap((term: string | null) => {
const search = term ? term.trim() : '';
return forkJoin({
accounts: this.sharesService.getShareAccountSummaries(search || undefined).pipe(catchError(() => of([]))),
offices: this.sharesService.getOffices().pipe(catchError(() => of([]))),
products: this.sharesService.getShareProducts().pipe(catchError(() => of([])))
});
})
)
.subscribe({
next: (results: any) => {
this.offices = results.offices || [];
const allShareAccounts = results.accounts || [];
// Derive unique products from the actual active share accounts to ensure no garbage/dummy
// products (like NextGen Tech Pvt Ltd) show up in the dropdown.
const uniqueProductsMap = new Map();
allShareAccounts.forEach((acc: any) => {
if (acc.productId && acc.productName) {
uniqueProductsMap.set(acc.productId, { id: acc.productId, name: acc.productName });
}
});
this.products = Array.from(uniqueProductsMap.values());
this.dataSource.data = allShareAccounts;
this.totalRows = allShareAccounts.length;
this.isLoading = false;
},
error: () => {
this.isLoading = false;
}
});
}
setupFilterPredicate() {
this.dataSource.filterPredicate = (data: any, filter: string) => {
let branchMatch = true;
if (this.branchFilter) {
branchMatch = data.officeId?.toString() === this.branchFilter.toString();
}
let productMatch = true;
if (this.productFilter) {
productMatch = data.productId?.toString() === this.productFilter.toString();
}
let statusMatch = true;
if (this.statusFilter) {
statusMatch =
data.status?.value?.toLowerCase() === this.statusFilter.toLowerCase() ||
data.status?.id?.toString() === this.statusFilter.toString();
}
let dateMatch = true;
if (this.dateRangeForm.value.start && data.activatedDate) {
const rowDate = new Date(data.activatedDate[0], data.activatedDate[1] - 1, data.activatedDate[2]);
const start = this.dateRangeForm.value.start;
const end = this.dateRangeForm.value.end || new Date(); // If no end date, use today
// Reset hours for accurate comparison
rowDate.setHours(0, 0, 0, 0);
start.setHours(0, 0, 0, 0);
end.setHours(0, 0, 0, 0);
dateMatch = rowDate >= start && rowDate <= end;
}
return branchMatch && productMatch && statusMatch && dateMatch;
};
}
applyFilters() {
// We update the filter string to a random/unique value to trigger the filterPredicate
this.dataSource.filter = Math.random().toString();
}
search(filterValue: string) {
this.searchControl.setValue(filterValue);
}
clearFilters() {
this.searchValue = '';
this.branchFilter = '';
this.productFilter = '';
this.statusFilter = '';
this.dateRangeForm.reset();
// Trigger server-side reload without search term
this.searchControl.setValue('', { emitEvent: true });
// Trigger client-side filters update
this.applyFilters();
}
}
+6 -9
View File
@@ -18,7 +18,7 @@ import { DividendsTabComponent } from './shares-account-view/dividends-tab/divid
import { CreateSharesAccountComponent } from './create-shares-account/create-shares-account.component';
import { EditSharesAccountComponent } from './edit-shares-account/edit-shares-account.component';
import { SharesAccountActionsComponent } from './shares-account-actions/shares-account-actions.component';
import { EditShareAccountDetailsComponent } from './edit-share-account-details/edit-share-account-details.component';
import { ShareHoldersComponent } from './share-holders/share-holders.component';
/** Custom Resolvers */
import { SharesAccountViewResolver } from './common-resolvers/share-account-view.resolver';
@@ -33,6 +33,11 @@ const routes: Routes = [
path: '',
data: { title: 'Shares', breadcrumb: 'Shares', routeParamBreadcrumb: false, addBreadcrumbLink: false },
children: [
{
path: 'share-holders',
data: { title: 'Share Holders', breadcrumb: 'Share Holders' },
component: ShareHoldersComponent
},
{
path: 'create',
data: { title: 'Create Shares Account', breadcrumb: 'Create Shares Account' },
@@ -90,14 +95,6 @@ const routes: Routes = [
sharesAccountAndTemplate: SharesAccountAndTemplateResolver
}
},
{
path: 'edit-details',
data: { title: 'Edit Share Account Details', breadcrumb: 'Edit Details', routeParamBreadcrumb: false },
component: EditShareAccountDetailsComponent,
resolve: {
sharesAccountAndTemplate: SharesAccountAndTemplateResolver
}
},
{
path: 'actions/:name',
data: { title: 'Shares Account Actions', breadcrumb: 'Actions', routeParamBreadcrumb: 'name' },
+18
View File
@@ -37,6 +37,15 @@ import { ApproveShareDialogComponent } from './shares-account-actions/approve-sh
import { RejectSharesComponent } from './shares-account-actions/reject-shares/reject-shares.component';
import { RejectShareDialogComponent } from './shares-account-actions/reject-shares/reject-share-dialog/reject-share-dialog.component';
import { GeneralTabComponent } from './shares-account-view/general-tab/general-tab.component';
import { ShareHoldersComponent } from './share-holders/share-holders.component';
import { MatTableModule } from '@angular/material/table';
import { MatPaginatorModule } from '@angular/material/paginator';
import { MatSortModule } from '@angular/material/sort';
import { MatCardModule } from '@angular/material/card';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
/**
* Shares Module
@@ -45,6 +54,15 @@ import { GeneralTabComponent } from './shares-account-view/general-tab/general-t
*/
@NgModule({
imports: [
ShareHoldersComponent,
SharedModule,
MatTableModule,
MatPaginatorModule,
MatSortModule,
MatCardModule,
MatProgressBarModule,
MatFormFieldModule,
MatInputModule,
SharedModule,
PipesModule,
DirectivesModule,
+33
View File
@@ -22,6 +22,39 @@ import { Observable } from 'rxjs';
export class SharesService {
private http = inject(HttpClient);
/**
* @param {any} params Optional HTTP params (like offset, limit, search)
* @returns {Observable<any>} List of all share accounts
*/
getSharesAccounts(params?: any): Observable<any> {
return this.http.get('/accounts/share', { params });
}
/**
* @returns {Observable<any>} List of all share accounts with client details summary
*/
getShareAccountSummaries(searchTerm?: string): Observable<any> {
let params = new HttpParams();
if (searchTerm) {
params = params.set('searchTerm', searchTerm);
}
return this.http.get('/shareaccounts/summary', { params });
}
/**
* @returns {Observable<any>} List of all offices
*/
getOffices(): Observable<any> {
return this.http.get('/offices');
}
/**
* @returns {Observable<any>} List of all share products
*/
getShareProducts(): Observable<any> {
return this.http.get('/products/share');
}
/**
* @param {string} accountId Shares Account Id of account to get data for.
* @param {boolean} template Shares account template required?.