diff --git a/src/app/centers/create-center/create-center.component.html b/src/app/centers/create-center/create-center.component.html index fd1481c823..9c1adec35f 100644 --- a/src/app/centers/create-center/create-center.component.html +++ b/src/app/centers/create-center/create-center.component.html @@ -167,6 +167,8 @@

{{ 'labels.heading.Selected Groups' | translate }}

mat-raised-button color="primary" [disabled]="!centerForm.valid" + [mifosxNgBusy]="loading" + [busyText]="'labels.buttons.Creating...' | translate" (click)="submit()" *mifosxHasPermission="'CREATE_CENTER'" > diff --git a/src/app/centers/create-center/create-center.component.ts b/src/app/centers/create-center/create-center.component.ts index 8db2a9df87..ab1e0dff2a 100644 --- a/src/app/centers/create-center/create-center.component.ts +++ b/src/app/centers/create-center/create-center.component.ts @@ -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. @@ -40,6 +41,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; ...STANDALONE_SHARED_IMPORTS, MatCheckbox, MatIconButton, + NgBusyDirective, FaIconComponent, MatNavList, MatListSubheaderCssMatStyler, @@ -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`. @@ -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; + } }); } } diff --git a/src/app/groups/create-group/create-group.component.html b/src/app/groups/create-group/create-group.component.html index f94c21a05c..bb29d6da9b 100644 --- a/src/app/groups/create-group/create-group.component.html +++ b/src/app/groups/create-group/create-group.component.html @@ -165,7 +165,14 @@

{{ 'labels.heading.Selected Clients' | translate }}

- diff --git a/src/app/groups/create-group/create-group.component.ts b/src/app/groups/create-group/create-group.component.ts index 1934647b2a..a3809f0238 100644 --- a/src/app/groups/create-group/create-group.component.ts +++ b/src/app/groups/create-group/create-group.component.ts @@ -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: [ @@ -43,6 +45,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; MatAutocompleteTrigger, MatAutocomplete, MatIconButton, + NgBusyDirective, FaIconComponent, MatNavList, MatListSubheaderCssMatStyler, @@ -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`. @@ -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 = []; } }); } @@ -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; + } }); } } diff --git a/src/app/groups/groups.component.html b/src/app/groups/groups.component.html index 9e4151ac8a..41eb454ddf 100644 --- a/src/app/groups/groups.component.html +++ b/src/app/groups/groups.component.html @@ -29,17 +29,21 @@ - + - + - + @@ -53,7 +57,9 @@ - + diff --git a/src/app/shared/_busy.scss b/src/app/shared/_busy.scss new file mode 100644 index 0000000000..7c4fc72a1d --- /dev/null +++ b/src/app/shared/_busy.scss @@ -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); + } +} diff --git a/src/app/shared/directives/ng-busy.directive.ts b/src/app/shared/directives/ng-busy.directive.ts new file mode 100644 index 0000000000..3741c7dc30 --- /dev/null +++ b/src/app/shared/directives/ng-busy.directive.ts @@ -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; + } + } +} diff --git a/src/assets/translations/cs-CS.json b/src/assets/translations/cs-CS.json index 57c47c5f9e..a795082f3a 100644 --- a/src/assets/translations/cs-CS.json +++ b/src/assets/translations/cs-CS.json @@ -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", diff --git a/src/assets/translations/de-DE.json b/src/assets/translations/de-DE.json index 3480977de6..c61f1bf78e 100644 --- a/src/assets/translations/de-DE.json +++ b/src/assets/translations/de-DE.json @@ -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", diff --git a/src/assets/translations/en-US.json b/src/assets/translations/en-US.json index ada00c97bb..3f1f357a50 100644 --- a/src/assets/translations/en-US.json +++ b/src/assets/translations/en-US.json @@ -571,6 +571,7 @@ "Show less": "Show less", "Show more": "Show more", "Signing in...": "Signing in...", + "Creating...": "Creating...", "Staff": "Staff", "Staff Assignment History": "Staff Assignment History", "Subledger Account": "Subledger Account", @@ -3103,6 +3104,7 @@ "Add Role": "Add Role", "Add customized reports and edit core reports": "You may add customized reports and edit core reports for your organization.", "Loading data": "Loading data", + "no-data": "-", "No repayment schedule available": "No repayment schedule available", "Add Job Step to Workflow": "Add Job Step to Workflow", "Add new extra fields to any entity": "Add new extra fields to any entity in the form of data table", diff --git a/src/assets/translations/es-CL.json b/src/assets/translations/es-CL.json index 1358687fd6..afbc7c1b4f 100644 --- a/src/assets/translations/es-CL.json +++ b/src/assets/translations/es-CL.json @@ -3051,6 +3051,7 @@ }, "text": { "Loading data": "Cargando datos", + "no-data": "-", "withoutCategory": "Sin categoría", "No repayment schedule available": "No hay cronograma de pagos encontrado", "About Us": "Sobre Nosotros", diff --git a/src/assets/translations/es-MX.json b/src/assets/translations/es-MX.json index 6bac81bca0..ed2510b5aa 100644 --- a/src/assets/translations/es-MX.json +++ b/src/assets/translations/es-MX.json @@ -3082,6 +3082,7 @@ "Failed to update payment type. Please try again.": "Error al actualizar el tipo de pago. Intente de nuevo.", "Review the transaction and recipient details below, then assign the payout.": "Revise los detalles de la transacción y del beneficiario a continuación, luego asigne el pago.", "Loading data": "Cargando datos", + "no-data": "-", "No repayment schedule available": "No se encontró el calendario de pagos", "About Us": "Sobre Nosotros", "A": "Una", diff --git a/src/assets/translations/fr-FR.json b/src/assets/translations/fr-FR.json index 3ae1be0819..e94bf39341 100644 --- a/src/assets/translations/fr-FR.json +++ b/src/assets/translations/fr-FR.json @@ -3053,6 +3053,7 @@ }, "text": { "Loading data": "Chargement des données", + "no-data": "-", "No repayment schedule available": "Aucun calendrier de remboursement trouvé", "About Us": "À Propos de Nous", "A": "UN", diff --git a/src/assets/translations/it-IT.json b/src/assets/translations/it-IT.json index e452fde1b9..f72d85d473 100644 --- a/src/assets/translations/it-IT.json +++ b/src/assets/translations/it-IT.json @@ -3048,6 +3048,7 @@ }, "text": { "Loading data": "Caricamento dati", + "no-data": "-", "No repayment schedule available": "Nessun piano di rimborso trovato", "About Us": "Chi Siamo", "A": "UN", diff --git a/src/assets/translations/ko-KO.json b/src/assets/translations/ko-KO.json index 29e7628d68..bc2395426e 100644 --- a/src/assets/translations/ko-KO.json +++ b/src/assets/translations/ko-KO.json @@ -3049,6 +3049,7 @@ }, "text": { "Loading data": "데이터 로딩 중", + "no-data": "-", "No repayment schedule available": "상환 일정을 사용할 수 없습니다", "About Us": "회사 소개", "A": "ㅏ", diff --git a/src/assets/translations/lt-LT.json b/src/assets/translations/lt-LT.json index fd9461aedf..55308ed929 100644 --- a/src/assets/translations/lt-LT.json +++ b/src/assets/translations/lt-LT.json @@ -24,6 +24,7 @@ "validationSaveError": "Nepavyko išsaugoti patvirtinimo duomenų. Bandykite dar kartą.", "error.msg.charge.attach.to.savings.product.invalid.currency": "Mokesčių ir taupymo produktų valiuta turi būti ta pati.", "error.msg.charge.cannot.be.applied.to.savings.product": "Mokestis su identifikatoriumi `{{params[0].value}} negali būti taikomas taupymo produktui.", + "no-data": "-", "error.msg.charge.cannot.be.deleted.it.is.already.used.in.loan": "Šio mokesčio negalima ištrinti, nes jis jau susietas su paskolos produktu ir (arba) paskola.", "error.msg.charge.due.at.disbursement.cannot.be.penalty": "Mokestis negali būti nustatytas kaip bauda, ​​mokėtina išmokėjimo metu.", "error.msg.charge.duplicate.name": "Mokestis tokiu pavadinimu jau yra.", diff --git a/src/assets/translations/lv-LV.json b/src/assets/translations/lv-LV.json index 15b253d3f3..b014af3079 100644 --- a/src/assets/translations/lv-LV.json +++ b/src/assets/translations/lv-LV.json @@ -3049,6 +3049,7 @@ }, "text": { "Loading data": "Ielādē datus", + "no-data": "-", "No repayment schedule available": "Nav pieejams atmaksas grafiks", "About Us": "Par Mums", "A": "A", diff --git a/src/assets/translations/ne-NE.json b/src/assets/translations/ne-NE.json index e5c737704f..869896140e 100644 --- a/src/assets/translations/ne-NE.json +++ b/src/assets/translations/ne-NE.json @@ -3047,6 +3047,7 @@ }, "text": { "Loading data": "डाटा लोड गर्दै", + "no-data": "-", "No repayment schedule available": "कुनै पुनर्भुक्तानी तालिका उपलब्ध छैन", "About Us": "हाम्रो बारेमा", "Account Transfers": "खाता स्थानान्तरण", diff --git a/src/assets/translations/pt-PT.json b/src/assets/translations/pt-PT.json index 8fa2fcbfd7..d0aa76b886 100644 --- a/src/assets/translations/pt-PT.json +++ b/src/assets/translations/pt-PT.json @@ -3049,6 +3049,7 @@ }, "text": { "Loading data": "Carregando dados", + "no-data": "-", "No repayment schedule available": "Nenhum cronograma de reembolso encontrada", "About Us": "Sobre Nós", "A": "A", diff --git a/src/assets/translations/sw-SW.json b/src/assets/translations/sw-SW.json index ee0e47ce6d..e5b5816ad3 100644 --- a/src/assets/translations/sw-SW.json +++ b/src/assets/translations/sw-SW.json @@ -3045,6 +3045,7 @@ }, "text": { "Loading data": "Inapakia data", + "no-data": "-", "No repayment schedule available": "Hakuna ratiba ya malipo inayopatikana", "About Us": "Kuhusu Sisi", "A": "A",
{{ 'labels.inputs.name' | translate }}{{ group.name }}{{ group.name ? group.name : ('labels.text.no-data' | translate) }} {{ 'labels.inputs.Account' | translate }} #{{ group.accountNo }} + {{ group.accountNo ? group.accountNo : ('labels.text.no-data' | translate) }} + {{ 'labels.inputs.External Id' | translate }}{{ group.externalId }} + {{ group.externalId ? group.externalId : ('labels.text.no-data' | translate) }} + {{ 'labels.inputs.Office Name' | translate }}{{ group.officeName }} + {{ group.officeName ? group.officeName : ('labels.text.no-data' | translate) }} +