diff --git a/src/app/search/search-page/search-page.component.html b/src/app/search/search-page/search-page.component.html index 826a199bf9..bb62bbfd81 100644 --- a/src/app/search/search-page/search-page.component.html +++ b/src/app/search/search-page/search-page.component.html @@ -19,29 +19,27 @@ {{ 'labels.inputs.Entity Name' | translate }} - {{ entity.entityName }} + {{ getEntityName(entity) }} {{ 'labels.inputs.Account No' | translate }} - + {{ 'labels.inputs.External Id' | translate }} - + {{ 'labels.inputs.Parent Type' | translate }} - - {{ ['CLIENT', 'GROUP', 'CENTER'].includes(entity.entityType) ? 'Office' : 'Client' }} - + {{ getParentType(entity) }} {{ 'labels.inputs.Parent Name' | translate }} - {{ entity.parentName }} + {{ entity.parentName || '' }} {{ 'labels.inputs.Details' | translate }} diff --git a/src/app/search/search-page/search-page.component.spec.ts b/src/app/search/search-page/search-page.component.spec.ts new file mode 100644 index 0000000000..70d125cd5a --- /dev/null +++ b/src/app/search/search-page/search-page.component.spec.ts @@ -0,0 +1,262 @@ +/** + * 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 { Clipboard } from '@angular/cdk/clipboard'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ActivatedRoute, Router } from '@angular/router'; +import { provideAnimationsAsync } from '@angular/platform-browser/animations/async'; +import { TranslateModule, TranslateService } from '@ngx-translate/core'; +import { FaIconLibrary } from '@fortawesome/angular-fontawesome'; +import { faCopy, faEye } from '@fortawesome/free-solid-svg-icons'; +import { of } from 'rxjs'; +import { describe, expect, it, jest } from '@jest/globals'; + +import { AlertService } from 'app/core/alert/alert.service'; +import { SearchData } from '../search.model'; +import { SearchPageComponent } from './search-page.component'; + +describe('SearchPageComponent', () => { + let component: SearchPageComponent; + let fixture: ComponentFixture; + let router: { navigate: jest.Mock }; + + const setup = async (searchResults: SearchData[]) => { + router = { + navigate: jest.fn() + }; + + await TestBed.configureTestingModule({ + imports: [ + SearchPageComponent, + TranslateModule.forRoot() + ], + providers: [ + { + provide: ActivatedRoute, + useValue: { + data: of({ searchResults }) + } + }, + { provide: Router, useValue: router }, + { provide: AlertService, useValue: { alert: jest.fn() } }, + { provide: Clipboard, useValue: { copy: jest.fn() } }, + provideAnimationsAsync() + ] + }).compileComponents(); + + fixture = TestBed.createComponent(SearchPageComponent); + TestBed.inject(FaIconLibrary).addIcons(faCopy, faEye); + TestBed.inject(TranslateService).setTranslation('en', { + labels: { + inputs: { + Client: 'Client', + Group: 'Group', + Office: 'Office' + } + } + }); + TestBed.inject(TranslateService).use('en'); + component = fixture.componentInstance; + fixture.detectChanges(); + }; + + const searchResult = (overrides: Partial): SearchData => ({ + entityId: 10, + entityAccountNo: '000000010', + entityExternalId: 'entity-ext', + entityName: 'Working Capital', + entityType: 'LOAN', + parentId: 20, + parentName: 'Asha Client', + entityStatus: { + id: 300, + code: 'loanStatusType.active', + value: 'Active' + }, + parentType: 'client', + subEntityType: '', + ...overrides + }); + + const tableText = () => (fixture.nativeElement as HTMLElement).textContent || ''; + + it('renders loan transaction rows with transaction id, account number, owner, and external id', async () => { + await setup([ + searchResult({ + entityType: 'LOAN_TRANSACTION', + entityExternalId: 'txn-ext-001', + transactionId: 501, + transactionType: 'repayment', + transactionExternalId: 'txn-ext-001', + accountId: 10, + accountNo: 'LN-00010', + accountType: 'loan' + }) + ]); + + expect(tableText()).toContain('Repayment #501'); + expect(tableText()).toContain('LN-00010'); + expect(tableText()).toContain('Asha Client'); + expect(tableText()).toContain('txn-ext-001'); + }); + + it('renders savings transaction rows with reference number when external id is missing', async () => { + await setup([ + searchResult({ + entityType: 'SAVINGS_TRANSACTION', + entityExternalId: '', + entityName: 'Voluntary Savings', + parentName: 'Market Group', + parentType: 'group', + transactionId: 777, + transactionType: 'withdrawal', + transactionExternalId: '', + transactionRefNo: 'ref-777', + accountId: 11, + accountNo: 'SV-00011', + accountType: 'savings' + }) + ]); + + expect(tableText()).toContain('Withdrawal #777'); + expect(tableText()).toContain('SV-00011'); + expect(tableText()).toContain('Group'); + expect(tableText()).toContain('Market Group'); + expect(tableText()).toContain('ref-777'); + }); + + it('does not render undefined or null when optional transaction fields are missing', async () => { + await setup([ + searchResult({ + entityType: 'SAVINGS_TRANSACTION', + entityExternalId: undefined, + parentName: undefined, + transactionId: undefined, + transactionType: undefined, + transactionExternalId: undefined, + transactionRefNo: undefined, + accountId: undefined, + accountNo: undefined, + accountType: 'savings' + }) + ]); + + expect(tableText()).not.toContain('undefined'); + expect(tableText()).not.toContain('null'); + }); + + it('navigates client-owned loan transactions with separate account and transaction ids', async () => { + await setup([]); + const entity = searchResult({ + entityType: 'LOAN_TRANSACTION', + parentId: 42, + parentType: 'client', + transactionId: 501, + accountId: 10 + }); + + component.navigate(entity); + + expect(router.navigate).toHaveBeenCalledWith([ + 'clients', + 42, + 'loans-accounts', + 10, + 'transactions', + 501 + ]); + }); + + it('navigates group-owned loan transactions with separate account and transaction ids', async () => { + await setup([]); + const entity = searchResult({ + entityType: 'LOAN_TRANSACTION', + parentId: 43, + parentType: 'group', + transactionId: 502, + accountId: 12 + }); + + component.navigate(entity); + + expect(router.navigate).toHaveBeenCalledWith([ + 'groups', + 43, + 'loans-accounts', + 12, + 'transactions', + 502 + ]); + }); + + it('navigates client-owned savings transactions to the general tab', async () => { + await setup([]); + const entity = searchResult({ + entityType: 'SAVINGS_TRANSACTION', + parentId: 42, + parentType: 'client', + transactionId: 776, + accountId: 13 + }); + + component.navigate(entity); + + expect(router.navigate).toHaveBeenCalledWith([ + 'clients', + 42, + 'savings-accounts', + 13, + 'transactions', + 776, + 'general' + ]); + }); + + it('navigates group-owned savings transactions to the general tab', async () => { + await setup([]); + const entity = searchResult({ + entityType: 'SAVINGS_TRANSACTION', + parentId: 43, + parentType: 'group', + transactionId: 777, + accountId: 11 + }); + + component.navigate(entity); + + expect(router.navigate).toHaveBeenCalledWith([ + 'groups', + 43, + 'savings-accounts', + 11, + 'transactions', + 777, + 'general' + ]); + }); + + it('preserves existing non-transaction loan navigation', async () => { + await setup([]); + + component.navigate( + searchResult({ + entityType: 'LOAN', + entityId: 10, + parentId: 42 + }) + ); + + expect(router.navigate).toHaveBeenCalledWith([ + 'clients', + 42, + 'loans-accounts', + 10, + 'general' + ]); + }); +}); diff --git a/src/app/search/search-page/search-page.component.ts b/src/app/search/search-page/search-page.component.ts index c582891ec0..51977e9b33 100644 --- a/src/app/search/search-page/search-page.component.ts +++ b/src/app/search/search-page/search-page.component.ts @@ -31,6 +31,7 @@ import { MatIconButton } from '@angular/material/button'; import { MatTooltip } from '@angular/material/tooltip'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { TranslateService } from '@ngx-translate/core'; /** * Search Page Component @@ -65,6 +66,7 @@ export class SearchPageComponent { private router = inject(Router); private cdr = inject(ChangeDetectorRef); private destroyRef = inject(DestroyRef); + private translateService = inject(TranslateService); /** Flags if number of search results exceed 200 */ overload: boolean; @@ -107,6 +109,16 @@ export class SearchPageComponent { * @param {any} entity Entity */ navigate(entity: SearchData) { + if (this.isLoanTransaction(entity)) { + this.navigateToTransaction(entity, 'loans-accounts'); + return; + } + + if (this.isSavingsTransaction(entity)) { + this.navigateToTransaction(entity, 'savings-accounts', 'general'); + return; + } + switch (entity.entityType) { case 'CLIENT': this.router.navigate([ @@ -180,4 +192,101 @@ export class SearchPageComponent { break; } } + + getEntityName(entity: SearchData): string { + if (!this.isTransaction(entity)) { + return this.emptyIfMissing(entity.entityName); + } + + const transactionType = this.formatTransactionType(entity.transactionType); + const transactionId = this.emptyIfMissing(entity.transactionId); + + if (transactionType && transactionId) { + return `${transactionType} #${transactionId}`; + } + + return transactionType || transactionId; + } + + getAccountNo(entity: SearchData): string { + return this.emptyIfMissing( + this.isTransaction(entity) ? entity.accountNo || entity.entityAccountNo : entity.entityAccountNo + ); + } + + getExternalId(entity: SearchData): string { + const externalId = this.isTransaction(entity) + ? entity.transactionExternalId || entity.transactionRefNo || entity.entityExternalId + : entity.entityExternalId; + + return this.emptyIfMissing(externalId); + } + + getParentType(entity: SearchData): string { + const officeParentEntityTypes = [ + 'CLIENT', + 'GROUP', + 'CENTER' + ]; + + if (officeParentEntityTypes.includes(entity.entityType)) { + return this.translateService.instant('labels.inputs.Office'); + } + + return this.translateService.instant(this.getParentTypeLabel(entity.parentType)); + } + + private isTransaction(entity: SearchData): boolean { + return this.isLoanTransaction(entity) || this.isSavingsTransaction(entity); + } + + private isLoanTransaction(entity: SearchData): boolean { + return entity.entityType === 'LOAN_TRANSACTION'; + } + + private isSavingsTransaction(entity: SearchData): boolean { + return entity.entityType === 'SAVINGS_TRANSACTION'; + } + + private navigateToTransaction(entity: SearchData, accountRoute: string, tab?: string): void { + if (!entity.parentId || !entity.accountId || !entity.transactionId) { + return; + } + + const parentRoute = entity.parentType === 'group' ? 'groups' : 'clients'; + const commands = [ + parentRoute, + entity.parentId, + accountRoute, + entity.accountId, + 'transactions', + entity.transactionId + ]; + + if (tab) { + commands.push(tab); + } + + this.router.navigate(commands); + } + + private emptyIfMissing(value: string | number | null | undefined): string { + return value == null ? '' : `${value}`; + } + + private formatTransactionType(transactionType: string | null | undefined): string { + if (!transactionType) { + return ''; + } + + return transactionType.charAt(0).toUpperCase() + transactionType.slice(1); + } + + private getParentTypeLabel(parentType: string | null | undefined): string { + if (parentType?.toLowerCase() === 'group') { + return 'labels.inputs.Group'; + } + + return 'labels.inputs.Client'; + } } diff --git a/src/app/search/search.model.ts b/src/app/search/search.model.ts index 1211bd6d25..de89e6edc6 100644 --- a/src/app/search/search.model.ts +++ b/src/app/search/search.model.ts @@ -17,6 +17,13 @@ export interface SearchData { entityStatus: EntityStatus; parentType: string; subEntityType: string; + transactionId?: number; + transactionType?: string; + transactionExternalId?: string; + transactionRefNo?: string; + accountId?: number; + accountNo?: string; + accountType?: string; } export interface EntityStatus { diff --git a/src/app/shared/search-tool/search-tool.component.spec.ts b/src/app/shared/search-tool/search-tool.component.spec.ts new file mode 100644 index 0000000000..6815292f11 --- /dev/null +++ b/src/app/shared/search-tool/search-tool.component.spec.ts @@ -0,0 +1,79 @@ +/** + * 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 { ComponentFixture, TestBed } from '@angular/core/testing'; +import { Router } from '@angular/router'; +import { provideAnimationsAsync } from '@angular/platform-browser/animations/async'; +import { TranslateModule } from '@ngx-translate/core'; +import { describe, expect, it, jest } from '@jest/globals'; +import { FaIconLibrary } from '@fortawesome/angular-fontawesome'; +import { faSearch } from '@fortawesome/free-solid-svg-icons'; + +import { SearchToolComponent } from './search-tool.component'; + +describe('SearchToolComponent', () => { + let component: SearchToolComponent; + let fixture: ComponentFixture; + let router: { navigate: jest.Mock }; + + const setup = async () => { + router = { + navigate: jest.fn() + }; + + await TestBed.configureTestingModule({ + imports: [ + SearchToolComponent, + TranslateModule.forRoot() + ], + providers: [ + { provide: Router, useValue: router }, + provideAnimationsAsync() + ] + }).compileComponents(); + + fixture = TestBed.createComponent(SearchToolComponent); + TestBed.inject(FaIconLibrary).addIcons(faSearch); + component = fixture.componentInstance; + fixture.detectChanges(); + }; + + it('maps transaction resource filters to backend resource names', async () => { + await setup(); + + expect(component.resourceOptions).toEqual( + expect.arrayContaining([ + { name: 'TXN Loans', value: 'loanTransactions' }, + { name: 'TXN Savings', value: 'savingsTransactions' } + ]) + ); + }); + + it('includes transaction resources in All search', async () => { + await setup(); + + expect(component.resource.value).toBe( + 'clients,clientIdentifiers,groups,savings,shares,loans,loanTransactions,savingsTransactions' + ); + }); + + it('passes the selected transaction resource to search navigation', async () => { + await setup(); + component.query.patchValue('123'); + component.resource.patchValue('loanTransactions'); + + component.search(); + + expect(router.navigate).toHaveBeenCalledWith(['/search'], { + queryParams: { + query: '123', + resource: 'loanTransactions' + } + }); + }); +}); diff --git a/src/app/shared/search-tool/search-tool.component.ts b/src/app/shared/search-tool/search-tool.component.ts index e8288522cd..190095a7c0 100644 --- a/src/app/shared/search-tool/search-tool.component.ts +++ b/src/app/shared/search-tool/search-tool.component.ts @@ -77,16 +77,23 @@ export class SearchToolComponent { /** Resource Options */ resourceOptions: any[] = [ - { name: 'All', value: 'clients,clientIdentifiers,groups,savings,shares,loans' }, + { + name: 'All', + value: 'clients,clientIdentifiers,groups,savings,shares,loans,loanTransactions,savingsTransactions' + }, { name: 'Clients', value: 'clients,clientIdentifiers' }, { name: 'Groups', value: 'groups' }, { name: 'Savings', value: 'savings' }, + { name: 'TXN Savings', value: 'savingsTransactions' }, { name: 'Shares', value: 'shares' }, - { name: 'Loans', value: 'loans' } + { name: 'Loans', value: 'loans' }, + { name: 'TXN Loans', value: 'loanTransactions' } ]; constructor() { - this.resource.patchValue('clients,clientIdentifiers,groups,savings,shares,loans'); + this.resource.patchValue( + 'clients,clientIdentifiers,groups,savings,shares,loans,loanTransactions,savingsTransactions' + ); this.query.patchValue(''); }