diff --git a/apps/isa-app/src/page/checkout/page-checkout-routing.module.ts b/apps/isa-app/src/page/checkout/page-checkout-routing.module.ts index 0e9679c3b..041b761b9 100644 --- a/apps/isa-app/src/page/checkout/page-checkout-routing.module.ts +++ b/apps/isa-app/src/page/checkout/page-checkout-routing.module.ts @@ -4,6 +4,7 @@ import { CheckoutReviewComponent } from './checkout-review/checkout-review.compo import { CheckoutSummaryComponent } from './checkout-summary/checkout-summary.component'; import { PageCheckoutComponent } from './page-checkout.component'; import { CheckoutReviewDetailsComponent } from './checkout-review/details/checkout-review-details.component'; +import { canDeactivateTabCleanup } from '@isa/core/tabs'; const routes: Routes = [ { @@ -22,10 +23,12 @@ const routes: Routes = [ { path: 'summary', component: CheckoutSummaryComponent, + canDeactivate: [canDeactivateTabCleanup], }, { path: 'summary/:orderIds', component: CheckoutSummaryComponent, + canDeactivate: [canDeactivateTabCleanup], }, { path: '', pathMatch: 'full', redirectTo: 'review' }, ], diff --git a/libs/checkout/feature/reward-order-confirmation/src/lib/routes.ts b/libs/checkout/feature/reward-order-confirmation/src/lib/routes.ts index 838cbfdf5..0cae641f8 100644 --- a/libs/checkout/feature/reward-order-confirmation/src/lib/routes.ts +++ b/libs/checkout/feature/reward-order-confirmation/src/lib/routes.ts @@ -1,6 +1,7 @@ import { Routes } from '@angular/router'; import { CoreCommandModule } from '@core/command'; import { OMS_ACTION_HANDLERS } from '@isa/oms/data-access'; +import { canDeactivateTabCleanup } from '@isa/core/tabs'; export const routes: Routes = [ { @@ -12,5 +13,6 @@ export const routes: Routes = [ import('./reward-order-confirmation.component').then( (m) => m.RewardOrderConfirmationComponent, ), + canDeactivate: [canDeactivateTabCleanup], }, ]; diff --git a/libs/core/tabs/src/index.ts b/libs/core/tabs/src/index.ts index fabdf9f3a..904896e40 100644 --- a/libs/core/tabs/src/index.ts +++ b/libs/core/tabs/src/index.ts @@ -8,3 +8,4 @@ export * from './lib/tab-navigation.constants'; export * from './lib/tab-config'; export * from './lib/helpers'; export * from './lib/has-tab-id.guard'; +export * from './lib/tab-cleanup.guard'; diff --git a/libs/core/tabs/src/lib/helpers.spec.ts b/libs/core/tabs/src/lib/helpers.spec.ts new file mode 100644 index 000000000..1408936f7 --- /dev/null +++ b/libs/core/tabs/src/lib/helpers.spec.ts @@ -0,0 +1,71 @@ +import { getNextTabNameHelper } from './helpers'; +import { Tab } from './schemas'; +import { EntityMap } from '@ngrx/signals/entities'; + +describe('getNextTabNameHelper', () => { + const createTab = (id: number, name: string): Tab => ({ + id, + name, + createdAt: Date.now(), + activatedAt: Date.now(), + tags: [], + metadata: {}, + location: { current: -1, locations: [] }, + }); + + it('should return "Vorgang 1" when no tabs exist', () => { + const entities: EntityMap = {}; + const result = getNextTabNameHelper(entities); + expect(result).toBe('Vorgang 1'); + }); + + it('should return "Vorgang 1" when no Vorgang tabs exist', () => { + const entities: EntityMap = { + 1: createTab(1, 'Bestellbestätigung'), + 2: createTab(2, 'Artikelsuche'), + }; + const result = getNextTabNameHelper(entities); + expect(result).toBe('Vorgang 1'); + }); + + it('should return "Vorgang 2" when one Vorgang tab exists', () => { + const entities: EntityMap = { + 1: createTab(1, 'Vorgang 1'), + }; + const result = getNextTabNameHelper(entities); + expect(result).toBe('Vorgang 2'); + }); + + it('should return "Vorgang 4" when three Vorgang tabs exist', () => { + const entities: EntityMap = { + 1: createTab(1, 'Vorgang 1'), + 2: createTab(2, 'Vorgang 3'), + 3: createTab(3, 'Vorgang 2'), + }; + const result = getNextTabNameHelper(entities); + expect(result).toBe('Vorgang 4'); + }); + + it('should count only Vorgang tabs, ignore other tabs', () => { + const entities: EntityMap = { + 1: createTab(1, 'Vorgang 1'), + 2: createTab(2, 'Bestellbestätigung'), + 3: createTab(3, 'Vorgang 2'), + 4: createTab(4, 'Artikelsuche'), + 5: createTab(5, 'Max Mustermann - Bestellbestätigung'), + }; + const result = getNextTabNameHelper(entities); + expect(result).toBe('Vorgang 3'); + }); + + it('should handle gaps in numbering by counting tabs', () => { + const entities: EntityMap = { + 1: createTab(1, 'Vorgang 1'), + 2: createTab(2, 'Vorgang 5'), + 3: createTab(3, 'Vorgang 10'), + }; + // Count is 3, so next should be Vorgang 4 + const result = getNextTabNameHelper(entities); + expect(result).toBe('Vorgang 4'); + }); +}); diff --git a/libs/core/tabs/src/lib/helpers.ts b/libs/core/tabs/src/lib/helpers.ts index 199ea1a95..e44a0789f 100644 --- a/libs/core/tabs/src/lib/helpers.ts +++ b/libs/core/tabs/src/lib/helpers.ts @@ -27,3 +27,75 @@ export function getMetadataHelper( } return undefined; } + +/** + * Gets the next tab name for a Vorgang (process). + * + * @param entities - All tab entities + * @returns Tab name in format "Vorgang X" where X is the count of existing Vorgang tabs + 1 + * + * Behavior: + * - Counts all tabs matching pattern "Vorgang \\d+" + * - Returns "Vorgang {count + 1}" + * - Example: If 2 Vorgang tabs exist -> returns "Vorgang 3" + */ +export function getNextTabNameHelper(entities: EntityMap): string { + const REGEX_PROCESS_NAME = /^Vorgang \d+$/; + const allTabs = Object.values(entities); + + // Count tabs with "Vorgang X" pattern + const vorgangTabCount = allTabs.filter((tab) => + REGEX_PROCESS_NAME.test(tab.name), + ).length; + + return `Vorgang ${vorgangTabCount + 1}`; +} + +// TODO: #5484 Move Logic to other location +/** + * Formats the customer name for tab display. + * + * For B2B accounts (have 'b2b' feature and not 'staff' feature), shows organization name. + * For regular customers, shows first and last name. + * Falls back to organization name if personal names are missing. + * + * @param customer - The customer data + * @returns Formatted customer name for display, or empty string if no name available + */ +export const formatCustomerTabNameHelper = (customer: { + firstName?: string | null; + lastName?: string | null; + organisation?: { name?: string | null } | null; + features?: Array<{ key?: string }> | null; +}): string => { + // Format tab name with customer info (same logic as details-main-view) + let name = `${customer.firstName ?? ''} ${customer.lastName ?? ''}`.trim(); + + // Check if this is a B2B account (has 'b2b' feature and not 'staff' feature) + const isBusinessKonto = + !!customer.features?.some((f) => f.key === 'b2b') && + !customer.features?.some((f) => f.key === 'staff'); + + // For B2B accounts or when names are missing, use organization name + if ( + (isBusinessKonto && customer.organisation?.name) || + (!customer.firstName && !customer.lastName) + ) { + name = customer.organisation?.name ?? ''; + } + + return name; +}; + +// TODO: #5484 Move Logic to other location +/** + * Helper function to check if a shopping cart has items. + * + * @param cart - The shopping cart object or null/undefined + * @returns True if cart has items, false otherwise + */ +export const checkCartHasItemsHelper = ( + cart: { items?: unknown[] | null } | null | undefined, +): boolean => { + return (cart?.items?.length ?? 0) > 0; +}; diff --git a/libs/core/tabs/src/lib/tab-cleanup.guard.ts b/libs/core/tabs/src/lib/tab-cleanup.guard.ts new file mode 100644 index 000000000..1d25c08c6 --- /dev/null +++ b/libs/core/tabs/src/lib/tab-cleanup.guard.ts @@ -0,0 +1,184 @@ +import { inject } from '@angular/core'; +import { CanDeactivateFn, Router } from '@angular/router'; +import { TabService } from './tab'; +import { logger } from '@isa/core/logging'; +import { + CheckoutMetadataService, + ShoppingCartService, +} from '@isa/checkout/data-access'; +import { + getNextTabNameHelper, + formatCustomerTabNameHelper, + checkCartHasItemsHelper, +} from './helpers'; +import { DomainCheckoutService } from '@domain/checkout'; +import { firstValueFrom } from 'rxjs'; + +// TODO: #5484 Move Guard to other location + Use resources for fetching cart data +/** + * CanDeactivate Guard that manages tab context based on shopping cart state. + * + * This guard checks both the regular shopping cart and reward shopping cart: + * - If BOTH carts are empty (or don't exist), the tab context is cleared and renamed to "Vorgang X" + * - If EITHER cart still has items: + * - Customer context is preserved + * - Tab name is updated to show customer name (or organization name for B2B) + * - process_type is set to 'cart-checkout' to show cart icon + * + * Usage: Apply to checkout-summary routes to automatically manage tab state after order completion. + */ +export const canDeactivateTabCleanup: CanDeactivateFn = async () => { + const tabService = inject(TabService); + const checkoutMetadataService = inject(CheckoutMetadataService); + const shoppingCartService = inject(ShoppingCartService); + const domainCheckoutService = inject(DomainCheckoutService); + const router = inject(Router); + const log = logger(() => ({ guard: 'TabCleanup' })); + + const tabId = tabService.activatedTabId(); + if (!tabId) { + log.warn('No active tab found'); + return true; + } + + // Check if the target URL contains a tab ID + // Routes without tab ID (e.g., /filiale/package-inspection, /kunde/dashboard) are global areas + // Routes with tab ID (e.g., /537825823/return, /kunde/3296528359/search) are tab-specific + const nextUrl = router.getCurrentNavigation()?.finalUrl?.toString() ?? ''; + const hasTabIdInUrl = /\/\d{10,}\//.test(nextUrl); + + // If navigating to a route without tab ID (filial/dashboard areas), keep tab unchanged + if (!hasTabIdInUrl) { + log.debug( + 'Navigating to global area (no tab ID), keeping tab unchanged', + () => ({ + tabId, + nextUrl, + }), + ); + return true; + } + + try { + // Get shopping cart IDs from tab metadata + const shoppingCartId = checkoutMetadataService.getShoppingCartId(tabId); + const rewardShoppingCartId = + checkoutMetadataService.getRewardShoppingCartId(tabId); + + // Load carts and check if they have items + let regularCart = null; + if (shoppingCartId) { + try { + regularCart = await shoppingCartService.getShoppingCart(shoppingCartId); + } catch (error) { + log.debug('Could not load regular shopping cart', () => ({ + shoppingCartId, + error: (error as Error).message, + })); + } + } + + let rewardCart = null; + if (rewardShoppingCartId) { + try { + rewardCart = + await shoppingCartService.getShoppingCart(rewardShoppingCartId); + } catch (error) { + log.debug('Could not load reward shopping cart', () => ({ + rewardShoppingCartId, + error: (error as Error).message, + })); + } + } + + const hasRegularItems = checkCartHasItemsHelper(regularCart); + const hasRewardItems = checkCartHasItemsHelper(rewardCart); + + log.debug('Cart status check', () => ({ + tabId, + shoppingCartId, + rewardShoppingCartId, + hasRegularItems, + hasRewardItems, + })); + + // If either cart has items, preserve context and update tab name with customer info + if (hasRegularItems || hasRewardItems) { + log.info( + 'Preserving checkout context - cart(s) still have items', + () => ({ + tabId, + hasRegularItems, + hasRewardItems, + }), + ); + + try { + // Get customer from checkout service + const customer = await firstValueFrom( + domainCheckoutService.getCustomer({ processId: tabId }), + ); + + if (customer) { + const name = formatCustomerTabNameHelper(customer); + + if (name) { + // Update tab name with customer info + tabService.patchTab(tabId, { name }); + + // Ensure process_type is 'cart' for proper cart icon display + tabService.patchTabMetadata(tabId, { + process_type: 'cart', + }); + + log.info('Updated tab name with customer info', () => ({ + tabId, + customerName: name, + })); + } + } + } catch (error) { + // If customer data can't be loaded, just log and continue + log.warn('Could not load customer for tab name update', () => ({ + tabId, + error: (error as Error).message, + })); + } + + return true; + } + + // Both carts are empty - clean up context + log.info('Cleaning up checkout context - both carts empty', () => ({ + tabId, + })); + + // Remove checkout state from store (customer, buyer, payer, etc.) + domainCheckoutService.removeProcess({ processId: tabId }); + + // Create new shopping cart for the cleaned tab + const newShoppingCart = await shoppingCartService.createShoppingCart(); + checkoutMetadataService.setShoppingCartId(tabId, newShoppingCart.id); + + // Clear tab metadata and location history, but keep process_type for cart icon + tabService.patchTabMetadata(tabId, { process_type: 'cart' }); + tabService.clearLocationHistory(tabId); + + // Rename tab to next "Vorgang X" based on count of existing Vorgang tabs + const tabName = getNextTabNameHelper(tabService.entityMap()); + tabService.patchTab(tabId, { name: tabName }); + + log.info('Tab reset to clean state', () => ({ + tabId, + name: tabName, + newShoppingCartId: newShoppingCart.id, + })); + + return true; + } catch (error) { + log.error('Error in checkout cleanup guard', error as Error, () => ({ + tabId, + })); + return true; // Allow navigation even if cleanup fails + } +}; diff --git a/libs/core/tabs/src/lib/tab.resolver-fn.ts b/libs/core/tabs/src/lib/tab.resolver-fn.ts index 927365d99..f7f525048 100644 --- a/libs/core/tabs/src/lib/tab.resolver-fn.ts +++ b/libs/core/tabs/src/lib/tab.resolver-fn.ts @@ -3,6 +3,7 @@ import { TabService } from './tab'; import { Tab } from './schemas'; import { inject } from '@angular/core'; import { logger } from '@isa/core/logging'; +import { getNextTabNameHelper } from './helpers'; export const tabResolverFn: ResolveFn = (route) => { const log = logger(() => ({ @@ -22,9 +23,10 @@ export const tabResolverFn: ResolveFn = (route) => { let tab = tabService.entityMap()[tabId]; if (!tab) { + const tabName = getNextTabNameHelper(tabService.entityMap()); tab = tabService.addTab({ id: tabId, - name: 'Neuer Vorgang', + name: tabName, metadata: { process_type: 'cart', },