Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/app/centers/create-center/create-center.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,8 @@ <h3 matSubheader>{{ 'labels.heading.Selected Groups' | translate }}</h3>
mat-raised-button
color="primary"
[disabled]="!centerForm.valid"
[mifosxNgBusy]="loading"
[busyText]="'labels.buttons.Creating...' | translate"
(click)="submit()"
*mifosxHasPermission="'CREATE_CENTER'"
>
Expand Down
15 changes: 13 additions & 2 deletions src/app/centers/create-center/create-center.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { MatNavList, MatListSubheaderCssMatStyler } from '@angular/material/list';
import { MatLine } from '@angular/material/grid-list';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { NgBusyDirective } from '../../shared/directives/ng-busy.directive';

/**
* Create Center component.
Expand All @@ -40,6 +41,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
...STANDALONE_SHARED_IMPORTS,
MatCheckbox,
MatIconButton,
NgBusyDirective,
FaIconComponent,
MatNavList,
MatListSubheaderCssMatStyler,
Expand Down Expand Up @@ -71,6 +73,8 @@ export class CreateCenterComponent implements OnInit {
groupMembers: any[] = [];
/** Group Choice. */
groupChoice = new UntypedFormControl('');
/** True if loading. */
loading = false;

/**
* Retrieves the offices data from `resolve`.
Expand Down Expand Up @@ -194,8 +198,15 @@ export class CreateCenterComponent implements OnInit {
};
data.groupMembers = [];
this.groupMembers.forEach((group: any) => data.groupMembers.push(group.id));
this.centerService.createCenter(data).subscribe((response: any) => {
this.router.navigate(['../centers']);

this.loading = true;
this.centerService.createCenter(data).subscribe({
next: (response: any) => {
this.router.navigate(['../centers']);
},
error: () => {
this.loading = false;
}
});
}
}
9 changes: 8 additions & 1 deletion src/app/groups/create-group/create-group.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,14 @@ <h3 matSubheader>{{ 'labels.heading.Selected Clients' | translate }}</h3>
<button type="button" mat-raised-button [routerLink]="['../']">
{{ 'labels.buttons.Cancel' | translate }}
</button>
<button mat-raised-button color="primary" [disabled]="!groupForm.valid" (click)="submit()">
<button
mat-raised-button
color="primary"
[disabled]="!groupForm.valid"
[mifosxNgBusy]="loading"
[busyText]="'labels.buttons.Creating...' | translate"
(click)="submit()"
>
{{ 'labels.buttons.Submit' | translate }}
</button>
</mat-card-actions>
Expand Down
28 changes: 21 additions & 7 deletions src/app/groups/create-group/create-group.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,14 @@ import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { MatNavList, MatListSubheaderCssMatStyler } from '@angular/material/list';
import { MatLine } from '@angular/material/grid-list';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { NgBusyDirective } from '../../shared/directives/ng-busy.directive';

/**
* Create Group component.
*/
@Component({
selector: 'mifosx-create-group',
standalone: true,
templateUrl: './create-group.component.html',
styleUrls: ['./create-group.component.scss'],
imports: [
Expand All @@ -43,6 +45,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
MatAutocompleteTrigger,
MatAutocomplete,
MatIconButton,
NgBusyDirective,
FaIconComponent,
MatNavList,
MatListSubheaderCssMatStyler,
Expand Down Expand Up @@ -74,6 +77,8 @@ export class CreateGroupComponent implements OnInit, AfterViewInit {
clientMembers: any[] = [];
/** ClientChoice. */
clientChoice = new UntypedFormControl('');
/** True if loading. */
loading = false;

/**
* Retrieves the offices data from `resolve`.
Expand Down Expand Up @@ -104,12 +109,14 @@ export class CreateGroupComponent implements OnInit, AfterViewInit {
*/
ngAfterViewInit() {
this.clientChoice.valueChanges.subscribe((value: string) => {
if (value.length >= 2) {
if (value && value.length >= 2) {
this.clientsService
.getFilteredClients('displayName', 'ASC', true, value, this.groupForm.get('officeId').value)
.subscribe((data: any) => {
this.clientsData = data.pageItems;
});
} else {
this.clientsData = [];
}
});
}
Expand Down Expand Up @@ -214,12 +221,19 @@ export class CreateGroupComponent implements OnInit, AfterViewInit {
};
data.clientMembers = [];
this.clientMembers.forEach((client: any) => data.clientMembers.push(client.id));
this.groupService.createGroup(data).subscribe((response: any) => {
this.router.navigate([
'../groups',
response.resourceId,
'general'
]);

this.loading = true;
this.groupService.createGroup(data).subscribe({
next: (response: any) => {
this.router.navigate([
'../groups',
response.resourceId,
'general'
]);
},
error: () => {
this.loading = false;
}
});
}
}
14 changes: 10 additions & 4 deletions src/app/groups/groups.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -29,17 +29,21 @@
<table mat-table [dataSource]="dataSource" matSort class="bordered-table">
<ng-container matColumnDef="name">
<th mat-header-cell *matHeaderCellDef mat-sort-header>{{ 'labels.inputs.name' | translate }}</th>
<td mat-cell *matCellDef="let group">{{ group.name }}</td>
<td mat-cell *matCellDef="let group">{{ group.name ? group.name : ('labels.text.no-data' | translate) }}</td>
</ng-container>

<ng-container matColumnDef="accountNo">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Account' | translate }} #</th>
<td mat-cell *matCellDef="let group">{{ group.accountNo }}</td>
<td mat-cell *matCellDef="let group">
{{ group.accountNo ? group.accountNo : ('labels.text.no-data' | translate) }}
</td>
</ng-container>

<ng-container matColumnDef="externalId">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.External Id' | translate }}</th>
<td mat-cell *matCellDef="let group">{{ group.externalId }}</td>
<td mat-cell *matCellDef="let group">
{{ group.externalId ? group.externalId : ('labels.text.no-data' | translate) }}
</td>
</ng-container>

<ng-container matColumnDef="status">
Expand All @@ -53,7 +57,9 @@

<ng-container matColumnDef="officeName">
<th mat-header-cell *matHeaderCellDef mat-sort-header>{{ 'labels.inputs.Office Name' | translate }}</th>
<td mat-cell *matCellDef="let group">{{ group.officeName }}</td>
<td mat-cell *matCellDef="let group">
{{ group.officeName ? group.officeName : ('labels.text.no-data' | translate) }}
</td>
</ng-container>

<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
Expand Down
43 changes: 43 additions & 0 deletions src/app/shared/_busy.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/**
* 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/.
*/

// ng-busy directive styles
.busy-loading {
position: relative;
pointer-events: none;

.css-spinner {
display: inline-block;
vertical-align: middle;
}
}

// Button specific busy styles
button.busy-loading {
cursor: not-allowed;
}

// Dots animation for loading indicator
@keyframes dots {
0%,
20% {
opacity: 0;
transform: scale(0.8);
}

50% {
opacity: 1;
transform: scale(1);
}

80%,
100% {
opacity: 0;
transform: scale(0.8);
}
}
161 changes: 161 additions & 0 deletions src/app/shared/directives/ng-busy.directive.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
/**
* 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 {
Directive,
Input,
ElementRef,
Renderer2,
OnInit,
OnChanges,
SimpleChanges,
OnDestroy,
inject
} from '@angular/core';

@Directive({
selector: '[mifosxNgBusy]',
standalone: true
})
export class NgBusyDirective implements OnInit, OnChanges, OnDestroy {
@Input() mifosxNgBusy = false;
@Input() busyText = '';
@Input() busyClass = 'busy-loading';

private originalContent: string | null = null;
private originalDisabled: boolean | null = null;
private spinnerElement: HTMLElement | null = null;
private styleElement: HTMLStyleElement | null = null;
private static globalStylesAdded = false;

private el = inject(ElementRef);
private renderer = inject(Renderer2);

ngOnInit() {
// Store original content and state
this.originalContent = this.el.nativeElement.innerHTML;
this.originalDisabled = this.el.nativeElement.disabled;

// Add global styles for animation
this.addGlobalStyles();
}

ngOnChanges(changes: SimpleChanges) {
if (changes['mifosxNgBusy']) {
this.updateBusyState();
}
}

private updateBusyState() {
if (this.mifosxNgBusy) {
this.showBusyState();
} else {
this.hideBusyState();
}
}

private showBusyState() {
// Add busy class
this.renderer.addClass(this.el.nativeElement, this.busyClass);

// Disable element
this.renderer.setAttribute(this.el.nativeElement, 'disabled', 'true');

// Create busy content
const busyContent = this.createBusyContent();

// Clear and set busy content
this.el.nativeElement.innerHTML = '';
this.el.nativeElement.appendChild(busyContent);
}

private hideBusyState() {
// Remove busy class
this.renderer.removeClass(this.el.nativeElement, this.busyClass);

// Restore original disabled state
if (this.originalDisabled) {
this.renderer.setAttribute(this.el.nativeElement, 'disabled', 'true');
} else {
this.renderer.removeAttribute(this.el.nativeElement, 'disabled');
}

// Restore original content
if (this.originalContent !== null && this.originalContent !== undefined) {
this.el.nativeElement.innerHTML = this.originalContent;
}
}

private createBusyContent(): HTMLElement {
const container = this.renderer.createElement('span');
this.renderer.setStyle(container, 'display', 'inline-flex');
this.renderer.setStyle(container, 'align-items', 'center');
this.renderer.setStyle(container, 'gap', '8px');

// Create CSS spinner with proper rotation
this.spinnerElement = this.renderer.createElement('span');
this.renderer.addClass(this.spinnerElement, 'css-spinner');

// Create spinner using CSS border technique
this.renderer.setStyle(this.spinnerElement, 'display', 'inline-block');
this.renderer.setStyle(this.spinnerElement, 'width', '16px');
this.renderer.setStyle(this.spinnerElement, 'height', '16px');
this.renderer.setStyle(this.spinnerElement, 'border', '2px solid rgba(255, 255, 255, 0.3)');
this.renderer.setStyle(this.spinnerElement, 'border-top', '2px solid #ffffff');
this.renderer.setStyle(this.spinnerElement, 'border-radius', '50%');
this.renderer.setStyle(this.spinnerElement, 'animation', 'spin 1s linear infinite');
this.renderer.setStyle(this.spinnerElement, 'vertical-align', 'middle');

// Create text
const text = this.busyText || 'Loading...';
const textNode = this.renderer.createText(text);

// Assemble
container.appendChild(this.spinnerElement);
container.appendChild(textNode);

return container;
}

private addGlobalStyles() {
// Add proper rotation animation only once globally
if (!NgBusyDirective.globalStylesAdded) {
this.styleElement = this.renderer.createElement('style');
this.renderer.setProperty(
this.styleElement,
'innerHTML',
`
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}

.css-spinner {
border: 2px solid rgba(255, 255, 255, 0.3) !important;
border-top: 2px solid #ffffff !important;
}

button .css-spinner {
border: 2px solid rgba(255, 255, 255, 0.3) !important;
border-top: 2px solid #ffffff !important;
}
`
);
this.renderer.appendChild(document.head, this.styleElement);
NgBusyDirective.globalStylesAdded = true;
}
}

ngOnDestroy() {
// Clean up style element if this is the last instance
if (this.styleElement && NgBusyDirective.globalStylesAdded) {
this.renderer.removeChild(document.head, this.styleElement);
NgBusyDirective.globalStylesAdded = false;
}
}
}
1 change: 1 addition & 0 deletions src/assets/translations/cs-CS.json
Original file line number Diff line number Diff line change
Expand Up @@ -3050,6 +3050,7 @@
},
"text": {
"Loading data": "Načítání dat",
"no-data": "-",
"withoutCategory": "Bez kategorie",
"No repayment schedule available": "Není nalezeny žádný splátkový kalendář",
"About Us": "O Nás",
Expand Down
1 change: 1 addition & 0 deletions src/assets/translations/de-DE.json
Original file line number Diff line number Diff line change
Expand Up @@ -3052,6 +3052,7 @@
},
"text": {
"Loading data": "Daten werden geladen",
"no-data": "-",
"withoutCategory": "Ohne Kategorie",
"No repayment schedule available": "Kein Rückzahlungsplan gefunden",
"About Us": "Über Uns",
Expand Down
Loading