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
@@ -410,6 +410,9 @@
<button mat-menu-item (click)="doAction('Add Charge')" *mifosxHasPermission="'CREATE_CLIENTCHARGE'">
{{ 'labels.buttons.Add Charge' | translate }}
</button>
<button mat-menu-item (click)="doAction('Welcome Kit')" *mifosxHasPermission="'READ_WELCOMEKIT'">
Welcome Kit
</button>
<button mat-menu-item (click)="doAction('Create Collateral')">
{{ 'labels.buttons.Create Collateral' | translate }}
</button>
@@ -22,6 +22,7 @@ import { DrawSignatureDialogComponent } from './custom-dialogs/draw-signature-di
import { DeleteDialogComponent } from 'app/shared/delete-dialog/delete-dialog.component';
import { UploadImageDialogComponent } from './custom-dialogs/upload-image-dialog/upload-image-dialog.component';
import { CaptureImageDialogComponent } from './custom-dialogs/capture-image-dialog/capture-image-dialog.component';
import { WelcomeKitDialogComponent } from './custom-dialogs/welcome-kit-dialog/welcome-kit-dialog.component';
/** Custom Services */
import { ClientsService } from '../clients.service';
@@ -255,6 +256,9 @@ export class ClientsViewComponent implements OnInit {
case 'Delete Image':
this.deleteProfileImage();
break;
case 'Welcome Kit':
this.dispatchWelcomeKit();
break;
case 'Create Standing Instructions':
const createStandingInstructionsQueryParams: any = {
officeId: this.clientViewData.officeId,
@@ -455,4 +459,20 @@ export class ClientsViewComponent implements OnInit {
}
});
}
/**
* Opens the Welcome Kit Dispatch dialog.
*/
private dispatchWelcomeKit() {
const welcomeKitDialogRef = this.dialog.open(WelcomeKitDialogComponent, {
data: { clientId: this.clientViewData.id },
width: '600px'
});
welcomeKitDialogRef.afterClosed().subscribe((response: any) => {
if (response) {
// the form was successfully submitted
this.reload();
}
});
}
}
@@ -0,0 +1,94 @@
<!--
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/.
-->
<h2 mat-dialog-title>Welcome Kit Dispatch</h2>
<mat-dialog-content class="mat-typography">
<div fxLayout="row" fxLayoutAlign="center center" style="padding: 2rem" *ngIf="isLoading">
<mat-spinner diameter="40"></mat-spinner>
</div>
<ng-container *ngIf="!isLoading">
<!-- Tracking view if dispatches already exist -->
<div
class="dispatch-tracking-panel"
*ngFor="let dispatch of activeDispatches; let i = index"
style="margin-bottom: 16px; padding: 16px; border: 1px solid #e0e0e0; border-radius: 4px; background: #fafafa"
>
<div fxLayout="row" fxLayoutAlign="space-between start" style="margin-bottom: 8px">
<h3 style="margin: 0">{{ i === 0 ? 'Current' : 'Previous' }} Welcome Kit Status</h3>
<button mat-stroked-button color="primary" type="button" (click)="downloadPdf(dispatch)">
Download Details
</button>
</div>
<p style="margin: 4px 0"><strong>Delivery Address:</strong> {{ getDisplayAddress(dispatch) }}</p>
<p style="margin: 4px 0"><strong>Tracking ID:</strong> {{ dispatch.trackingId }}</p>
<p style="margin: 4px 0">
<strong>Delivery Mode:</strong> {{ dispatch.deliveryMode }}
<ng-container *ngIf="dispatch.deliveryMode === 'OTHER'">({{ dispatch.deliveryModeOtherText }})</ng-container>
</p>
<p style="margin: 4px 0"><strong>Dispatched On:</strong> {{ dispatch.dispatchedOnDate | date: 'mediumDate' }}</p>
<p style="margin: 4px 0">
<strong>Status:</strong>
<span class="status-badge status-{{ dispatch.status | lowercase }}">{{ dispatch.status }}</span>
</p>
</div>
<hr *ngIf="activeDispatches.length > 0 && welcomeKitsData?.canDispatchNew" />
<!-- Create form if they can dispatch a new one -->
<div class="dispatch-create-form" [formGroup]="dispatchForm" *ngIf="welcomeKitsData?.canDispatchNew">
<h3>Create New Dispatch</h3>
<div fxLayout="column" fxLayoutGap="16px">
<mat-form-field appearance="outline" class="full-width">
<mat-label>Delivery Address</mat-label>
<textarea matInput formControlName="deliveryAddress" rows="3" required></textarea>
<mat-error *ngIf="dispatchForm.get('deliveryAddress')?.hasError('required')">
Delivery Address is required
</mat-error>
</mat-form-field>
<mat-form-field appearance="outline" class="full-width">
<mat-label>Delivery Mode</mat-label>
<mat-select formControlName="deliveryMode" required>
<mat-option *ngFor="let mode of deliveryModes" [value]="mode">
{{ mode }}
</mat-option>
</mat-select>
<mat-error *ngIf="dispatchForm.get('deliveryMode')?.hasError('required')">
Delivery Mode is required
</mat-error>
</mat-form-field>
<mat-form-field
appearance="outline"
class="full-width"
*ngIf="dispatchForm.get('deliveryMode')?.value === 'OTHER'"
>
<mat-label>Specify Other Mode</mat-label>
<input matInput formControlName="deliveryModeOtherText" required />
<mat-error *ngIf="dispatchForm.get('deliveryModeOtherText')?.hasError('required')">
Please specify the delivery mode
</mat-error>
</mat-form-field>
</div>
</div>
</ng-container>
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-button mat-dialog-close>Close</button>
<button
mat-raised-button
color="primary"
*ngIf="welcomeKitsData?.canDispatchNew && !isLoading"
(click)="submit()"
[disabled]="dispatchForm.invalid || isLoading"
>
Dispatch
</button>
</mat-dialog-actions>
@@ -0,0 +1,59 @@
/**
* 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/.
*/
.dispatch-tracking-panel {
background-color: #f5f5f5;
border-radius: 8px;
padding: 16px;
margin-bottom: 16px;
h3 {
margin-top: 0;
margin-bottom: 12px;
font-size: 1.1rem;
}
p {
margin: 6px 0;
}
.status-badge {
padding: 4px 8px;
border-radius: 4px;
font-size: 0.85rem;
font-weight: 500;
color: white;
&.status-dispatched {
background-color: #1976d2;
}
&.status-in_transit {
background-color: #f57c00;
}
&.status-delivered {
background-color: #388e3c;
}
&.status-returned {
background-color: #d32f2f;
}
}
}
.dispatch-create-form {
h3 {
margin-top: 0;
margin-bottom: 12px;
font-size: 1.1rem;
}
}
.full-width {
width: 100%;
}
@@ -0,0 +1,347 @@
/**
* 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, inject } from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import {
MAT_DIALOG_DATA,
MatDialogRef,
MatDialogTitle,
MatDialogContent,
MatDialogActions,
MatDialogClose
} from '@angular/material/dialog';
import { MatSnackBar } from '@angular/material/snack-bar';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { ClientsService } from '../../../clients.service';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { DatePipe } from '@angular/common';
import { jsPDF } from 'jspdf';
import autoTable from 'jspdf-autotable';
@Component({
selector: 'mifosx-welcome-kit-dialog',
templateUrl: './welcome-kit-dialog.component.html',
styleUrls: ['./welcome-kit-dialog.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
MatDialogTitle,
MatDialogContent,
MatDialogActions,
MatDialogClose,
MatProgressSpinnerModule
],
providers: [DatePipe]
})
export class WelcomeKitDialogComponent implements OnInit {
private dialogRef = inject(MatDialogRef<WelcomeKitDialogComponent>);
data: { clientId: string } = inject(MAT_DIALOG_DATA);
private clientsService = inject(ClientsService);
private formBuilder = inject(FormBuilder);
private snackBar = inject(MatSnackBar);
private datePipe = inject(DatePipe);
isLoading = true;
welcomeKitsData: any = null;
dispatchForm: FormGroup = this.formBuilder.group({
deliveryAddress: [
'',
Validators.required
],
deliveryMode: [
'POSTMAN',
Validators.required
],
deliveryModeOtherText: ['']
});
deliveryModes = [
'POSTMAN',
'COURIER',
'OTHER'
];
constructor() {
this.dispatchForm.get('deliveryMode')?.valueChanges.subscribe((mode) => {
const otherControl = this.dispatchForm.get('deliveryModeOtherText');
if (mode === 'OTHER') {
otherControl?.setValidators([Validators.required]);
} else {
otherControl?.clearValidators();
}
otherControl?.updateValueAndValidity();
});
}
ngOnInit(): void {
this.fetchData();
}
fetchData() {
this.isLoading = true;
this.clientsService.getWelcomeKits(this.data.clientId).subscribe({
next: (response: any) => {
this.welcomeKitsData = response;
if (this.welcomeKitsData && this.welcomeKitsData.canDispatchNew) {
this.fetchPrimaryAddress();
} else {
this.isLoading = false;
}
},
error: () => {
this.isLoading = false;
this.snackBar.open('Failed to load welcome kits data', 'Close', { duration: 3000 });
}
});
}
fetchPrimaryAddress() {
this.clientsService.getClientAddressData(this.data.clientId).subscribe({
next: (addresses: any) => {
this.isLoading = false;
if (addresses && addresses.length > 0) {
// Attempt to build a readable address from the first address returned
const primary = addresses[0];
const parts = [];
if (primary.addressLine1) parts.push(primary.addressLine1);
if (primary.addressLine2) parts.push(primary.addressLine2);
if (primary.city) parts.push(primary.city);
if (primary.stateName) parts.push(primary.stateName);
if (primary.postalCode) parts.push(primary.postalCode);
if (primary.countryName) parts.push(primary.countryName);
const clientNameStr = this.welcomeKitsData?.clientName ? this.welcomeKitsData.clientName + ' — ' : '';
this.dispatchForm.patchValue({
deliveryAddress: clientNameStr + parts.join(', ')
});
}
},
error: () => {
this.isLoading = false;
}
});
}
get activeDispatches() {
if (this.welcomeKitsData && this.welcomeKitsData.dispatches && this.welcomeKitsData.dispatches.length > 0) {
return this.welcomeKitsData.dispatches;
}
return [];
}
submit() {
if (this.dispatchForm.invalid) {
this.dispatchForm.markAllAsTouched();
return;
}
this.isLoading = true;
const formValue = this.dispatchForm.value;
const today = new Date();
const formattedDate = this.datePipe.transform(today, 'dd MMMM yyyy');
const payload = {
deliveryAddress: formValue.deliveryAddress,
deliveryMode: formValue.deliveryMode,
deliveryModeOtherText: formValue.deliveryMode === 'OTHER' ? formValue.deliveryModeOtherText : null,
dispatchedOnDate: formattedDate,
locale: 'en',
dateFormat: 'dd MMMM yyyy'
};
this.clientsService.dispatchWelcomeKit(this.data.clientId, payload).subscribe({
next: () => {
this.snackBar.open('Welcome Kit Dispatched successfully', 'Close', { duration: 3000 });
this.fetchData();
},
error: () => {
this.isLoading = false;
this.snackBar.open('Failed to dispatch welcome kit', 'Close', { duration: 3000 });
}
});
}
getDisplayAddress(dispatch: any): string {
const address = dispatch.deliveryAddress || '';
const name = this.welcomeKitsData?.clientName || '';
if (name && !address.startsWith(name)) {
return `${name} — ${address}`;
}
return address;
}
downloadPdf(dispatch: any) {
const doc = new jsPDF();
const clientName = this.welcomeKitsData?.clientName || dispatch.clientName || 'N/A';
const address = this.getDisplayAddress(dispatch);
const mode =
dispatch.deliveryMode === 'OTHER'
? `${dispatch.deliveryMode} (${dispatch.deliveryModeOtherText || ''})`
: dispatch.deliveryMode;
const trackingId = dispatch.trackingId || 'N/A';
const status = dispatch.status || 'N/A';
const dispatchedOn = this.datePipe.transform(dispatch.dispatchedOnDate, 'mediumDate') || 'N/A';
const primaryColor: [number, number, number] = [
31,
73,
125
]; // Navy Blue
const grayColor: [number, number, number] = [
100,
100,
100
];
const lightGray: [number, number, number] = [
230,
230,
230
];
const pageWidth = doc.internal.pageSize.getWidth();
const pageHeight = doc.internal.pageSize.getHeight();
// --- 1. Header Banner ---
doc.setFillColor(...primaryColor);
doc.rect(0, 0, pageWidth, 40, 'F');
// Decorative circles
doc.setFillColor(255, 255, 255);
doc.circle(pageWidth - 20, 15, 3, 'F');
doc.circle(pageWidth - 30, 25, 2, 'F');
doc.circle(pageWidth - 10, 30, 1.5, 'F');
doc.setFontSize(28);
doc.setFont('helvetica', 'bold');
doc.setTextColor(255, 255, 255);
doc.text('CBSNexora', 20, 22);
doc.setFontSize(11);
doc.setFont('helvetica', 'normal');
doc.text('Core Banking Solution', 22, 30);
// --- 2. Title ---
let currentY = 55;
doc.setFontSize(16);
doc.setFont('helvetica', 'bold');
doc.setTextColor(...primaryColor);
doc.text('Welcome Kit Dispatch Confirmation', pageWidth / 2, currentY, { align: 'center' });
currentY += 12;
// --- 3. Date Line ---
doc.setFontSize(11);
doc.setFont('helvetica', 'normal');
doc.setTextColor(0, 0, 0);
const today = this.datePipe.transform(new Date(), 'longDate');
doc.text(`Date: ${today}`, pageWidth - 20, currentY, { align: 'right' });
currentY += 15;
// --- 4. Greeting ---
doc.text(`Dear ${clientName},`, 20, currentY);
currentY += 10;
// --- 5. Opening welcome paragraph ---
const p1 =
'Welcome to the CBSNexora family! We are delighted to have you on board as a valued client. As part of our onboarding process, we have dispatched your Welcome Kit to your registered address, which includes your account documentation and essential banking materials to help you get started with us.';
const splitP1 = doc.splitTextToSize(p1, pageWidth - 40);
doc.text(splitP1, 20, currentY);
currentY += splitP1.length * 6 + 8;
// --- 6. Short paragraph about the company ---
const p2 =
'CBSNexora is committed to providing secure, reliable, and customer-first banking services. Our team is always available to assist you with any banking needs, from account management to digital services, ensuring a seamless banking experience.';
const splitP2 = doc.splitTextToSize(p2, pageWidth - 40);
doc.text(splitP2, 20, currentY);
currentY += splitP2.length * 6 + 8;
// --- 7. Transition line before details ---
doc.text('Please find your Welcome Kit dispatch details below for your reference:', 20, currentY);
currentY += 8;
// --- 8. Details Section ---
autoTable(doc, {
startY: currentY,
theme: 'plain',
margin: { left: 20, right: 20 },
styles: { fontSize: 11, cellPadding: 6, textColor: [
0,
0,
0
] },
columnStyles: {
0: { fontStyle: 'bold', cellWidth: 50 },
1: { cellWidth: 'auto' }
},
didDrawCell: (data) => {
if (data.row.index >= 0) {
doc.setDrawColor(...lightGray);
doc.setLineWidth(0.2);
doc.line(
data.cell.x,
data.cell.y + data.cell.height,
data.cell.x + data.cell.width,
data.cell.y + data.cell.height
);
}
},
body: [
[
'Delivery Address',
address
],
[
'Delivery Mode',
mode
],
[
'Tracking ID',
trackingId
],
[
'Status',
status
],
[
'Dispatched On',
dispatchedOn
]
]
});
// --- 9. Closing paragraph ---
currentY = (doc as any).lastAutoTable.finalY + 12;
const p3 =
'Should you have any questions about your Welcome Kit or need any assistance, please do not hesitate to reach out to our support team. We look forward to serving you and building a long-term relationship with you.';
const splitP3 = doc.splitTextToSize(p3, pageWidth - 40);
doc.text(splitP3, 20, currentY);
currentY += splitP3.length * 6 + 12;
// --- 10. Sign-off ---
doc.text('Warm regards,', 20, currentY);
currentY += 7;
doc.setFont('helvetica', 'bold');
doc.text('CBSNexora Operations Team', 20, currentY);
// --- 11. Footer ---
// Ensure footer doesn't float too high on short pages, but pushes down if content goes long
let footerY = currentY + 30;
if (footerY < pageHeight - 30) {
footerY = pageHeight - 30;
}
doc.setDrawColor(...primaryColor);
doc.setLineWidth(0.3);
doc.line(20, footerY - 5, pageWidth - 20, footerY - 5);
doc.setFontSize(9);
doc.setFont('helvetica', 'italic');
doc.setTextColor(...grayColor);
const timestamp = this.datePipe.transform(new Date(), 'medium');
doc.text(`This is a system-generated document. Generated on: ${timestamp}`, 20, footerY + 2);
doc.save(`WelcomeKit_Dispatch_${trackingId !== 'N/A' ? trackingId : new Date().getTime()}.pdf`);
}
}
+8
View File
@@ -950,4 +950,12 @@ export class ClientsService {
submitClientForm60(clientId: string, data: any): Observable<any> {
return this.http.post(`/clients/${clientId}/form60`, data);
}
getWelcomeKits(clientId: string | number): Observable<any> {
return this.http.get(`/clients/${clientId}/welcomekit`);
}
dispatchWelcomeKit(clientId: string | number, data: any): Observable<any> {
return this.http.post(`/clients/${clientId}/welcomekit`, data);
}
}
+1 -1
View File
@@ -1121,7 +1121,7 @@
<button mat-menu-item tabindex="0">
<fa-icon icon="check-circle" class="menu-item-icon"></fa-icon> Share Issued
</button>
<button mat-menu-item tabindex="0">
<button mat-menu-item tabindex="0" [routerLink]="['/shares/share-holders']">
<fa-icon icon="users" class="menu-item-icon"></fa-icon> Share Holder
</button>
<button mat-menu-item tabindex="0">
+4
View File
@@ -40,6 +40,10 @@ const routes: Routes = [
resolve: {
offices: OfficesResolver
}
},
{
path: 'shares',
loadChildren: () => import('../shares/shares.module').then((m) => m.SharesModule)
}
])
];
@@ -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?.
+2 -2
View File
@@ -9,8 +9,8 @@
/* tslint:disable */
export default {
'mifos_x': {
'version': '260729',
'hash': 'c1fc95d9'
'version': '260731',
'hash': 'ca85bae4'
},
'allow_switching_backend_instance': true
};