diff --git a/e2e/client-empty-account-cta.spec.ts b/e2e/client-empty-account-cta.spec.ts new file mode 100644 index 000000000..cdb61b9bb --- /dev/null +++ b/e2e/client-empty-account-cta.spec.ts @@ -0,0 +1,124 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { expect, test, type Page } from './fixtures'; + +const CLIENT_ID = 2001; +const CLIENT_URL = `/clients/view/${CLIENT_ID}`; + +test.use({ video: 'on' }); + +async function signIn(page: Page): Promise { + await page.route('**/config.json*', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ fineractApiUrl: '/api/v1', defaultTenant: 'default' }), + }); + }); + await page.route('**/api/v1/authentication**', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + username: 'mifos', + userId: 1, + base64EncodedAuthenticationKey: 'YmFzZTY0', + authenticated: true, + officeId: 1, + officeName: 'Head Office', + permissions: ['ALL_FUNCTIONS'], + }), + }); + }); + await page.route('**/api/v1/businessdate**', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([{ type: 'BUSINESS_DATE', date: [2026, 9, 5] }]), + }); + }); + + await page.goto('/login'); + await page.locator('#tenantId').fill('default'); + await page.locator('#username').fill('mifos'); + await page.locator('#password').fill('password'); + await page.getByRole('button', { name: 'Sign In' }).click(); + await expect(page).toHaveURL('/dashboard'); +} + +async function mockClientWithoutAccounts(page: Page): Promise { + await page.route('**/api/v1/clients/2001**', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + id: CLIENT_ID, + accountNo: '000002001', + displayName: 'Empty Account Client', + firstname: 'Empty', + lastname: 'Client', + officeName: 'Head Office', + status: { id: 300, value: 'Active' }, + }), + }); + }); + await page.route('**/api/v1/clients/2001/accounts**', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ loanAccounts: [], savingsAccounts: [] }), + }); + }); +} + +test('empty account tabs offer the matching creation action', async ({ page }, testInfo) => { + await signIn(page); + await mockClientWithoutAccounts(page); + await page.goto(CLIENT_URL); + + await page.getByRole('tab', { name: 'Savings Accounts', exact: true }).click({ force: true }); + const savingsAction = page.getByTestId('client-create-savings-account'); + await expect(savingsAction).toBeVisible(); + + const screenshot = testInfo.outputPath('empty-savings-account-cta.png'); + await page.screenshot({ path: screenshot, fullPage: true }); + await testInfo.attach('empty Savings Accounts CTA', { + path: screenshot, + contentType: 'image/png', + }); + + await savingsAction.click(); + await expect(page).toHaveURL('/products/savings-accounts/create?clientId=2001'); + + await page.goto(CLIENT_URL); + await page.getByRole('tab', { name: 'Loan Accounts', exact: true }).click({ force: true }); + const loanAction = page.getByTestId('client-create-loan-account'); + await expect(loanAction).toBeVisible(); + + const loanScreenshot = testInfo.outputPath('empty-loan-account-cta.png'); + await page.screenshot({ path: loanScreenshot, fullPage: true }); + await testInfo.attach('empty Loan Accounts CTA', { + path: loanScreenshot, + contentType: 'image/png', + }); + + await loanAction.click(); + await expect(page).toHaveURL('/loans/create?clientId=2001'); +}); diff --git a/src/app/features/clients/client-view.component.test.ts b/src/app/features/clients/client-view.component.test.ts index 08c881126..be903d7ca 100644 --- a/src/app/features/clients/client-view.component.test.ts +++ b/src/app/features/clients/client-view.component.test.ts @@ -31,7 +31,7 @@ import { ShareAccountService, } from '../../api'; import { AuthService } from '../../core/services/auth.service'; -import { ActivatedRoute, Router } from '@angular/router'; +import { ActivatedRoute, Router, provideRouter } from '@angular/router'; import { of } from 'rxjs'; import { TranslateModule } from '@ngx-translate/core'; import { provideNoopAnimations } from '@angular/platform-browser/animations'; @@ -50,7 +50,7 @@ describe('ClientViewComponent', () => { let identifierServiceSpy: SpyObj; let authServiceSpy: SpyObj; let shareAccountServiceSpy: SpyObj; - let routerSpy: SpyObj; + let routerSpy: Router; beforeEach(async () => { clientServiceSpy = createSpyObj(['getClientsClientId', 'getClientsClientIdAccounts']); @@ -77,7 +77,7 @@ describe('ClientViewComponent', () => { permissions: ['ALL_FUNCTIONS'], }), }); - routerSpy = createSpyObj(['navigate']); + authServiceSpy.hasPermission.mockReturnValue(true); await TestBed.configureTestingModule({ imports: [ClientViewComponent, TranslateModule.forRoot()], @@ -92,7 +92,7 @@ describe('ClientViewComponent', () => { { provide: ClientIdentifierService, useValue: identifierServiceSpy }, { provide: AuthService, useValue: authServiceSpy }, { provide: ShareAccountService, useValue: shareAccountServiceSpy }, - { provide: Router, useValue: routerSpy }, + provideRouter([]), { provide: ActivatedRoute, useValue: { @@ -129,6 +129,9 @@ describe('ClientViewComponent', () => { identifierServiceSpy.getClientsClientIdIdentifiers.mockReturnValue(of([]) as any); notesServiceSpy.getResourceTypeResourceIdNotes.mockReturnValue(of([]) as any); + routerSpy = TestBed.inject(Router); + vi.spyOn(routerSpy, 'navigate').mockResolvedValue(true); + fixture = TestBed.createComponent(ClientViewComponent); component = fixture.componentInstance; fixture.detectChanges(); @@ -144,6 +147,77 @@ describe('ClientViewComponent', () => { expect(component.client()?.displayName).toBe('John Doe'); }); + describe('empty account tabs', () => { + for (const { tab, testId, permission } of [ + { + tab: CLIENT_TAB.savings, + testId: 'client-create-savings-account', + permission: 'CREATE_SAVINGSACCOUNT', + }, + { + tab: CLIENT_TAB.loans, + testId: 'client-create-loan-account', + permission: 'CREATE_LOAN', + }, + ]) { + it(`disables the ${tab} action without ${permission}`, () => { + authServiceSpy.hasPermission.mockReturnValue(false); + component.onTabChange(tab); + fixture.detectChanges(); + + const button = fixture.nativeElement.querySelector(`[data-testid="${CSS.escape(testId)}"]`); + expect(button).not.toBeNull(); + expect(button.disabled).toBe(true); + expect(button.getAttribute('aria-disabled')).toBe('true'); + expect(authServiceSpy.hasPermission).toHaveBeenCalledWith(permission, false); + }); + + it(`removes the ${tab} empty-state action when accounts arrive`, () => { + component.onTabChange(tab); + fixture.detectChanges(); + expect( + fixture.nativeElement.querySelector(`[data-testid="${CSS.escape(testId)}"]`).disabled, + ).toBe(false); + + clientServiceSpy.getClientsClientIdAccounts.mockReturnValue( + of({ + savingsAccounts: [{ id: 2, accountNo: 'S002', depositType: { id: 100 } }], + loanAccounts: [{ id: 3, accountNo: 'L003' }], + }) as any, + ); + component.loadClientAccounts(); + fixture.detectChanges(); + + expect( + fixture.nativeElement.querySelector(`[data-testid="${CSS.escape(testId)}"]`), + ).toBeNull(); + expect(fixture.nativeElement.querySelector('table')).not.toBeNull(); + }); + } + + it('offers a contextual action to create a savings account', () => { + component.onTabChange(CLIENT_TAB.savings); + fixture.detectChanges(); + + fixture.nativeElement.querySelector('[data-testid="client-create-savings-account"]').click(); + + expect(routerSpy.navigate).toHaveBeenCalledWith(['/products/savings-accounts/create'], { + queryParams: { clientId: 123 }, + }); + }); + + it('offers a contextual action to create a loan', () => { + component.onTabChange(CLIENT_TAB.loans); + fixture.detectChanges(); + + fixture.nativeElement.querySelector('[data-testid="client-create-loan-account"]').click(); + + expect(routerSpy.navigate).toHaveBeenCalledWith(['/loans/create'], { + queryParams: { clientId: 123 }, + }); + }); + }); + describe('deposit accounts', () => { /** * The platform returns savings, fixed deposits and recurring deposits in one array, told diff --git a/src/app/features/clients/client-view.component.ts b/src/app/features/clients/client-view.component.ts index 1827e2c8c..33215f014 100644 --- a/src/app/features/clients/client-view.component.ts +++ b/src/app/features/clients/client-view.component.ts @@ -673,6 +673,15 @@ export type ClientTab = (typeof CLIENT_TAB)[keyof typeof CLIENT_TAB];

{{ 'CLIENTS.NO_SAVINGS_ACCOUNTS' | translate }}

+ + + {{ 'ACTIONS.CREATE_SAVINGS_ACCOUNT' | translate }} +
} @@ -784,6 +793,15 @@ export type ClientTab = (typeof CLIENT_TAB)[keyof typeof CLIENT_TAB];

{{ 'CLIENTS.NO_LOAN_ACCOUNTS' | translate }}

+ + + {{ 'LOANS.CREATE_LOAN' | translate }} +
} @@ -1054,6 +1072,9 @@ export type ClientTab = (typeof CLIENT_TAB)[keyof typeof CLIENT_TAB]; margin: 0; font-size: 16px; } + .empty-state ion-button { + margin-top: 16px; + } .clickable-link { color: #3f51b5; text-decoration: none; diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index 19c7c629e..4921bc647 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -333,6 +333,7 @@ "REJECT_CLIENT": "Reject Client", "RELEASE_AMOUNT": "Release Held Amount", "SAVINGS_ACCOUNT": "Savings Account", + "CREATE_SAVINGS_ACCOUNT": "Create Savings Account", "UNASSIGNMENT_DATE": "Unassignment Date", "UNASSIGN_LOAN_OFFICER": "Unassign Loan Officer", "UNDO_APPROVAL": "Undo approval",