Compare commits

...

34 Commits

Author SHA1 Message Date
Lorenz Hilpert
50363790c1 chore: update dependencies to latest versions
This update includes various minor improvements and security patches
to ensure the application remains stable and secure.
2025-11-18 11:16:55 +01:00
Lorenz Hilpert
65491fb0d4 Merge branch 'develop' into feature/5315-Crm-Card-Booking 2025-11-18 11:04:46 +01:00
Nino Righi
71af23544f Merged PR 2029: feature(crm, isa-app-customer-search): Adjustments to Card Transaction Histor...
feature(crm, isa-app-customer-search): Adjustments to Card Transaction History, Added Scroll Top Button, Show 50 last Transactions, Wording changes
Refs: #5316
2025-11-18 09:58:26 +00:00
Nino
1f2eff8615 feat(crm-customer-booking): add loyalty card booking component
Implement new component for customer loyalty card credit/debit bookings with booking type selection and real-time transaction updates. Includes automatic reload of transaction history after successful bookings.

Key changes:
- Add CrmFeatureCustomerBookingComponent with booking form UI
- Create CustomerCardBookingFacade for booking API calls
- Add CustomerBookingReasonsResource for loading booking types
- Extend CrmSearchService with booking methods (addBooking, fetchBookingReasons, fetchCurrentBookingPartnerStore)
- Add AddBookingSchema with Zod validation
- Integrate component into KundenkarteMainViewComponent
- Update CustomerCardTransactionsResource to providedIn: 'root' for shared access
- Improve transaction list UX (hide header/center empty state when no data)

Technical details:
- New library: @isa/crm/feature/customer-booking (Vitest-based)
- Signals-based state management with computed properties
- Automatic points calculation based on booking type multiplier
- Error handling with feedback dialogs
- 500ms delay before transaction reload to ensure API consistency
- Data attributes for E2E testing (data-what, data-which)

Ref: #5315
2025-11-17 18:31:48 +01:00
Nino
442707774b feat(swagger-crm-api): Swagger CRM Api Update, Refs: #5333 2025-11-17 16:56:16 +01:00
Lorenz Hilpert
e654a4d95e Merged PR 2028: Commit 86563a73: feat(crm): add customer card transactions history feature
Commit 86563a73:  feat(crm): add customer card transactions history feature

Implements #5316 - Service Portal History displaying last 5 loyalty card transactions

**New feature library:**
- Created @isa/crm/feature/customer-card-transactions with CDK table component
- Shows transactions for first activated customer loyalty card
- Displays: Date, Transaction type (reason), Amount (EUR), Receipt number, Points

**Data layer:**
- Added CustomerCardTransactionsResource with reactive resource pattern
- Extended CrmSearchService with fetchLoyaltyBookings() method
- Uses LoyaltyCardService.LoyaltyCardListBookings() API endpoint

**UI/UX:**
- CDK table with ISA design system colors
- Header: 48px height, rounded corners (26px), neutral-400 background
- Visual indicators: Green up arrow (EARN), Red down arrow (BURN)
- German locale formatting (dd.MM.yyyy HH:mm.ss)
- Full-width table layout with proper spacing (24px between rows)
- Empty state when no transactions available

**Icons:**
- Added isaActionPolygonUp and isaActionPolygonDown to @isa/icons

**Integration:**
- Integrated into kundenkarte-main-view component
- Automatically loads transactions for first active card

Related work items: #5316
2025-11-14 13:09:58 +00:00
Lorenz Hilpert
5057d56532 Merged PR 2026: feat(crm): add customer loyalty cards feature with points summary
Related work items: #5312
2025-11-14 12:59:02 +00:00
Nino
70ded96858 Merge branch 'release/4.4' into develop 2025-11-14 10:56:11 +01:00
Nino
7c2c72745f feature(checkout-reward): Enable Delivery Options 2025-11-14 10:55:05 +01:00
Nino
2ea76b6796 fix(core-tabs): Create Shopping Cart Correctly
Ref: #5480
2025-11-13 17:48:11 +01:00
Nino Righi
83292836a3 Merged PR 2027: #5483 Reset Reward Cart + Customer from Tab
#5483 Reset Reward Cart + Customer from Tab

Small Bugfixes to #5480 Customer clearing Logic
2025-11-13 15:49:49 +00:00
Nino Righi
212203fb04 Merged PR 2025: fix(core-tabs): improve tab cleanup and naming logic
fix(core-tabs): improve tab cleanup and naming logic

Refactor tab management to handle checkout state transitions more reliably:

- Extract helpers (formatCustomerTabNameHelper, checkCartHasItemsHelper,
  getNextTabNameHelper) from checkout component to @isa/core/tabs for reuse
- Fix getNextTabNameHelper to count tabs instead of finding max ID,
  preventing gaps in "Vorgang X" numbering
- Add canDeactivateTabCleanup guard to manage tab context based on cart state:
  * Preserves customer context if either cart (regular or reward) has items
  * Updates tab name with customer/organization name when context preserved
  * Resets tab to clean "Vorgang X" state when both carts empty
  * Handles navigation to global areas (without tab ID) gracefully
- Apply canDeactivateTabCleanup to checkout-summary and reward-order-confirmation routes
- Move tab cleanup logic from component ngOnDestroy to reusable guard
- Add comprehensive unit tests for getNextTabNameHelper

This ensures tabs maintain correct state after order completion, properly
display customer context when carts have items, and reset cleanly when
both carts are empty. The guard approach centralizes cleanup logic and
makes it reusable across checkout flows.

Ref: #5480
2025-11-13 14:10:43 +00:00
Lorenz Hilpert
b89cf57a8d fix(tabs): fix tab activation issue 2025-11-12 20:56:53 +01:00
Lorenz Hilpert
b70f2798df fix(auth): Use Angular Router for post-auth redirects
Replace window.location.href with Router.navigateByUrl() to ensure proper Angular navigation flow after authentication, maintaining state and avoiding full page reloads.
2025-11-12 15:58:38 +01:00
Lorenz Hilpert
0066e8baa1 fix(config): Update ISA API endpoint to test environment
Change @swagger/isa rootUrl from isa-feature to isa-test environment
to resolve KulturPass voucher redemption infinite loading issue.

Fixes #5474
2025-11-12 15:55:23 +01:00
Lorenz Hilpert
999f61fcc0 Merged PR 2023: fix(core/tabs): Add logging and fix critical bugs in TabService
fix(core/tabs): Add logging and fix critical bugs in TabService

- Add comprehensive logging using @isa/core/logging for all TabService methods
- Fix null pointer in patchTab when tab doesn't exist
- Fix activateTab allowing non-existent tabs to be set as active
- Fix removeTab not clearing activatedTabId when removing active tab
- Replace unsafe 'as any' type casts with proper type checking
- Document side effect in getCurrentLocation method

Fixes #5474

Related work items: #5474
2025-11-12 13:39:50 +00:00
Nino
b827a6f0a0 fix(isa-app): Fixes Auth and TabID Errors on Startup
Refs: #5472, #5473
2025-11-12 12:40:59 +01:00
Lorenz Hilpert
29b6091a30 chore: update MCP configuration and add Nx guidelines
- Update nx-mcp command format in .mcp.json
- Add Nx configuration section to CLAUDE.md
- Create AGENTS.md with Nx guidelines
- Improve formatting in CLAUDE.md
2025-11-12 12:09:04 +01:00
Nino
989294cc90 chore(azure-pipelines): Version Bump 2025-11-12 11:38:55 +01:00
Nino
c643d988fa Merge branch 'release/4.3' 2025-11-11 21:56:00 +01:00
Nino
463e46e17a chore(azure-pipelines, package-lock): Version Bump 2025-11-11 16:41:29 +01:00
Nino
c98d5666a4 fix(checkout-completion-orchestrator): Added Feedback Error Dialog 2025-11-11 16:07:50 +01:00
Nino
835546a799 feat(reward-purchasing-options): Disable Delivery Options 2025-11-11 15:18:38 +01:00
Lorenz Hilpert
f261fc9987 Merged PR 2021: feat(pickup-shelf): display Prämie label and Lesepunkte for reward items
feat(pickup-shelf): display Prämie label and Lesepunkte for reward items

- Add "Prämie" ui-label badge below product images in both list and details views
- Display Lesepunkte value instead of price for reward items
- Update getOrderItemRewardFeature helper to use structural typing for better type flexibility
- Apply to pickup-shelf-details-item and pickup-shelf-list-item components

Fixes #5467
2025-11-11 14:15:41 +00:00
Lorenz Hilpert
cc186dbbe2 Merged PR 2022: fix(checkout): prevent duplicate tasks in open reward carousel
Related work items: #5468
2025-11-11 14:15:05 +00:00
Nino Righi
6df02d9e86 Merged PR 2020: feat(confirmation-list-item-action-card): improve action card visibility logi...
feat(confirmation-list-item-action-card): improve action card visibility logic and add ordered state

Enhance the action card display logic to show only when both feature flag
and command/completion conditions are met. Add support for "Ordered" state
to distinguish pending items from completed ones.

Changes:
- Add hasLoyaltyCollectCommand helper to check for LOYALTY_COLLECT_COMMAND
- Update displayActionCard logic to require both Rücklage feature AND
  (loyalty collect command OR completion state)
- Add ProcessingStatusState.Ordered to distinguish ordered vs completed items
- Update isComplete to exclude ordered items from completion state
- Move role-based visibility check to outer container level
- Remove unused CSS class for completed state
- Add comprehensive unit tests for new helpers

The action card now correctly appears only for items that need user action,
hiding for CallCenter role and for items without the required commands.

Ref: #5459
2025-11-11 12:16:17 +00:00
Lorenz Hilpert
4a7b74a6c5 Merged PR 2018: add reward points (Prämie) display and label
Related work items: #5413
2025-11-11 09:48:26 +00:00
Lorenz Hilpert
9c989055cb Merged PR 2019: fix(customer-details): prioritize cart navigation over reward return URL
fix(customer-details): prioritize cart navigation over reward return URL

Fixes issue #5461 where navigating to cart after customer selection would
incorrectly route to reward shop page. Changed navigation priority to check
for regular shopping cart items before checking for reward return URL context.

This ensures that active standard checkout flows take precedence over any
lingering reward flow navigation context.

Related work items: #5461
2025-11-11 09:09:29 +00:00
Lorenz Hilpert
2e0853c91a Merged PR 2016: feat(core/auth): add type-safe role-based authorization library
feat(core/auth): add type-safe role-based authorization library

Created @isa/core/auth library with comprehensive role checking:
- RoleService for programmatic hasRole() checks
- IfRoleDirective for declarative *ifRole/*ifNotRole templates
- Type-safe Role enum (CallCenter, Store)
- TokenProvider abstraction with OAuth2 integration
- Signal-based reactive rendering with Angular effects
- Zero-configuration setup via InjectionToken factory

Fixed Bug #5451:
- Hide action buttons for HSC (CallCenter) users on reward order confirmation
- Applied *ifNotRole="Role.CallCenter" to actions container
- Actions now hidden while maintaining card visibility

Testing:
- 18/18 unit tests passing with Vitest
- JUnit and Cobertura reporting configured
- Complete test coverage for role checking logic

Documentation:
- Comprehensive README (817 lines) with API reference
- Usage examples and architecture diagrams
- Updated library-reference.md (62→63 libraries)

Technical:
- Handles both string and array JWT role formats
- Integrated with @isa/core/logging
- Standalone directive (no module imports)
- Full TypeScript type safety

Closes #5451

Related work items: #5451
2025-11-10 17:00:39 +00:00
Nino
c5ea5ed3ec Merge branch 'develop' of https://dev.azure.com/hugendubel/ISA/_git/ISA-Frontend into develop 2025-11-10 16:16:21 +01:00
Nino
7c29429040 fix(get-main-actions): Return only enabled Actions 2025-11-10 16:15:41 +01:00
Nino Righi
c3e9a03169 Merged PR 2015: fix(crm-data-access, customer-details, reward-shopping-cart): persist selecte...
fix(crm-data-access, customer-details, reward-shopping-cart): persist selected addresses across navigation flows

Implement address selection persistence using CRM tab metadata to ensure
selected shipping addresses and payers are retained throughout the customer
selection flow, particularly when navigating from Kundenkarte to reward cart.

Changes include:
- Create PayerResource and CustomerPayerAddressResource to load selected
  payer from tab metadata with fallback to customer as payer
- Create PayerService to fetch payer data from CRM API with proper error
  handling and abort signal support
- Update BillingAndShippingAddressCardComponent to prefer selected addresses
  from metadata over customer defaults, with computed loading state
- Refactor continue() flow in CustomerDetailsViewMainComponent to load
  selected addresses from metadata before setting in checkout service
- Add adapter logic to convert CRM payer/shipping address types to checkout
  types with proper type casting for incompatible enum types
- Implement fallback chain: metadata selection → component state → customer
  default for both payer and shipping address

This ensures address selections made in the address selection dialogs are
properly preserved and applied when completing the customer selection flow,
fixing the issue where addresses would revert to customer defaults.

Ref: #5411
2025-11-10 15:10:56 +00:00
Lorenz Hilpert
3c13a230cc Merged PR 1992: ♻️ refactor(catalog): extract shared Reihe prefix pattern to constants
♻️ refactor(catalog): extract shared Reihe prefix pattern to constants

Ref: #5421

- Create reihe.constants.ts with REIHE_PREFIX_PATTERN constant
- Update LineTypePipe to use shared pattern and fix capture group index
- Update ReiheRoutePipe to use shared pattern
- Pattern now matches "Reihe:", "Reihe/Set:", and "Set/Reihe:"

Related work items: #5421
2025-11-03 10:07:02 +00:00
Lorenz Hilpert
0a5b1dac71 Merged PR 1983: fix(auth): prevent duplicate login popup on slow networks during QR code login
fix(auth): prevent duplicate login popup on slow networks during QR code login

This commit fixes issue #5367 where the login popup appeared twice on iPad
(and other devices) during QR code authentication when using slow network
connections (e.g., Fast 4G).

Root Cause:
During the QR code login flow on slow networks, there was a race condition:
1. User scans QR code and login flow initiates
2. Before SSO redirect completes, HTTP requests (e.g., user storage) fail with 401
3. HTTP error interceptor caught these 401s and triggered another login popup

Changes:

1. HTTP Error Interceptor (http-error.interceptor.ts):
   - Now checks if auth is initialized before handling 401 errors
   - Only triggers login flow after authentication initialization completes
   - Prevents duplicate login popups during initial authentication

2. User Storage Provider (user.storage-provider.ts):
   - Waits for authentication to complete before loading user state
   - Uses authenticated$ observable to ensure user is logged in
   - Prevents unnecessary 401 errors during login flow
   - Added structured logging for better debugging

3. Auth Service (auth.service.ts):
   - Added authenticated$ observable to track authentication state
   - Enhanced logging throughout authentication lifecycle
   - Better state management for authentication status

4. App Module (app.module.ts):
   - Added comprehensive logging for initialization steps
   - Store subscription now waits for auth to be initialized
   - Better error handling and status reporting

5. Storage Tokens (tokens.ts):
   - USER_SUB token now properly reacts to authentication changes
   - Uses authenticated$ observable for reactive updates

Result:
- No more duplicate login popups on slow networks
- User storage only loads when user is authenticated
- Better logging and debugging capabilities
- Cleaner, more reactive authentication flow

Related work items: #5367
2025-10-24 14:31:32 +00:00
175 changed files with 6852 additions and 1211 deletions

View File

@@ -1,22 +1,22 @@
{
"mcpServers": {
"context7": {
"type": "http",
"url": "https://mcp.context7.com/mcp"
},
"nx-mcp": {
"type": "stdio",
"command": "npx",
"args": ["nx-mcp@latest"]
},
"angular-mcp": {
"type": "stdio",
"command": "npx",
"args": ["@angular/cli", "mcp"]
},
"figma-desktop": {
"type": "http",
"url": "http://127.0.0.1:3845/mcp"
}
}
}
{
"mcpServers": {
"context7": {
"type": "http",
"url": "https://mcp.context7.com/mcp"
},
"nx-mcp": {
"type": "stdio",
"command": "npx",
"args": ["nx", "mcp"]
},
"angular-mcp": {
"type": "stdio",
"command": "npx",
"args": ["@angular/cli", "mcp"]
},
"figma-desktop": {
"type": "http",
"url": "http://127.0.0.1:3845/mcp"
}
}
}

13
AGENTS.md Normal file
View File

@@ -0,0 +1,13 @@
<!-- nx configuration start-->
<!-- Leave the start & end comments to automatically receive updates. -->
# General Guidelines for working with Nx
- When running tasks (for example build, lint, test, e2e, etc.), always prefer running the task through `nx` (i.e. `nx run`, `nx run-many`, `nx affected`) instead of using the underlying tooling directly
- You have access to the Nx MCP server and its tools, use them to help the user
- When answering questions about the repository, use the `nx_workspace` tool first to gain an understanding of the workspace architecture where applicable.
- When working in individual projects, use the `nx_project_details` mcp tool to analyze and understand the specific project structure and dependencies
- For questions around nx configuration, best practices or if you're unsure, use the `nx_docs` tool to get relevant, up-to-date docs. Always use this instead of assuming things about nx configuration
- If the user needs help with an Nx configuration or project graph error, use the `nx_workspace` tool to get any errors
<!-- nx configuration end-->

View File

@@ -5,6 +5,7 @@ This file contains meta-instructions for how Claude should work with the ISA-Fro
## 🔴 CRITICAL: Mandatory Agent Usage
**You MUST use these subagents for ALL research and knowledge management tasks:**
- **`docs-researcher`**: For ALL documentation (packages, libraries, READMEs)
- **`docs-researcher-advanced`**: Auto-escalate when docs-researcher fails
- **`Explore`**: For ALL code pattern searches and multi-file analysis
@@ -14,6 +15,7 @@ This file contains meta-instructions for how Claude should work with the ISA-Fro
## Communication Guidelines
**Keep answers concise and focused:**
- Provide direct, actionable responses without unnecessary elaboration
- Skip verbose explanations unless specifically requested
- Focus on what the user needs to know, not everything you know
@@ -26,13 +28,13 @@ This file contains meta-instructions for how Claude should work with the ISA-Fro
### Required Agent Usage
| Task Type | Required Agent | Escalation Path |
|-----------|---------------|-----------------|
| **Package/Library Documentation** | `docs-researcher` | → `docs-researcher-advanced` if not found |
| **Internal Library READMEs** | `docs-researcher` | Keep context clean |
| **Code Pattern Search** | `Explore` | Set thoroughness level |
| **Implementation Analysis** | `Explore` | Multiple file analysis |
| **Single Specific File** | Read tool directly | No agent needed |
| Task Type | Required Agent | Escalation Path |
| --------------------------------- | ------------------ | ----------------------------------------- |
| **Package/Library Documentation** | `docs-researcher` | → `docs-researcher-advanced` if not found |
| **Internal Library READMEs** | `docs-researcher` | Keep context clean |
| **Code Pattern Search** | `Explore` | Set thoroughness level |
| **Implementation Analysis** | `Explore` | Multiple file analysis |
| **Single Specific File** | Read tool directly | No agent needed |
### Documentation Research System (Two-Tier)
@@ -57,3 +59,17 @@ This file contains meta-instructions for how Claude should work with the ISA-Fro
```
**Remember: Using subagents is NOT optional - it's mandatory for maintaining context efficiency and search quality.**
<!-- nx configuration start-->
<!-- Leave the start & end comments to automatically receive updates. -->
# General Guidelines for working with Nx
- When running tasks (for example build, lint, test, e2e, etc.), always prefer running the task through `nx` (i.e. `nx run`, `nx run-many`, `nx affected`) instead of using the underlying tooling directly
- You have access to the Nx MCP server and its tools, use them to help the user
- When answering questions about the repository, use the `nx_workspace` tool first to gain an understanding of the workspace architecture where applicable.
- When working in individual projects, use the `nx_project_details` mcp tool to analyze and understand the specific project structure and dependencies
- For questions around nx configuration, best practices or if you're unsure, use the `nx_docs` tool to get relevant, up-to-date docs. Always use this instead of assuming things about nx configuration
- If the user needs help with an Nx configuration or project graph error, use the `nx_workspace` tool to get any errors
<!-- nx configuration end-->

View File

@@ -29,7 +29,11 @@ import {
ActivateProcessIdWithConfigKeyGuard,
} from './guards/activate-process-id.guard';
import { MatomoRouteData } from 'ngx-matomo-client';
import { tabResolverFn, processResolverFn } from '@isa/core/tabs';
import {
tabResolverFn,
processResolverFn,
hasTabIdGuard,
} from '@isa/core/tabs';
import { provideScrollPositionRestoration } from '@isa/utils/scroll-position';
const routes: Routes = [
@@ -182,7 +186,7 @@ const routes: Routes = [
path: ':tabId',
component: MainComponent,
resolve: { process: processResolverFn, tab: tabResolverFn },
canActivate: [IsAuthenticatedGuard],
canActivate: [IsAuthenticatedGuard, hasTabIdGuard],
children: [
{
path: 'reward',

View File

@@ -68,7 +68,7 @@ import {
matWifiOff,
} from '@ng-icons/material-icons/baseline';
import { NetworkStatusService } from './services/network-status.service';
import { debounceTime, firstValueFrom } from 'rxjs';
import { debounceTime, filter, firstValueFrom, switchMap } from 'rxjs';
import { provideMatomo } from 'ngx-matomo-client';
import { withRouter, withRouteData } from 'ngx-matomo-client';
import {
@@ -77,7 +77,7 @@ import {
LogLevel,
withSink,
ConsoleLogSink,
logger,
logger as loggerFactory,
} from '@isa/core/logging';
import {
IDBStorageProvider,
@@ -85,57 +85,77 @@ import {
UserStorageProvider,
} from '@isa/core/storage';
import { Store } from '@ngrx/store';
import { TabNavigationService } from '@isa/core/tabs';
import { OAuthService } from 'angular-oauth2-oidc';
import z from 'zod';
import { TabNavigationService } from '@isa/core/tabs';
registerLocaleData(localeDe, localeDeExtra);
registerLocaleData(localeDe, 'de', localeDeExtra);
export function _appInitializerFactory(config: Config, injector: Injector) {
return async () => {
// Get logging service for initialization logging
const logger = loggerFactory(() => ({ service: 'AppInitializer' }));
const statusElement = document.querySelector('#init-status');
const laoderElement = document.querySelector('#init-loader');
try {
logger.info('Starting application initialization');
let online = false;
const networkStatus = injector.get(NetworkStatusService);
while (!online) {
online = await firstValueFrom(networkStatus.online$);
if (!online) {
logger.warn('Waiting for network connection');
statusElement.innerHTML =
'<b>Warte auf Netzwerkverbindung (WLAN)</b><br><br>Bitte prüfen Sie die Netzwerkverbindung (WLAN).<br>Sobald eine Netzwerkverbindung besteht, wird die App automatisch neu geladen.';
await new Promise((resolve) => setTimeout(resolve, 250));
}
}
logger.info('Network connection established');
statusElement.innerHTML = 'Konfigurationen werden geladen...';
logger.info('Loading configurations');
statusElement.innerHTML = 'Scanner wird initialisiert...';
logger.info('Initializing scanner');
const scanAdapter = injector.get(ScanAdapterService);
await scanAdapter.init();
logger.info('Scanner initialized');
statusElement.innerHTML = 'Authentifizierung wird geprüft...';
logger.info('Initializing authentication');
const auth = injector.get(AuthService);
try {
await auth.init();
const authenticated = await auth.init();
if (!authenticated) {
throw new Error('User is not authenticated');
}
} catch {
statusElement.innerHTML = 'Authentifizierung wird durchgeführt...';
logger.info('Performing login');
const strategy = injector.get(LoginStrategy);
await strategy.login();
return;
}
statusElement.innerHTML = 'Native Container wird initialisiert...';
logger.info('Initializing native container');
const nativeContainer = injector.get(NativeContainerService);
await nativeContainer.init();
logger.info('Native container initialized');
statusElement.innerHTML = 'Datenbank wird initialisiert...';
logger.info('Initializing database');
await injector.get(IDBStorageProvider).init();
logger.info('Database initialized');
statusElement.innerHTML = 'Benutzerzustand wird geladen...';
logger.info('Loading user storage');
const userStorage = injector.get(UserStorageProvider);
await userStorage.init();
@@ -144,16 +164,29 @@ export function _appInitializerFactory(config: Config, injector: Injector) {
const state = userStorage.get('store');
if (state && state['version'] === version) {
store.dispatch({ type: 'HYDRATE', payload: userStorage.get('store') });
logger.info('Store hydrated from user storage');
} else {
logger.debug('Store hydration skipped', () => ({
reason: state ? 'version mismatch' : 'no stored state',
}));
}
// Subscribe on Store changes and save to user storage
store.pipe(debounceTime(1000)).subscribe((state) => {
userStorage.set('store', { ...state, version });
});
auth.initialized$
.pipe(
filter((initialized) => initialized),
switchMap(() => store.pipe(debounceTime(1000))),
)
.subscribe((state) => {
userStorage.set('store', state);
});
logger.info('Application initialization completed');
// Inject tab navigation service to initialize it
injector.get(TabNavigationService).init();
} catch (error) {
console.error('Error during app initialization', error);
logger.error('Application initialization failed', error as Error, () => ({
message: (error as Error).message,
}));
laoderElement.remove();
statusElement.classList.add('text-xl');
statusElement.innerHTML +=
@@ -199,7 +232,7 @@ export function _notificationsHubOptionsFactory(
}
const USER_SUB_FACTORY = () => {
const _logger = logger(() => ({
const _logger = loggerFactory(() => ({
context: 'USER_SUB',
}));
const auth = inject(OAuthService);

View File

@@ -8,24 +8,25 @@ import {
} from '@angular/common/http';
import { from, NEVER, Observable, throwError } from 'rxjs';
import { catchError, filter, mergeMap, takeUntil } from 'rxjs/operators';
import { LoginStrategy } from '@core/auth';
import { IsaLogProvider } from '../providers';
import { LogLevel } from '@core/logger';
import { AuthService, LoginStrategy } from '@core/auth';
import { injectOnline$ } from '../services/network-status.service';
import { logger } from '@isa/core/logging';
@Injectable()
export class HttpErrorInterceptor implements HttpInterceptor {
readonly offline$ = injectOnline$().pipe(filter((online) => !online));
readonly injector = inject(Injector);
constructor(private _isaLogProvider: IsaLogProvider) {}
#logger = logger(() => ({
'http-interceptor': 'HttpErrorInterceptor',
}));
#offline$ = injectOnline$().pipe(filter((online) => !online));
#injector = inject(Injector);
#auth = inject(AuthService);
intercept(
req: HttpRequest<any>,
next: HttpHandler,
): Observable<HttpEvent<any>> {
return next.handle(req).pipe(
takeUntil(this.offline$),
takeUntil(this.#offline$),
catchError((error: HttpErrorResponse, caught: any) =>
this.handleError(error),
),
@@ -33,18 +34,22 @@ export class HttpErrorInterceptor implements HttpInterceptor {
}
handleError(error: HttpErrorResponse): Observable<any> {
if (error.status === 401) {
const strategy = this.injector.get(LoginStrategy);
return this.#auth.initialized$.pipe(
mergeMap((initialized) => {
if (initialized && error.status === 401) {
const strategy = this.#injector.get(LoginStrategy);
return from(strategy.login('Sie sind nicht mehr angemeldet')).pipe(
mergeMap(() => NEVER),
);
}
return from(strategy.login('Sie sind nicht mehr angemeldet')).pipe(
mergeMap(() => NEVER),
);
}
if (!error.url.endsWith('/isa/logging')) {
this._isaLogProvider.log(LogLevel.ERROR, 'Http Error', error);
}
if (!error.url.endsWith('/isa/logging')) {
this.#logger.error('Http Error', error);
}
return throwError(error);
return throwError(() => error);
}),
);
}
}

View File

@@ -1,85 +1,85 @@
{
"title": "ISA - Feature",
"silentRefresh": {
"interval": 300000
},
"@cdn/product-image": {
"url": "https://produktbilder.paragon-data.net"
},
"@core/auth": {
"issuer": "https://sso-test.paragon-data.de",
"clientId": "hug-isa",
"responseType": "id_token token",
"oidc": true,
"scope": "openid profile cmf_user isa-isa-webapi isa-checkout-webapi isa-cat-webapi isa-ava-webapi isa-crm-webapi isa-review-webapi isa-kpi-webapi isa-oms-webapi isa-nbo-webapi isa-print-webapi eis-service isa-inv-webapi isa-wws-webapi"
},
"@core/logger": {
"logLevel": "debug"
},
"@domain/checkout": {
"olaExpiration": "5m"
},
"@swagger/isa": {
"rootUrl": "https://isa-feature.paragon-data.net/isa/v1"
},
"@swagger/cat": {
"rootUrl": "https://isa-test.paragon-data.net/catsearch/v6"
},
"@swagger/av": {
"rootUrl": "https://isa-test.paragon-data.net/ava/v6"
},
"@swagger/checkout": {
"rootUrl": "https://isa-feature.paragon-data.net/checkout/v6"
},
"@swagger/crm": {
"rootUrl": "https://isa-feature.paragon-data.net/crm/v6"
},
"@swagger/oms": {
"rootUrl": "https://isa-feature.paragon-data.net/oms/v6"
},
"@swagger/print": {
"rootUrl": "https://isa-feature.paragon-data.net/print/v1"
},
"@swagger/eis": {
"rootUrl": "https://filialinformationsystem-test.paragon-systems.de/eiswebapi/v1"
},
"@swagger/remi": {
"rootUrl": "https://isa-feature.paragon-data.net/inv/v6"
},
"@swagger/wws": {
"rootUrl": "https://isa-test.paragon-data.net/wws/v1"
},
"hubs": {
"notifications": {
"url": "https://isa-feature.paragon-data.net/isa/v1/rt",
"enableAutomaticReconnect": false,
"httpOptions": {
"transport": 1,
"logMessageContent": true,
"skipNegotiation": true
}
}
},
"process": {
"ids": {
"goodsOut": 1000,
"goodsIn": 2000,
"taskCalendar": 3000,
"remission": 4000,
"packageInspection": 5000,
"assortment": 6000,
"pickupShelf": 7000
}
},
"checkForUpdates": 3600000,
"licence": {
"scandit": "Ae8F2Wx2RMq5Lvn7UUAlWzVFZTt2+ubMAF8XtDpmPlNkBeG/LWs1M7AbgDW0LQqYLnszClEENaEHS56/6Ts2vrJ1Ux03CXUjK3jUvZpF5OchXR1CpnmpepJ6WxPCd7LMVHUGG1BbwPLDTFjP3y8uT0caTSmmGrYQWAs4CZcEF+ZBabP0z7vfm+hCZF/ebj9qqCJZcW8nH/n19hohshllzYBjFXjh87P2lIh1s6yZS3OaQWWXo/o0AKdxx7T6CVyR0/G5zq6uYJWf6rs3euUBEhpzOZHbHZK86Lvy2AVBEyVkkcttlDW1J2fA4l1W1JV/Xibz8AQV6kG482EpGF42KEoK48paZgX3e1AQsqUtmqzw294dcP4zMVstnw5/WrwKKi/5E/nOOJT2txYP1ZufIjPrwNFsqTlv7xCQlHjMzFGYwT816yD5qLRLbwOtjrkUPXNZLZ06T4upvWwJDmm8XgdeoDqMjHdcO4lwji1bl9EiIYJ/2qnsk9yZ2FqSaHzn4cbiL0f5u2HFlNAP0GUujGRlthGhHi6o4dFU+WAxKsFMKVt+SfoQUazNKHFVQgiAklTIZxIc/HUVzRvOLMxf+wFDerraBtcqGJg+g/5mrWYqeDBGhCBHtKiYf6244IJ4afzNTiH1/30SJcRzXwbEa3A7q1fJTx9/nLTOfVPrJKBQs7f/OQs2dA7LDCel8mzXdbjvsNQaeU5+iCIAq6zbTNKy1xT8wwj+VZrQmtNJs+qeznD+u29nCM24h8xCmRpvNPo4/Mww/lrTNrrNwLBSn1pMIwsH7yS9hH0v0oNAM3A6bVtk1D9qEkbyw+xZa+MZGpMP0D0CdcsqHalPcm5r/Ik="
},
"gender": {
"0": "Keine Anrede",
"1": "Enby",
"2": "Herr",
"4": "Frau"
},
"@shared/icon": "/assets/icons.json"
}
{
"title": "ISA - Feature",
"silentRefresh": {
"interval": 300000
},
"@cdn/product-image": {
"url": "https://produktbilder.paragon-data.net"
},
"@core/auth": {
"issuer": "https://sso-test.paragon-data.de",
"clientId": "hug-isa",
"responseType": "id_token token",
"oidc": true,
"scope": "openid profile cmf_user isa-isa-webapi isa-checkout-webapi isa-cat-webapi isa-ava-webapi isa-crm-webapi isa-review-webapi isa-kpi-webapi isa-oms-webapi isa-nbo-webapi isa-print-webapi eis-service isa-inv-webapi isa-wws-webapi"
},
"@core/logger": {
"logLevel": "debug"
},
"@domain/checkout": {
"olaExpiration": "5m"
},
"@swagger/isa": {
"rootUrl": "https://isa-test.paragon-data.net/isa/v1"
},
"@swagger/cat": {
"rootUrl": "https://isa-test.paragon-data.net/catsearch/v6"
},
"@swagger/av": {
"rootUrl": "https://isa-test.paragon-data.net/ava/v6"
},
"@swagger/checkout": {
"rootUrl": "https://isa-feature.paragon-data.net/checkout/v6"
},
"@swagger/crm": {
"rootUrl": "https://isa-feature.paragon-data.net/crm/v6"
},
"@swagger/oms": {
"rootUrl": "https://isa-feature.paragon-data.net/oms/v6"
},
"@swagger/print": {
"rootUrl": "https://isa-feature.paragon-data.net/print/v1"
},
"@swagger/eis": {
"rootUrl": "https://filialinformationsystem-test.paragon-systems.de/eiswebapi/v1"
},
"@swagger/remi": {
"rootUrl": "https://isa-feature.paragon-data.net/inv/v6"
},
"@swagger/wws": {
"rootUrl": "https://isa-test.paragon-data.net/wws/v1"
},
"hubs": {
"notifications": {
"url": "https://isa-feature.paragon-data.net/isa/v1/rt",
"enableAutomaticReconnect": false,
"httpOptions": {
"transport": 1,
"logMessageContent": true,
"skipNegotiation": true
}
}
},
"process": {
"ids": {
"goodsOut": 1000,
"goodsIn": 2000,
"taskCalendar": 3000,
"remission": 4000,
"packageInspection": 5000,
"assortment": 6000,
"pickupShelf": 7000
}
},
"checkForUpdates": 3600000,
"licence": {
"scandit": "Ae8F2Wx2RMq5Lvn7UUAlWzVFZTt2+ubMAF8XtDpmPlNkBeG/LWs1M7AbgDW0LQqYLnszClEENaEHS56/6Ts2vrJ1Ux03CXUjK3jUvZpF5OchXR1CpnmpepJ6WxPCd7LMVHUGG1BbwPLDTFjP3y8uT0caTSmmGrYQWAs4CZcEF+ZBabP0z7vfm+hCZF/ebj9qqCJZcW8nH/n19hohshllzYBjFXjh87P2lIh1s6yZS3OaQWWXo/o0AKdxx7T6CVyR0/G5zq6uYJWf6rs3euUBEhpzOZHbHZK86Lvy2AVBEyVkkcttlDW1J2fA4l1W1JV/Xibz8AQV6kG482EpGF42KEoK48paZgX3e1AQsqUtmqzw294dcP4zMVstnw5/WrwKKi/5E/nOOJT2txYP1ZufIjPrwNFsqTlv7xCQlHjMzFGYwT816yD5qLRLbwOtjrkUPXNZLZ06T4upvWwJDmm8XgdeoDqMjHdcO4lwji1bl9EiIYJ/2qnsk9yZ2FqSaHzn4cbiL0f5u2HFlNAP0GUujGRlthGhHi6o4dFU+WAxKsFMKVt+SfoQUazNKHFVQgiAklTIZxIc/HUVzRvOLMxf+wFDerraBtcqGJg+g/5mrWYqeDBGhCBHtKiYf6244IJ4afzNTiH1/30SJcRzXwbEa3A7q1fJTx9/nLTOfVPrJKBQs7f/OQs2dA7LDCel8mzXdbjvsNQaeU5+iCIAq6zbTNKy1xT8wwj+VZrQmtNJs+qeznD+u29nCM24h8xCmRpvNPo4/Mww/lrTNrrNwLBSn1pMIwsH7yS9hH0v0oNAM3A6bVtk1D9qEkbyw+xZa+MZGpMP0D0CdcsqHalPcm5r/Ik="
},
"gender": {
"0": "Keine Anrede",
"1": "Enby",
"2": "Herr",
"4": "Frau"
},
"@shared/icon": "/assets/icons.json"
}

View File

@@ -5,6 +5,8 @@ import { isNullOrUndefined } from '@utils/common';
import { AuthConfig, OAuthService } from 'angular-oauth2-oidc';
import { JwksValidationHandler } from 'angular-oauth2-oidc-jwks';
import { BehaviorSubject } from 'rxjs';
import { logger } from '@isa/core/logging';
import { Router } from '@angular/router';
/**
* Storage key for the URL to redirect to after login
@@ -15,9 +17,17 @@ const REDIRECT_URL_KEY = 'auth_redirect_url';
providedIn: 'root',
})
export class AuthService {
private readonly _initialized = new BehaviorSubject<boolean>(false);
#logger = logger(() => ({ service: 'AuthService' }));
#router = inject(Router);
#initialized = new BehaviorSubject<boolean>(false);
get initialized$() {
return this._initialized.asObservable();
return this.#initialized.asObservable();
}
#authenticated = new BehaviorSubject<boolean>(false);
get authenticated$() {
return this.#authenticated.asObservable();
}
private _authConfig: AuthConfig;
@@ -27,16 +37,21 @@ export class AuthService {
) {
this._oAuthService.events?.subscribe((event) => {
if (event.type === 'token_received') {
console.log(
'SSO Token Expiration:',
new Date(this._oAuthService.getAccessTokenExpiration()),
);
this.#logger.info('SSO token received', () => ({
tokenExpiration: new Date(
this._oAuthService.getAccessTokenExpiration(),
).toISOString(),
}));
// Handle redirect after successful authentication
setTimeout(() => {
const redirectUrl = this._getAndClearRedirectUrl();
if (redirectUrl) {
window.location.href = redirectUrl;
this.#logger.debug('Redirecting after authentication', () => ({
redirectUrl,
}));
this.#router.navigateByUrl(redirectUrl);
}
}, 100);
}
@@ -44,50 +59,72 @@ export class AuthService {
}
async init() {
if (this._initialized.getValue()) {
if (this.#initialized.getValue()) {
this.#logger.error(
'AuthService initialization attempted twice',
new Error('Already initialized'),
);
throw new Error('AuthService is already initialized');
}
this.#logger.info('Initializing AuthService');
this._authConfig = this._config.get('@core/auth');
this.#logger.debug('Auth config loaded', () => ({
issuer: this._authConfig.issuer,
clientId: this._authConfig.clientId,
scope: this._authConfig.scope,
}));
this._authConfig.redirectUri = window.location.origin;
this._authConfig.silentRefreshRedirectUri =
window.location.origin + '/silent-refresh.html';
this._authConfig.useSilentRefresh = true;
this.#logger.debug('Auth URIs configured', () => ({
redirectUri: this._authConfig.redirectUri,
silentRefreshRedirectUri: this._authConfig.silentRefreshRedirectUri,
}));
this._oAuthService.configure(this._authConfig);
this._oAuthService.tokenValidationHandler = new JwksValidationHandler();
this.#logger.debug('Setting up automatic silent refresh');
this._oAuthService.setupAutomaticSilentRefresh();
await this._oAuthService.loadDiscoveryDocumentAndTryLogin();
this.#logger.debug('Loading discovery document and attempting login');
const authenticated =
await this._oAuthService.loadDiscoveryDocumentAndTryLogin();
if (!this._oAuthService.getAccessToken()) {
throw new Error('No access token. User is not authenticated.');
}
this.#authenticated.next(authenticated);
this.#logger.info('AuthService initialized', () => ({ authenticated }));
this._initialized.next(true);
this.#initialized.next(true);
return authenticated;
}
isAuthenticated() {
return this.isIdTokenValid();
return this.#authenticated.getValue();
}
isIdTokenValid() {
console.log(
'ID Token Expiration:',
new Date(this._oAuthService.getIdTokenExpiration()),
);
return this._oAuthService.hasValidIdToken();
const expiration = new Date(this._oAuthService.getIdTokenExpiration());
const isValid = this._oAuthService.hasValidIdToken();
this.#logger.debug('ID token validation check', () => ({
expiration: expiration.toISOString(),
isValid,
}));
return isValid;
}
isAccessTokenValid() {
console.log(
'ACCESS Token Expiration:',
new Date(this._oAuthService.getAccessTokenExpiration()),
);
return this._oAuthService.hasValidAccessToken();
const expiration = new Date(this._oAuthService.getAccessTokenExpiration());
const isValid = this._oAuthService.hasValidAccessToken();
this.#logger.debug('Access token validation check', () => ({
expiration: expiration.toISOString(),
isValid,
}));
return isValid;
}
getToken() {
@@ -111,6 +148,7 @@ export class AuthService {
if (isNullOrUndefined(token)) {
return null;
}
const base64Url = token.split('.')[1];
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
@@ -135,18 +173,22 @@ export class AuthService {
}
login() {
this.#logger.info('Initiating login flow');
this._saveRedirectUrl();
this._oAuthService.initLoginFlow();
}
setKeyCardToken(token: string) {
this.#logger.debug('Setting keycard token');
this._oAuthService.customQueryParams = {
temp_token: token,
};
}
async logout() {
this.#logger.info('Initiating logout');
await this._oAuthService.revokeTokenAndLogout();
this.#logger.info('Logout completed');
}
hasRole(role: string | string[]) {
@@ -163,16 +205,20 @@ export class AuthService {
async refresh() {
try {
this.#logger.debug('Refreshing authentication token');
if (
this._authConfig.responseType.includes('code') &&
this._authConfig.scope.includes('offline_access')
) {
await this._oAuthService.refreshToken();
this.#logger.info('Token refreshed using refresh token');
} else {
await this._oAuthService.silentRefresh();
this.#logger.info('Token refreshed using silent refresh');
}
} catch (error) {
console.error(error);
this.#logger.error('Token refresh failed', error as Error);
}
}
}

View File

@@ -1,4 +1,5 @@
import { Pipe, PipeTransform } from '@angular/core';
import { REIHE_PREFIX_PATTERN } from './reihe.constants';
@Pipe({
name: 'lineType',
@@ -7,8 +8,8 @@ import { Pipe, PipeTransform } from '@angular/core';
})
export class LineTypePipe implements PipeTransform {
transform(value: string, ...args: any[]): 'text' | 'reihe' {
const REIHE_REGEX = /^Reihe:\s*"(.+)\"$/g;
const reihe = REIHE_REGEX.exec(value)?.[1];
const REIHE_REGEX = new RegExp(`^${REIHE_PREFIX_PATTERN}:\\s*"(.+)"$`, 'g');
const reihe = REIHE_REGEX.exec(value)?.[2];
return reihe ? 'reihe' : 'text';
}
}

View File

@@ -1,9 +1,15 @@
import { ChangeDetectorRef, OnDestroy, Pipe, PipeTransform } from '@angular/core';
import {
ChangeDetectorRef,
OnDestroy,
Pipe,
PipeTransform,
} from '@angular/core';
import { ApplicationService } from '@core/application';
import { ProductCatalogNavigationService } from '@shared/services/navigation';
import { isEqual } from 'lodash';
import { Subscription, combineLatest, BehaviorSubject } from 'rxjs';
import { distinctUntilChanged } from 'rxjs/operators';
import { REIHE_PREFIX_PATTERN } from './reihe.constants';
@Pipe({
name: 'reiheRoute',
@@ -22,10 +28,13 @@ export class ReiheRoutePipe implements PipeTransform, OnDestroy {
private application: ApplicationService,
private cdr: ChangeDetectorRef,
) {
this.subscription = combineLatest([this.application.activatedProcessId$, this.value$])
this.subscription = combineLatest([
this.application.activatedProcessId$,
this.value$,
])
.pipe(distinctUntilChanged(isEqual))
.subscribe(([processId, value]) => {
const REIHE_REGEX = /[";]|Reihe:/g; // Entferne jedes Semikolon, Anführungszeichen und den String Reihe:
const REIHE_REGEX = new RegExp(`[";]|${REIHE_PREFIX_PATTERN}:`, 'g'); // Entferne jedes Semikolon, Anführungszeichen und den String Reihe:/Reihe/Set:/Set/Reihe:
const reihe = value?.replace(REIHE_REGEX, '')?.trim();
if (!reihe) {
@@ -33,9 +42,15 @@ export class ReiheRoutePipe implements PipeTransform, OnDestroy {
return;
}
const main_qs = reihe.split('/')[0];
// Entferne Zahlen am Ende, die mit Leerzeichen, Komma, Slash oder Semikolon getrennt sind
// Beispiele: "Harry Potter 1" -> "Harry Potter", "Harry Potter,1" -> "Harry Potter", "Harry Potter/2" -> "Harry Potter"
const main_qs = reihe
.split('/')[0]
.replace(/[\s,;]+\d+$/g, '')
.trim();
const path = this.navigation.getArticleSearchResultsPath(processId).path;
const path =
this.navigation.getArticleSearchResultsPath(processId).path;
this.result = {
path,

View File

@@ -0,0 +1,5 @@
/**
* Shared regex pattern for matching Reihe line prefixes.
* Matches: "Reihe:", "Reihe/Set:", or "Set/Reihe:"
*/
export const REIHE_PREFIX_PATTERN = '(Reihe|Reihe\\/Set|Set\\/Reihe)';

View File

@@ -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' },
],

View File

@@ -36,13 +36,21 @@ import { CrmCustomerService } from '@domain/crm';
import { MessageModalComponent, MessageModalData } from '@modal/message';
import { GenderSettingsService } from '@shared/services/gender';
import { toSignal } from '@angular/core/rxjs-interop';
import { CrmTabMetadataService, Customer } from '@isa/crm/data-access';
import { CustomerAdapter } from '@isa/checkout/data-access';
import {
CrmTabMetadataService,
Customer,
AssignedPayer,
} from '@isa/crm/data-access';
import {
CustomerAdapter,
ShippingAddressAdapter,
} from '@isa/checkout/data-access';
import {
NavigateAfterRewardSelection,
RewardSelectionPopUpService,
} from '@isa/checkout/shared/reward-selection-dialog';
import { NavigationStateService } from '@isa/core/navigation';
import { ShippingAddressDTO as CrmShippingAddressDTO } from '@generated/swagger/crm-api';
export interface CustomerDetailsViewMainState {
isBusy: boolean;
@@ -407,9 +415,18 @@ export class CustomerDetailsViewMainComponent
await this._updateNotifcationChannelsAsync(currentBuyer);
this._setPayer();
await this._setPayer();
this._setShippingAddress();
await this._setShippingAddress();
// #5461 Priority fix: Check for regular shopping cart items BEFORE reward return URL
// This ensures that if a user has items in their regular cart, that takes precedence
// over any lingering reward flow context
if (this.shoppingCartHasItems) {
await this.#rewardSelectionPopUpFlow(this.processId);
this.setIsBusy(false);
return;
}
// #5262 Check for reward selection flow before navigation
if (this.hasReturnUrl()) {
@@ -429,16 +446,11 @@ export class CustomerDetailsViewMainComponent
return;
}
// Regular checkout navigation
if (this.shoppingCartHasItems) {
await this.#rewardSelectionPopUpFlow(this.processId);
} else {
// Navigation zur Artikelsuche
const path = this._catalogNavigation.getArticleSearchBasePath(
this.processId,
).path;
await this._router.navigate(path);
}
// Navigation zur Artikelsuche
const path = this._catalogNavigation.getArticleSearchBasePath(
this.processId,
).path;
await this._router.navigate(path);
this.setIsBusy(false);
}
@@ -631,8 +643,46 @@ export class CustomerDetailsViewMainComponent
}
}
@log
_setPayer() {
@logAsync
async _setPayer() {
// Check if there's a selected payer in metadata (from previous address selection)
const selectedPayerId = this.crmTabMetadataService.selectedPayerId(
this.processId,
);
if (selectedPayerId) {
// Load the selected payer from metadata
try {
const payerResponse = await this.customerService
.getPayer(selectedPayerId)
.toPromise();
if (payerResponse?.result) {
// Create AssignedPayer structure expected by adapter
// Type cast needed due to incompatible enum types between CRM and Checkout APIs
const assignedPayer = {
payer: {
id: selectedPayerId,
data: payerResponse.result,
},
} as AssignedPayer;
const payer = CustomerAdapter.toPayerFromAssignedPayer(assignedPayer);
if (payer) {
this._checkoutService.setPayer({
processId: this.processId,
payer,
});
return;
}
}
} catch (error) {
console.error('Failed to load selected payer from metadata', error);
}
}
// Fallback to current payer from component state
if (this.payer) {
this._checkoutService.setPayer({
processId: this.processId,
@@ -641,8 +691,41 @@ export class CustomerDetailsViewMainComponent
}
}
@log
_setShippingAddress() {
@logAsync
async _setShippingAddress() {
// Check if there's a selected shipping address in metadata (from previous address selection)
const selectedShippingAddressId =
this.crmTabMetadataService.selectedShippingAddressId(this.processId);
if (selectedShippingAddressId) {
// Load the selected shipping address from metadata
try {
const addressResponse = await this.customerService
.getShippingAddress(selectedShippingAddressId)
.toPromise();
if (addressResponse?.result) {
const shippingAddress = ShippingAddressAdapter.fromCrmShippingAddress(
addressResponse.result as CrmShippingAddressDTO,
);
if (shippingAddress) {
this._checkoutService.setShippingAddress({
processId: this.processId,
shippingAddress,
});
return;
}
}
} catch (error) {
console.error(
'Failed to load selected shipping address from metadata',
error,
);
}
}
// Fallback to current shipping address from component state
if (this.shippingAddress) {
this._checkoutService.setShippingAddress({
processId: this.processId,

View File

@@ -1,3 +1,3 @@
:host {
@apply grid grid-flow-row items-center gap-4 bg-surface text-surface-content rounded px-4 py-6;
@apply max-h-[calc(100vh-14rem)] grid grid-flow-row items-center gap-4 bg-surface text-surface-content rounded px-4 py-6 overflow-hidden overflow-y-scroll;
}

View File

@@ -1,34 +1,21 @@
<div class="flex flex-row justify-end -mt-2">
<page-customer-menu [customerId]="customerId$ | async" [processId]="processId$ | async" [showCustomerCard]="false"></page-customer-menu>
</div>
<h1 class="text-center text-2xl font-bold">Kundenkarte</h1>
@if (!(noDataFound$ | async)) {
<p class="text-center text-xl">
Alle Infos zu Ihrer Kundenkarte
<br />
und allen Partnerkarten.
</p>
}
@if (noDataFound$ | async) {
<p class="text-center text-xl">Keine Kundenkarte gefunden.</p>
}
@for (karte of primaryKundenkarte$ | async; track karte) {
<page-customer-kundenkarte
class="justify-self-center"
[cardDetails]="karte"
[isCustomerCard]="true"
[customerId]="customerId$ | async"
></page-customer-kundenkarte>
}
@if ((partnerKundenkarte$ | async)?.length) {
<p class="text-center text-xl font-bold">Partnerkarten</p>
}
@for (karte of partnerKundenkarte$ | async; track karte) {
<page-customer-kundenkarte
class="justify-self-center"
[cardDetails]="karte"
[isCustomerCard]="false"
></page-customer-kundenkarte>
}
<div class="flex flex-row justify-end -mt-2">
<page-customer-menu
[customerId]="customerId$ | async"
[processId]="processId$ | async"
[showCustomerCard]="false"
/>
</div>
<crm-customer-loyalty-cards
[customerId]="customerId$ | async"
[tabId]="processId$ | async"
class="mt-4"
/>
<crm-customer-booking [cardCode]="firstActiveCardCode()" class="mt-4" />
<crm-customer-card-transactions
[cardCode]="firstActiveCardCode()"
class="mt-8"
/>
<utils-scroll-top-button
[target]="hostElement"
class="flex flex-col justify-self-end fixed bottom-6 right-6"
></utils-scroll-top-button>

View File

@@ -1,63 +1,77 @@
import { Component, ChangeDetectionStrategy, OnInit, OnDestroy, inject } from '@angular/core';
import { CustomerSearchStore } from '../store';
import { ActivatedRoute } from '@angular/router';
import { Subject, combineLatest, of } from 'rxjs';
import { catchError, map, share, switchMap } from 'rxjs/operators';
import { CrmCustomerService } from '@domain/crm';
import { KundenkarteComponent } from '../../components/kundenkarte';
import { AsyncPipe } from '@angular/common';
import { CustomerSearchNavigation } from '@shared/services/navigation';
import { BonusCardInfoDTO } from '@generated/swagger/crm-api';
import { CustomerMenuComponent } from '../../components/customer-menu';
@Component({
selector: 'page-customer-kundenkarte-main-view',
templateUrl: 'kundenkarte-main-view.component.html',
styleUrls: ['kundenkarte-main-view.component.css'],
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'page-customer-kundenkarte-main-view' },
imports: [CustomerMenuComponent, KundenkarteComponent, AsyncPipe],
})
export class KundenkarteMainViewComponent implements OnInit, OnDestroy {
private _store = inject(CustomerSearchStore);
private _activatedRoute = inject(ActivatedRoute);
private _customerService = inject(CrmCustomerService);
private _navigation = inject(CustomerSearchNavigation);
private _onDestroy$ = new Subject<void>();
customerId$ = this._activatedRoute.params.pipe(map((params) => params.customerId));
processId$ = this._store.processId$;
kundenkarte$ = this.customerId$.pipe(
switchMap((customerId) =>
this._customerService.getCustomerCard(customerId).pipe(
map((response) => response.result?.filter((f) => f.isActive)),
catchError(() => of<BonusCardInfoDTO[]>([])),
),
),
share(),
);
noDataFound$ = this.kundenkarte$.pipe(map((kundenkarte) => kundenkarte?.length == 0));
primaryKundenkarte$ = this.kundenkarte$.pipe(map((kundenkarte) => kundenkarte?.filter((k) => k.isPrimary)));
partnerKundenkarte$ = this.kundenkarte$.pipe(map((kundenkarte) => kundenkarte?.filter((k) => !k.isPrimary)));
detailsRoute$ = combineLatest([this._store.processId$, this._store.customerId$]).pipe(
map(([processId, customerId]) => this._navigation.detailsRoute({ processId, customerId })),
);
ngOnInit() {
this.customerId$.subscribe((customerId) => {
this._store.selectCustomer(customerId);
});
}
ngOnDestroy() {
this._onDestroy$.next();
this._onDestroy$.complete();
}
}
import {
Component,
ChangeDetectionStrategy,
inject,
computed,
effect,
ElementRef,
} from '@angular/core';
import { CustomerSearchStore } from '../store';
import { ActivatedRoute } from '@angular/router';
import { map } from 'rxjs/operators';
import { AsyncPipe } from '@angular/common';
import { CustomerMenuComponent } from '../../components/customer-menu';
import { CustomerLoyaltyCardsComponent } from '@isa/crm/feature/customer-loyalty-cards';
import { CrmFeatureCustomerCardTransactionsComponent } from '@isa/crm/feature/customer-card-transactions';
import { toSignal } from '@angular/core/rxjs-interop';
import { CustomerBonusCardsResource } from '@isa/crm/data-access';
import { CrmFeatureCustomerBookingComponent } from '@isa/crm/feature/customer-booking';
import { ScrollTopButtonComponent } from '@isa/utils/scroll-position';
@Component({
selector: 'page-customer-kundenkarte-main-view',
templateUrl: 'kundenkarte-main-view.component.html',
styleUrls: ['kundenkarte-main-view.component.css'],
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'page-customer-kundenkarte-main-view' },
imports: [
CustomerMenuComponent,
AsyncPipe,
CustomerLoyaltyCardsComponent,
CrmFeatureCustomerCardTransactionsComponent,
CrmFeatureCustomerBookingComponent,
ScrollTopButtonComponent,
],
providers: [CustomerBonusCardsResource],
})
export class KundenkarteMainViewComponent {
private _store = inject(CustomerSearchStore);
private _activatedRoute = inject(ActivatedRoute);
private _bonusCardsResource = inject(CustomerBonusCardsResource);
elementRef = inject(ElementRef);
get hostElement() {
return this.elementRef.nativeElement;
}
customerId$ = this._activatedRoute.params.pipe(
map((params) => params.customerId),
);
processId$ = this._store.processId$;
/**
* Convert customerId observable to signal for reactive usage
*/
readonly customerId = toSignal(this.customerId$);
/**
* Get the first active card code
*/
readonly firstActiveCardCode = computed(() => {
const cards = this._bonusCardsResource.resource.value();
const firstActiveCard = cards?.find((card) => card.isActive);
return firstActiveCard?.code;
});
constructor() {
// Load bonus cards when customerId changes
effect(() => {
const customerId = this.customerId();
if (customerId) {
this._bonusCardsResource.params({ customerId: Number(customerId) });
}
});
}
}

View File

@@ -1,113 +1,151 @@
<div class="page-customer-order-item-list-item__card-content grid grid-cols-[6rem_1fr] gap-4">
<div>
<img class="rounded shadow mx-auto w-[5.9rem]" [src]="orderItem?.product?.ean | productImage" [alt]="orderItem?.product?.name" />
</div>
<div class="grid grid-flow-row gap-2">
<div class="grid grid-flow-col justify-between items-end">
<span>{{ orderItem.product?.contributors }}</span>
@if (orderDetailsHistoryRoute$ | async; as orderDetailsHistoryRoute) {
<a
[routerLink]="orderDetailsHistoryRoute.path"
[queryParams]="orderDetailsHistoryRoute.urlTree.queryParams"
[queryParamsHandling]="'merge'"
class="text-brand font-bold text-xl"
>
Historie
</a>
}
</div>
<div class="font-bold text-lg">
{{ orderItem?.product?.name }}
</div>
<div>
<span class="isa-label">
{{ processingStatus$ | async | orderItemProcessingStatus }}
</span>
</div>
@if (orderItemSubsetItem$ | async; as orderItemSubsetItem) {
<div class="grid grid-flow-row gap-2">
<div class="col-data">
<div class="col-label">Menge</div>
<div class="col-value">{{ orderItem?.quantity?.quantity }}x</div>
</div>
<div class="col-data">
<div class="col-label">Format</div>
<div class="col-value grid-flow-col grid gap-3 items-center justify-start">
<shared-icon [icon]="orderItem?.product?.format"></shared-icon>
<span>{{ orderItem?.product?.formatDetail }}</span>
</div>
</div>
<div class="col-data">
<div class="col-label">ISBN/EAN</div>
<div class="col-value">{{ orderItem?.product?.ean }}</div>
</div>
<div class="col-data">
<div class="col-label">Preis</div>
<div class="col-value">{{ orderItem?.unitPrice?.value?.value | currency: orderItem?.unitPrice?.value?.currency : 'code' }}</div>
</div>
<div class="col-data">
<div class="col-label">MwSt</div>
<div class="col-value">{{ orderItem?.unitPrice?.vat?.inPercent }}%</div>
</div>
<hr />
<div class="col-data">
<div class="col-label">Lieferant</div>
<div class="col-value">
{{ orderItemSubsetItem?.supplier?.data?.name }}
</div>
</div>
<div class="col-data">
<div class="col-label">Meldenummer</div>
<div class="col-value">{{ orderItemSubsetItem?.ssc }} - {{ orderItemSubsetItem?.sscText }}</div>
</div>
<div class="col-data">
<div class="col-label">Vsl. Lieferdatum</div>
<div class="col-value">
{{ orderItemSubsetItem?.estimatedShippingDate | date: 'dd.MM.yyyy' }}
</div>
</div>
@if (orderItemSubsetItem?.preferredPickUpDate) {
<div class="col-data">
<div class="col-label">Zurücklegen bis</div>
<div class="col-value">
{{ orderItemSubsetItem?.preferredPickUpDate | date: 'dd.MM.yyyy' }}
</div>
</div>
}
<hr />
@if (orderItemSubsetItem?.compartmentCode) {
<div class="col-data">
<div class="col-label">Abholfachnummer</div>
<div class="col-value">
<span>{{ orderItemSubsetItem?.compartmentCode }}</span>
@if (orderItemSubsetItem?.compartmentInfo) {
<span>_{{ orderItemSubsetItem?.compartmentInfo }}</span>
}
</div>
</div>
}
<div class="col-data">
<div class="col-label">Vormerker</div>
<div class="col-value">{{ isPrebooked$ | async }}</div>
</div>
<hr />
<div class="col-data">
<div class="col-label">Zahlungsweg</div>
<div class="col-value">-</div>
</div>
<div class="col-data">
<div class="col-label">Zahlungsart</div>
<div class="col-value">
{{ orderPaymentType$ | async | paymentType }}
</div>
</div>
<div class="col-data">
<div class="col-label">Anmerkung</div>
<div class="col-value">
{{ orderItemSubsetItem?.specialComment || '-' }}
</div>
</div>
</div>
}
</div>
</div>
<div
class="page-customer-order-item-list-item__card-content grid grid-cols-[6rem_1fr] gap-4"
>
<div class="flex flex-col gap-2 justify-start items-center">
@let ean = orderItem?.product?.ean;
@let name = orderItem?.product?.name;
@if (ean && name) {
<img
class="rounded shadow mx-auto w-[5.9rem]"
[src]="ean | productImage"
[alt]="name"
/>
}
@if (hasRewardPoints$ | async) {
<ui-label [type]="Labeltype.Tag" [priority]="LabelPriority.High">
Prämie
</ui-label>
}
</div>
<div class="grid grid-flow-row gap-2">
<div class="grid grid-flow-col justify-between items-end">
<span>{{ orderItem.product?.contributors }}</span>
@if (orderDetailsHistoryRoute$ | async; as orderDetailsHistoryRoute) {
<a
[routerLink]="orderDetailsHistoryRoute.path"
[queryParams]="orderDetailsHistoryRoute.urlTree.queryParams"
[queryParamsHandling]="'merge'"
class="text-brand font-bold text-xl"
>
Historie
</a>
}
</div>
<div class="font-bold text-lg">
{{ orderItem?.product?.name }}
</div>
<div>
<span class="isa-label">
{{ processingStatus$ | async | orderItemProcessingStatus }}
</span>
</div>
@if (orderItemSubsetItem$ | async; as orderItemSubsetItem) {
<div class="grid grid-flow-row gap-2">
<div class="col-data">
<div class="col-label">Menge</div>
<div class="col-value">{{ orderItem?.quantity?.quantity }}x</div>
</div>
<div class="col-data">
<div class="col-label">Format</div>
<div
class="col-value grid-flow-col grid gap-3 items-center justify-start"
>
@let format = orderItem?.product?.format;
@if (format) {
<shared-icon [icon]="orderItem?.product?.format"></shared-icon>
}
<span>{{ orderItem?.product?.formatDetail }}</span>
</div>
</div>
<div class="col-data">
<div class="col-label">ISBN/EAN</div>
<div class="col-value">{{ orderItem?.product?.ean }}</div>
</div>
<div class="col-data">
@if (hasRewardPoints$ | async) {
<div class="col-label">Prämie</div>
<div class="col-value">{{ rewardPoints$ | async | number: '1.0-0' }} Lesepunkte</div>
} @else {
<div class="col-label">Preis</div>
<div class="col-value">
{{
orderItem?.unitPrice?.value?.value
| currency: orderItem?.unitPrice?.value?.currency : 'code'
}}
</div>
}
</div>
<div class="col-data">
<div class="col-label">MwSt</div>
<div class="col-value">
{{ orderItem?.unitPrice?.vat?.inPercent }}%
</div>
</div>
<hr />
<div class="col-data">
<div class="col-label">Lieferant</div>
<div class="col-value">
{{ orderItemSubsetItem?.supplier?.data?.name }}
</div>
</div>
<div class="col-data">
<div class="col-label">Meldenummer</div>
<div class="col-value">
{{ orderItemSubsetItem?.ssc }} - {{ orderItemSubsetItem?.sscText }}
</div>
</div>
<div class="col-data">
<div class="col-label">Vsl. Lieferdatum</div>
<div class="col-value">
{{
orderItemSubsetItem?.estimatedShippingDate | date: 'dd.MM.yyyy'
}}
</div>
</div>
@if (orderItemSubsetItem?.preferredPickUpDate) {
<div class="col-data">
<div class="col-label">Zurücklegen bis</div>
<div class="col-value">
{{
orderItemSubsetItem?.preferredPickUpDate | date: 'dd.MM.yyyy'
}}
</div>
</div>
}
<hr />
@if (orderItemSubsetItem?.compartmentCode) {
<div class="col-data">
<div class="col-label">Abholfachnummer</div>
<div class="col-value">
<span>{{ orderItemSubsetItem?.compartmentCode }}</span>
@if (orderItemSubsetItem?.compartmentInfo) {
<span>_{{ orderItemSubsetItem?.compartmentInfo }}</span>
}
</div>
</div>
}
<div class="col-data">
<div class="col-label">Vormerker</div>
<div class="col-value">{{ isPrebooked$ | async }}</div>
</div>
<hr />
<div class="col-data">
<div class="col-label">Zahlungsweg</div>
<div class="col-value">-</div>
</div>
<div class="col-data">
<div class="col-label">Zahlungsart</div>
<div class="col-value">
{{ orderPaymentType$ | async | paymentType }}
</div>
</div>
<div class="col-data">
<div class="col-label">Anmerkung</div>
<div class="col-value">
{{ orderItemSubsetItem?.specialComment || '-' }}
</div>
</div>
</div>
}
</div>
</div>

View File

@@ -1,88 +1,136 @@
import { AsyncPipe, CurrencyPipe, DatePipe } from '@angular/common';
import { Component, ChangeDetectionStrategy, Input, OnDestroy, OnInit, inject } from '@angular/core';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { ProductImagePipe } from '@cdn/product-image';
import { OrderItemProcessingStatusPipe } from '@shared/pipes/order';
import { OrderItemDTO } from '@generated/swagger/oms-api';
import { BehaviorSubject, Subject, combineLatest } from 'rxjs';
import { map, takeUntil } from 'rxjs/operators';
import { CustomerSearchStore } from '../../store';
import { CustomerSearchNavigation } from '@shared/services/navigation';
import { PaymentTypePipe } from '@shared/pipes/customer';
@Component({
selector: 'page-customer-order-item-list-item',
templateUrl: 'order-item-list-item.component.html',
styleUrls: ['order-item-list-item.component.css'],
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'page-customer-order-item-list-item' },
imports: [
AsyncPipe,
DatePipe,
ProductImagePipe,
CurrencyPipe,
RouterLink,
PaymentTypePipe,
OrderItemProcessingStatusPipe
],
})
export class CustomerOrderItemListItemComponent implements OnInit, OnDestroy {
private _activatedRoute = inject(ActivatedRoute);
private _store = inject(CustomerSearchStore);
private _navigation = inject(CustomerSearchNavigation);
private _onDestroy = new Subject<void>();
private _orderItemSub = new BehaviorSubject<OrderItemDTO>(undefined);
@Input()
get orderItem() {
return this._orderItemSub.getValue();
}
set orderItem(value: OrderItemDTO) {
this._orderItemSub.next(value);
}
orderId$ = this._activatedRoute.params.pipe(map((params) => Number(params.orderId)));
order$ = this._store.order$;
orderPaymentType$ = this.order$.pipe(map((order) => order?.paymentType));
customerId$ = this._activatedRoute.params.pipe(map((params) => Number(params.customerId)));
orderItemOrderType$ = this._orderItemSub.pipe(map((orderItem) => orderItem?.features?.orderType));
orderItemSubsetItem$ = this._orderItemSub.pipe(map((orderItem) => orderItem?.subsetItems?.[0]?.data));
orderDetailsHistoryRoute$ = combineLatest([
this.customerId$,
this._store.processId$,
this.orderId$,
this._orderItemSub,
]).pipe(
map(([customerId, processId, orderId, orderItem]) =>
this._navigation.orderDetailsHistoryRoute({ processId, customerId, orderId, orderItemId: orderItem?.id }),
),
);
isPrebooked$ = this.orderItemSubsetItem$.pipe(map((subsetItem) => (subsetItem?.isPrebooked ? 'Ja' : 'Nein')));
processingStatus$ = this.orderItemSubsetItem$.pipe(map((subsetItem) => subsetItem?.processingStatus));
ngOnInit() {
this.customerId$.pipe(takeUntil(this._onDestroy)).subscribe((customerId) => {
this._store.selectCustomer({ customerId });
});
this.orderId$.pipe(takeUntil(this._onDestroy)).subscribe((orderId) => {
this._store.selectOrder(+orderId);
});
}
ngOnDestroy() {
this._onDestroy.next();
this._onDestroy.complete();
this._orderItemSub.complete();
}
}
import {
AsyncPipe,
CurrencyPipe,
DatePipe,
DecimalPipe,
} from '@angular/common';
import {
Component,
ChangeDetectionStrategy,
Input,
OnDestroy,
OnInit,
inject,
} from '@angular/core';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { ProductImagePipe } from '@cdn/product-image';
import { OrderItemProcessingStatusPipe } from '@shared/pipes/order';
import { OrderItemDTO } from '@generated/swagger/oms-api';
import { BehaviorSubject, Subject, combineLatest } from 'rxjs';
import { map, takeUntil } from 'rxjs/operators';
import { CustomerSearchStore } from '../../store';
import { CustomerSearchNavigation } from '@shared/services/navigation';
import { PaymentTypePipe } from '@shared/pipes/customer';
import { LabelComponent, Labeltype, LabelPriority } from '@isa/ui/label';
import { getOrderItemRewardFeature } from '@isa/oms/data-access';
import { IconComponent } from '@shared/components/icon';
@Component({
selector: 'page-customer-order-item-list-item',
templateUrl: 'order-item-list-item.component.html',
styleUrls: ['order-item-list-item.component.css'],
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'page-customer-order-item-list-item' },
imports: [
AsyncPipe,
DatePipe,
ProductImagePipe,
CurrencyPipe,
RouterLink,
PaymentTypePipe,
OrderItemProcessingStatusPipe,
LabelComponent,
IconComponent,
DecimalPipe,
],
})
export class CustomerOrderItemListItemComponent implements OnInit, OnDestroy {
private _activatedRoute = inject(ActivatedRoute);
private _store = inject(CustomerSearchStore);
private _navigation = inject(CustomerSearchNavigation);
private _onDestroy = new Subject<void>();
private _orderItemSub = new BehaviorSubject<OrderItemDTO>(undefined);
@Input()
get orderItem() {
return this._orderItemSub.getValue();
}
set orderItem(value: OrderItemDTO) {
this._orderItemSub.next(value);
}
orderId$ = this._activatedRoute.params.pipe(
map((params) => Number(params.orderId)),
);
order$ = this._store.order$;
orderPaymentType$ = this.order$.pipe(map((order) => order?.paymentType));
customerId$ = this._activatedRoute.params.pipe(
map((params) => Number(params.customerId)),
);
orderItemOrderType$ = this._orderItemSub.pipe(
map((orderItem) => orderItem?.features?.orderType),
);
orderItemSubsetItem$ = this._orderItemSub.pipe(
map((orderItem) => orderItem?.subsetItems?.[0]?.data),
);
orderDetailsHistoryRoute$ = combineLatest([
this.customerId$,
this._store.processId$,
this.orderId$,
this._orderItemSub,
]).pipe(
map(([customerId, processId, orderId, orderItem]) =>
this._navigation.orderDetailsHistoryRoute({
processId,
customerId,
orderId,
orderItemId: orderItem?.id,
}),
),
);
isPrebooked$ = this.orderItemSubsetItem$.pipe(
map((subsetItem) => (subsetItem?.isPrebooked ? 'Ja' : 'Nein')),
);
processingStatus$ = this.orderItemSubsetItem$.pipe(
map((subsetItem) => subsetItem?.processingStatus),
);
hasRewardPoints$ = this._orderItemSub.pipe(
map((orderItem) => getOrderItemRewardFeature(orderItem) !== undefined),
);
rewardPoints$ = this._orderItemSub.pipe(
map((orderItem) => getOrderItemRewardFeature(orderItem)),
);
Labeltype = Labeltype;
LabelPriority = LabelPriority;
ngOnInit() {
this.customerId$
.pipe(takeUntil(this._onDestroy))
.subscribe((customerId) => {
this._store.selectCustomer({ customerId });
});
this.orderId$.pipe(takeUntil(this._onDestroy)).subscribe((orderId) => {
this._store.selectOrder(+orderId);
});
}
ngOnDestroy() {
this._onDestroy.next();
this._onDestroy.complete();
this._orderItemSub.complete();
}
}

View File

@@ -40,6 +40,11 @@
[src]="orderItem.product?.ean | productImage"
[alt]="orderItem.product?.name"
/>
@if (hasRewardPoints$ | async) {
<ui-label [type]="Labeltype.Tag" [priority]="LabelPriority.High">
Prämie
</ui-label>
}
</div>
<div class="page-pickup-shelf-details-item__details">
<div class="flex flex-row justify-between items-start mb-[1.3125rem]">
@@ -117,10 +122,15 @@
<div class="value">{{ orderItem.product?.ean }}</div>
</div>
}
@if (orderItem.price !== undefined) {
@if (orderItem.price !== undefined || (hasRewardPoints$ | async)) {
<div class="detail">
<div class="label">Preis</div>
<div class="value">{{ orderItem.price | currency: 'EUR' }}</div>
@if (hasRewardPoints$ | async) {
<div class="label">Prämie</div>
<div class="value">{{ rewardPoints$ | async | number: '1.0-0' }} Lesepunkte</div>
} @else {
<div class="label">Preis</div>
<div class="value">{{ orderItem.price | currency: 'EUR' }}</div>
}
</div>
}
@if (!!orderItem.retailPrice?.vat?.inPercent) {

View File

@@ -21,6 +21,8 @@ button {
}
.page-pickup-shelf-details-item__thumbnail {
@apply flex flex-col items-center gap-2;
img {
@apply rounded shadow-cta w-[3.625rem] max-h-[5.9375rem];
}

View File

@@ -1,20 +1,22 @@
import { CdkTextareaAutosize, TextFieldModule } from '@angular/cdk/text-field';
import { AsyncPipe, CurrencyPipe, DatePipe } from '@angular/common';
import { AsyncPipe, CurrencyPipe, DatePipe, DecimalPipe } from '@angular/common';
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
EventEmitter,
Input,
OnInit,
Output,
ViewChild,
inject, OnDestroy,
inject,
OnDestroy,
} from '@angular/core';
import { FormsModule, ReactiveFormsModule, UntypedFormControl } from '@angular/forms';
import { NavigateOnClickDirective, ProductImageModule } from '@cdn/product-image';
import { DBHOrderItemListItemDTO, OrderDTO, ReceiptDTO } from '@generated/swagger/oms-api';
import { getOrderItemRewardFeature } from '@isa/oms/data-access';
import { UiCommonModule } from '@ui/common';
import { LabelComponent, Labeltype, LabelPriority } from '@isa/ui/label';
import { UiTooltipModule } from '@ui/tooltip';
import { PickupShelfPaymentTypePipe } from '../pipes/payment-type.pipe';
import { IconModule } from '@shared/components/icon';
@@ -48,6 +50,7 @@ export interface PickUpShelfDetailsItemComponentState {
ReactiveFormsModule,
CurrencyPipe,
DatePipe,
DecimalPipe,
AsyncPipe,
ProductImageModule,
TextFieldModule,
@@ -56,12 +59,13 @@ export interface PickUpShelfDetailsItemComponentState {
UiQuantityDropdownModule,
NotificationTypePipe,
NavigateOnClickDirective,
MatomoModule
MatomoModule,
LabelComponent
],
})
export class PickUpShelfDetailsItemComponent
extends ComponentStore<PickUpShelfDetailsItemComponentState>
implements OnInit, OnDestroy
implements OnDestroy
{
private _store = inject(PickupShelfDetailsStore);
@@ -117,6 +121,22 @@ export class PickUpShelfDetailsItemComponent
hasSmsNotification$ = this.smsNotificationDates$.pipe(map((dates) => dates?.length > 0));
/**
* Observable that indicates whether the order item has reward points (Lesepunkte).
* Returns true if the item has a 'praemie' feature.
*/
hasRewardPoints$ = this.orderItem$.pipe(
map((orderItem) => getOrderItemRewardFeature(orderItem) !== undefined),
);
/**
* Observable that emits the reward points (Lesepunkte) value for the order item.
* Returns the parsed numeric value from the 'praemie' feature, or undefined if not present.
*/
rewardPoints$ = this.orderItem$.pipe(
map((orderItem) => getOrderItemRewardFeature(orderItem)),
);
canChangeQuantity$ = combineLatest([this.orderItem$, this._store.fetchPartial$]).pipe(
map(([item, partialPickup]) => ([16, 8192].includes(item?.processingStatus) || partialPickup) && item.quantity > 1),
);
@@ -167,12 +187,12 @@ export class PickUpShelfDetailsItemComponent
return this._store.receipts;
}
readonly receipts$ = this._store.receipts$;
set receipts(receipts: ReceiptDTO[]) {
this._store.updateReceipts(receipts);
}
readonly receipts$ = this._store.receipts$;
readonly receiptCount$ = this.receipts$.pipe(map((receipts) => receipts?.length));
specialCommentControl = new UntypedFormControl();
@@ -181,7 +201,11 @@ export class PickUpShelfDetailsItemComponent
private _onDestroy$ = new Subject<void>();
expanded: boolean = false;
expanded = false;
// Expose to template
Labeltype = Labeltype;
LabelPriority = LabelPriority;
constructor(private _cdr: ChangeDetectorRef) {
super({
@@ -189,8 +213,6 @@ export class PickUpShelfDetailsItemComponent
});
}
ngOnInit() {}
ngOnDestroy() {
// Remove Prev OrderItem from selected list
this._store.selectOrderItem(this.orderItem, false);

View File

@@ -39,6 +39,7 @@
.page-pickup-shelf-list-item__item-thumbnail {
grid-area: thumbnail;
@apply flex flex-col items-center gap-2;
}
.page-pickup-shelf-list-item__item-image {

View File

@@ -10,7 +10,7 @@
[class.page-pickup-shelf-list-item__item-grid-container-main]="primaryOutletActive"
[class.page-pickup-shelf-list-item__item-grid-container-secondary]="primaryOutletActive && isItemSelectable === undefined"
>
<div class="page-pickup-shelf-list-item__item-thumbnail text-center w-[3.125rem] h-[4.9375rem]">
<div class="page-pickup-shelf-list-item__item-thumbnail text-center">
@if (item?.product?.ean | productImage; as productImage) {
<img
class="page-pickup-shelf-list-item__item-image w-[3.125rem] max-h-[4.9375rem]"
@@ -20,6 +20,11 @@
[alt]="item?.product?.name"
/>
}
@if (hasRewardPoints) {
<ui-label [type]="Labeltype.Tag" [priority]="LabelPriority.High">
Prämie
</ui-label>
}
</div>
<div

View File

@@ -5,6 +5,8 @@ import { NavigateOnClickDirective, ProductImageModule } from '@cdn/product-image
import { EnvironmentService } from '@core/environment';
import { IconModule } from '@shared/components/icon';
import { DBHOrderItemListItemDTO } from '@generated/swagger/oms-api';
import { getOrderItemRewardFeature } from '@isa/oms/data-access';
import { LabelComponent, Labeltype, LabelPriority } from '@isa/ui/label';
import { UiCommonModule } from '@ui/common';
import { PickupShelfProcessingStatusPipe } from '../pipes/processing-status.pipe';
import { FormsModule } from '@angular/forms';
@@ -29,7 +31,8 @@ import { MatomoModule } from 'ngx-matomo-client';
UiCommonModule,
PickupShelfProcessingStatusPipe,
NavigateOnClickDirective,
MatomoModule
MatomoModule,
LabelComponent
],
providers: [PickupShelfProcessingStatusPipe],
})
@@ -77,12 +80,24 @@ export class PickUpShelfListItemComponent {
return { 'background-color': this._processingStatusPipe.transform(this.item?.processingStatus, true) };
}
/**
* Indicates whether the order item has reward points (Lesepunkte).
* Returns true if the item has a 'praemie' feature.
*/
get hasRewardPoints() {
return getOrderItemRewardFeature(this.item) !== undefined;
}
selected$ = this.store.selectedListItems$.pipe(
map((selectedListItems) =>
selectedListItems?.find((item) => item?.orderItemSubsetId === this.item?.orderItemSubsetId),
),
);
// Expose to template
Labeltype = Labeltype;
LabelPriority = LabelPriority;
constructor(
private _elRef: ElementRef,
private _environment: EnvironmentService,

View File

@@ -6,7 +6,7 @@ import { NavigationRoute } from './defs/navigation-route';
import {
encodeFormData,
mapCustomerInfoDtoToCustomerCreateFormData,
} from 'apps/isa-app/src/page/customer';
} from '@page/customer';
@Injectable({ providedIn: 'root' })
export class CustomerCreateNavigation {
@@ -58,7 +58,7 @@ export class CustomerCreateNavigation {
},
];
let formData = params?.customerInfo
const formData = params?.customerInfo
? encodeFormData(
mapCustomerInfoDtoToCustomerCreateFormData(params.customerInfo),
)

View File

@@ -12,7 +12,7 @@ variables:
value: '4'
# Minor Version einstellen
- name: 'Minor'
value: '2'
value: '4'
- name: 'Patch'
value: "$[counter(format('{0}.{1}', variables['Major'], variables['Minor']),0)]"
- name: 'BuildUniqueID'

View File

@@ -1,11 +1,11 @@
# Library Reference Guide
> **Last Updated:** 2025-10-27
> **Last Updated:** 2025-01-10
> **Angular Version:** 20.1.2
> **Nx Version:** 21.3.2
> **Total Libraries:** 62
> **Total Libraries:** 63
All 62 libraries in the monorepo have comprehensive README.md documentation located at `libs/[domain]/[layer]/[feature]/README.md`.
All 63 libraries in the monorepo have comprehensive README.md documentation located at `libs/[domain]/[layer]/[feature]/README.md`.
**IMPORTANT: Always use the `docs-researcher` subagent** to retrieve and analyze library documentation. This keeps the main context clean and prevents pollution.
@@ -82,7 +82,14 @@ A comprehensive print management library for Angular applications providing prin
---
## Core Libraries (5 libraries)
## Core Libraries (6 libraries)
### `@isa/core/auth`
Type-safe role-based authorization utilities with Angular signals integration for the ISA Frontend application. Provides Role enum, RoleService for programmatic checks, and IfRoleDirective for declarative template rendering with automatic JWT token parsing via OAuthService.
**Location:** `libs/core/auth/`
**Testing:** Vitest (18 passing tests)
**Features:** Signal-based reactivity, type-safe Role enum, zero-configuration OAuth2 integration
### `@isa/core/config`
A lightweight, type-safe configuration management system for Angular applications with runtime validation and nested object access.

View File

@@ -76,6 +76,15 @@ export { EntityDTOBaseOfCustomerInfoDTOAndICustomer } from './models/entity-dtob
export { QueryTokenDTO } from './models/query-token-dto';
export { QueryTokenDTO2 } from './models/query-token-dto2';
export { ResponseArgsOfCustomerDTO } from './models/response-args-of-customer-dto';
export { ResponseArgsOfAccountDetailsDTO } from './models/response-args-of-account-details-dto';
export { AccountDetailsDTO } from './models/account-details-dto';
export { AccountBalanceDTO } from './models/account-balance-dto';
export { IdentifierDTO } from './models/identifier-dto';
export { StateLevelDTO } from './models/state-level-dto';
export { MembershipDetailsDTO } from './models/membership-details-dto';
export { CustomPropertyDTO } from './models/custom-property-dto';
export { OptinDTO } from './models/optin-dto';
export { AddLoyaltyCardValues } from './models/add-loyalty-card-values';
export { SaveCustomerValues } from './models/save-customer-values';
export { ResponseArgsOfAssignedPayerDTO } from './models/response-args-of-assigned-payer-dto';
export { ResponseArgsOfBoolean } from './models/response-args-of-boolean';
@@ -92,7 +101,8 @@ export { DiffDTO } from './models/diff-dto';
export { ResponseArgsOfIQueryResultOfLoyaltyBookingInfoDTO } from './models/response-args-of-iquery-result-of-loyalty-booking-info-dto';
export { IQueryResultOfLoyaltyBookingInfoDTO } from './models/iquery-result-of-loyalty-booking-info-dto';
export { LoyaltyBookingInfoDTO } from './models/loyalty-booking-info-dto';
export { ResponseArgsOfIEnumerableOfString } from './models/response-args-of-ienumerable-of-string';
export { ResponseArgsOfIEnumerableOfKeyValueDTOOfStringAndInteger } from './models/response-args-of-ienumerable-of-key-value-dtoof-string-and-integer';
export { KeyValueDTOOfStringAndInteger } from './models/key-value-dtoof-string-and-integer';
export { ResponseArgsOfKeyValueDTOOfStringAndString } from './models/response-args-of-key-value-dtoof-string-and-string';
export { ResponseArgsOfLoyaltyBookingInfoDTO } from './models/response-args-of-loyalty-booking-info-dto';
export { LoyaltyBookingValues } from './models/loyalty-booking-values';

View File

@@ -0,0 +1,5 @@
/* tslint:disable */
export interface AccountBalanceDTO {
lockedPoints: number;
points: number;
}

View File

@@ -0,0 +1,14 @@
/* tslint:disable */
import { AccountBalanceDTO } from './account-balance-dto';
import { IdentifierDTO } from './identifier-dto';
import { StateLevelDTO } from './state-level-dto';
import { MembershipDetailsDTO } from './membership-details-dto';
export interface AccountDetailsDTO {
accountBalance?: AccountBalanceDTO;
accountId?: string;
createdAt?: string;
identifiers?: Array<IdentifierDTO>;
level?: StateLevelDTO;
memberships?: Array<MembershipDetailsDTO>;
status?: string;
}

View File

@@ -0,0 +1,8 @@
/* tslint:disable */
export interface AddLoyaltyCardValues {
/**
* Card code
*/
cardCode?: string;
}

View File

@@ -0,0 +1,5 @@
/* tslint:disable */
export interface CustomPropertyDTO {
name?: string;
value?: string;
}

View File

@@ -0,0 +1,8 @@
/* tslint:disable */
export interface IdentifierDTO {
code?: string;
displayCode?: string;
identifierId?: string;
status?: string;
type?: string;
}

View File

@@ -0,0 +1,12 @@
/* tslint:disable */
export interface KeyValueDTOOfStringAndInteger {
command?: string;
description?: string;
enabled?: boolean;
group?: string;
key?: string;
label?: string;
selected?: boolean;
sort?: number;
value: number;
}

View File

@@ -0,0 +1,19 @@
/* tslint:disable */
import { CustomPropertyDTO } from './custom-property-dto';
import { OptinDTO } from './optin-dto';
export interface MembershipDetailsDTO {
birthDate?: string;
city?: string;
countryCode?: string;
customProperties?: Array<CustomPropertyDTO>;
emailAddress?: string;
familyName?: string;
genderCode?: string;
givenName?: string;
memberRole?: string;
membershipId?: string;
optins?: Array<OptinDTO>;
streetHouseNo?: string;
userId?: string;
zipCode?: string;
}

View File

@@ -0,0 +1,5 @@
/* tslint:disable */
export interface OptinDTO {
flag: boolean;
type?: string;
}

View File

@@ -0,0 +1,6 @@
/* tslint:disable */
import { ResponseArgs } from './response-args';
import { AccountDetailsDTO } from './account-details-dto';
export interface ResponseArgsOfAccountDetailsDTO extends ResponseArgs{
result?: AccountDetailsDTO;
}

View File

@@ -0,0 +1,6 @@
/* tslint:disable */
import { ResponseArgs } from './response-args';
import { KeyValueDTOOfStringAndInteger } from './key-value-dtoof-string-and-integer';
export interface ResponseArgsOfIEnumerableOfKeyValueDTOOfStringAndInteger extends ResponseArgs{
result?: Array<KeyValueDTOOfStringAndInteger>;
}

View File

@@ -1,5 +0,0 @@
/* tslint:disable */
import { ResponseArgs } from './response-args';
export interface ResponseArgsOfIEnumerableOfString extends ResponseArgs{
result?: Array<string>;
}

View File

@@ -0,0 +1,10 @@
/* tslint:disable */
export interface StateLevelDTO {
currentStatePoints?: number;
name?: string;
neededStatePoints?: number;
neededStatePointsNextLevel?: number;
requiredPointsToMaintainLevel?: number;
requiredPointsToReachNextLevel?: number;
validTo?: string;
}

View File

@@ -17,6 +17,8 @@ import { ResponseArgsOfCustomerDTO } from '../models/response-args-of-customer-d
import { SaveCustomerValues } from '../models/save-customer-values';
import { CustomerDTO } from '../models/customer-dto';
import { ResponseArgsOfBoolean } from '../models/response-args-of-boolean';
import { ResponseArgsOfAccountDetailsDTO } from '../models/response-args-of-account-details-dto';
import { AddLoyaltyCardValues } from '../models/add-loyalty-card-values';
import { ResponseArgsOfAssignedPayerDTO } from '../models/response-args-of-assigned-payer-dto';
import { ResponseArgsOfIEnumerableOfCustomerInfoDTO } from '../models/response-args-of-ienumerable-of-customer-info-dto';
import { ResponseArgsOfIEnumerableOfBonusCardInfoDTO } from '../models/response-args-of-ienumerable-of-bonus-card-info-dto';
@@ -35,6 +37,7 @@ class CustomerService extends __BaseService {
static readonly CustomerUpdateCustomerPath = '/customer/{customerId}';
static readonly CustomerPatchCustomerPath = '/customer/{customerId}';
static readonly CustomerDeleteCustomerPath = '/customer/{customerId}';
static readonly CustomerAddLoyaltyCardPath = '/customer/{customerId}/loyalty/add-card';
static readonly CustomerCreateCustomerPath = '/customer';
static readonly CustomerAddPayerReferencePath = '/customer/{customerId}/payer';
static readonly CustomerDeactivateCustomerPath = '/customer/{customerId}/deactivate';
@@ -389,6 +392,56 @@ class CustomerService extends __BaseService {
);
}
/**
* Kundenkarte hinzufügen
* @param params The `CustomerService.CustomerAddLoyaltyCardParams` containing the following parameters:
*
* - `loyaltyCardValues`:
*
* - `customerId`:
*
* - `locale`:
*/
CustomerAddLoyaltyCardResponse(params: CustomerService.CustomerAddLoyaltyCardParams): __Observable<__StrictHttpResponse<ResponseArgsOfAccountDetailsDTO>> {
let __params = this.newParams();
let __headers = new HttpHeaders();
let __body: any = null;
__body = params.loyaltyCardValues;
if (params.locale != null) __params = __params.set('locale', params.locale.toString());
let req = new HttpRequest<any>(
'POST',
this.rootUrl + `/customer/${encodeURIComponent(String(params.customerId))}/loyalty/add-card`,
__body,
{
headers: __headers,
params: __params,
responseType: 'json'
});
return this.http.request<any>(req).pipe(
__filter(_r => _r instanceof HttpResponse),
__map((_r) => {
return _r as __StrictHttpResponse<ResponseArgsOfAccountDetailsDTO>;
})
);
}
/**
* Kundenkarte hinzufügen
* @param params The `CustomerService.CustomerAddLoyaltyCardParams` containing the following parameters:
*
* - `loyaltyCardValues`:
*
* - `customerId`:
*
* - `locale`:
*/
CustomerAddLoyaltyCard(params: CustomerService.CustomerAddLoyaltyCardParams): __Observable<ResponseArgsOfAccountDetailsDTO> {
return this.CustomerAddLoyaltyCardResponse(params).pipe(
__map(_r => _r.body as ResponseArgsOfAccountDetailsDTO)
);
}
/**
* Anlage eines neuen Kunden
* @param payload Kundendaten
@@ -861,6 +914,15 @@ module CustomerService {
deletionComment?: null | string;
}
/**
* Parameters for CustomerAddLoyaltyCard
*/
export interface CustomerAddLoyaltyCardParams {
loyaltyCardValues: AddLoyaltyCardValues;
customerId: number;
locale?: null | string;
}
/**
* Parameters for CustomerAddPayerReference
*/

View File

@@ -10,7 +10,7 @@ import { map as __map, filter as __filter } from 'rxjs/operators';
import { ResponseArgsOfIQueryResultOfLoyaltyBookingInfoDTO } from '../models/response-args-of-iquery-result-of-loyalty-booking-info-dto';
import { ResponseArgsOfLoyaltyBookingInfoDTO } from '../models/response-args-of-loyalty-booking-info-dto';
import { LoyaltyBookingValues } from '../models/loyalty-booking-values';
import { ResponseArgsOfIEnumerableOfString } from '../models/response-args-of-ienumerable-of-string';
import { ResponseArgsOfIEnumerableOfKeyValueDTOOfStringAndInteger } from '../models/response-args-of-ienumerable-of-key-value-dtoof-string-and-integer';
import { ResponseArgsOfKeyValueDTOOfStringAndString } from '../models/response-args-of-key-value-dtoof-string-and-string';
import { ResponseArgsOfBoolean } from '../models/response-args-of-boolean';
import { LoyaltyBonValues } from '../models/loyalty-bon-values';
@@ -133,7 +133,7 @@ class LoyaltyCardService extends __BaseService {
/**
* Booking reason / Buchungsgründe
*/
LoyaltyCardBookingReasonResponse(): __Observable<__StrictHttpResponse<ResponseArgsOfIEnumerableOfString>> {
LoyaltyCardBookingReasonResponse(): __Observable<__StrictHttpResponse<ResponseArgsOfIEnumerableOfKeyValueDTOOfStringAndInteger>> {
let __params = this.newParams();
let __headers = new HttpHeaders();
let __body: any = null;
@@ -150,16 +150,16 @@ class LoyaltyCardService extends __BaseService {
return this.http.request<any>(req).pipe(
__filter(_r => _r instanceof HttpResponse),
__map((_r) => {
return _r as __StrictHttpResponse<ResponseArgsOfIEnumerableOfString>;
return _r as __StrictHttpResponse<ResponseArgsOfIEnumerableOfKeyValueDTOOfStringAndInteger>;
})
);
}
/**
* Booking reason / Buchungsgründe
*/
LoyaltyCardBookingReason(): __Observable<ResponseArgsOfIEnumerableOfString> {
LoyaltyCardBookingReason(): __Observable<ResponseArgsOfIEnumerableOfKeyValueDTOOfStringAndInteger> {
return this.LoyaltyCardBookingReasonResponse().pipe(
__map(_r => _r.body as ResponseArgsOfIEnumerableOfString)
__map(_r => _r.body as ResponseArgsOfIEnumerableOfKeyValueDTOOfStringAndInteger)
);
}

View File

@@ -0,0 +1,122 @@
import { describe, it, expect } from 'vitest';
import { hasLoyaltyCollectCommand } from './has-loyalty-collect-command.helper';
import { DisplayOrderItemSubset } from '@isa/oms/data-access';
describe('hasLoyaltyCollectCommand', () => {
describe('when items is undefined', () => {
it('should return false', () => {
// Act
const result = hasLoyaltyCollectCommand(undefined);
// Assert
expect(result).toBe(false);
});
});
describe('when items is empty array', () => {
it('should return false', () => {
// Arrange
const items: DisplayOrderItemSubset[] = [];
// Act
const result = hasLoyaltyCollectCommand(items);
// Assert
expect(result).toBe(false);
});
});
describe('when items have no actions', () => {
it('should return false', () => {
// Arrange
const items: DisplayOrderItemSubset[] = [
{
id: 1,
quantity: 1,
} as DisplayOrderItemSubset,
];
// Act
const result = hasLoyaltyCollectCommand(items);
// Assert
expect(result).toBe(false);
});
});
describe('when items have actions but no LOYALTY_COLLECT_COMMAND', () => {
it('should return false', () => {
// Arrange
const items: any[] = [
{
id: 1,
quantity: 1,
actions: [
{ command: 'SOME_OTHER_COMMAND', label: 'Other Action' },
{ command: 'ANOTHER_COMMAND', label: 'Another Action' },
],
},
];
// Act
const result = hasLoyaltyCollectCommand(items);
// Assert
expect(result).toBe(false);
});
});
describe('when items have LOYALTY_COLLECT_COMMAND action', () => {
it('should return true', () => {
// Arrange
const items: any[] = [
{
id: 1,
quantity: 1,
actions: [
{
command: 'LOYALTY_COLLECT_COMMAND',
label: 'Abschließen',
selected: true,
value: 'Abschließen',
},
],
},
];
// Act
const result = hasLoyaltyCollectCommand(items);
// Assert
expect(result).toBe(true);
});
});
describe('when items have multiple actions including LOYALTY_COLLECT_COMMAND', () => {
it('should return true', () => {
// Arrange
const items: any[] = [
{
id: 1,
quantity: 1,
actions: [
{ command: 'SOME_OTHER_COMMAND', label: 'Other Action' },
{
command: 'LOYALTY_COLLECT_COMMAND',
label: 'Abschließen',
selected: true,
value: 'Abschließen',
},
{ command: 'ANOTHER_COMMAND', label: 'Another Action' },
],
},
];
// Act
const result = hasLoyaltyCollectCommand(items);
// Assert
expect(result).toBe(true);
});
});
});

View File

@@ -0,0 +1,17 @@
import { DisplayOrderItemSubset } from '@isa/oms/data-access';
/**
* Checks if any of the subset items has a LOYALTY_COLLECT_COMMAND action
* @param items - Array of DisplayOrderItemSubset to check
* @returns true if at least one item has a LOYALTY_COLLECT_COMMAND action
*/
export const hasLoyaltyCollectCommand = (
items?: DisplayOrderItemSubset[],
): boolean => {
const firstItem = items?.find((_) => true);
return (
firstItem?.actions?.some((action) =>
action?.command?.includes('LOYALTY_COLLECT_COMMAND'),
) ?? false
);
};

View File

@@ -1,5 +1,6 @@
export * from './get-order-type-feature.helper';
export * from './has-order-type-feature.helper';
export * from './has-loyalty-collect-command.helper';
export * from './checkout-analysis.helpers';
export * from './checkout-business-logic.helpers';
export * from './checkout-data.helpers';

View File

@@ -1,4 +1,10 @@
import { ChangeDetectionStrategy, Component, computed, inject, input } from '@angular/core';
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
input,
} from '@angular/core';
import { RouterLink } from '@angular/router';
import { DBHOrderItemListItemDTO } from '@generated/swagger/oms-api';
import { TabService } from '@isa/core/tabs';
@@ -11,6 +17,12 @@ import { isaActionChevronRight } from '@isa/icons';
*
* Shows customer name and a chevron button for navigation to the order completion page.
*/
export type OpenTaskCardInput = Pick<
DBHOrderItemListItemDTO,
'orderId' | 'firstName' | 'lastName'
>;
@Component({
selector: 'reward-catalog-open-task-card',
standalone: true,
@@ -20,7 +32,7 @@ import { isaActionChevronRight } from '@isa/icons';
<a
class="bg-isa-white flex items-center justify-between px-[22px] py-[20px] rounded-2xl w-[334px] cursor-pointer no-underline"
data-what="open-task-card"
[attr.data-which]="task().orderItemId"
[attr.data-which]="task().orderId"
[routerLink]="routePath()"
>
<div class="flex flex-col gap-1">
@@ -47,7 +59,7 @@ export class OpenTaskCardComponent {
/**
* The open task data to display
*/
readonly task = input.required<DBHOrderItemListItemDTO>();
readonly task = input.required<OpenTaskCardInput>();
/**
* Computed customer name from first and last name
@@ -62,7 +74,9 @@ export class OpenTaskCardComponent {
/**
* Current tab ID for navigation
*/
readonly #tabId = computed(() => this.#tabService.activatedTab()?.id ?? Date.now());
readonly #tabId = computed(
() => this.#tabService.activatedTab()?.id ?? Date.now(),
);
/**
* Route path to the reward order confirmation page.
@@ -74,6 +88,12 @@ export class OpenTaskCardComponent {
console.warn('Missing orderId in task', this.task());
return [];
}
return ['/', this.#tabId(), 'reward', 'order-confirmation', orderId.toString()];
return [
'/',
this.#tabId(),
'reward',
'order-confirmation',
orderId.toString(),
];
});
}

View File

@@ -1,4 +1,4 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core';
import { OpenRewardTasksResource } from '@isa/oms/data-access';
import { CarouselComponent } from '@isa/ui/carousel';
import { OpenTaskCardComponent } from './open-task-card.component';
@@ -13,6 +13,7 @@ import { OpenTaskCardComponent } from './open-task-card.component';
* - Keyboard navigation (Arrow Left/Right)
* - Automatic visibility based on task availability
* - Shared global resource for consistent data across app
* - Deduplicates tasks to show only one card per orderId
*/
@Component({
selector: 'reward-catalog-open-tasks-carousel',
@@ -22,7 +23,7 @@ import { OpenTaskCardComponent } from './open-task-card.component';
@if (openTasksResource.hasOpenTasks()) {
<div class="mb-4" data-what="open-tasks-carousel">
<ui-carousel [gap]="'1rem'" [arrowAutoHide]="true">
@for (task of openTasksResource.tasks(); track task.orderItemId) {
@for (task of uniqueTasks(); track task.orderId) {
<reward-catalog-open-task-card [task]="task" />
}
</ui-carousel>
@@ -36,4 +37,23 @@ export class OpenTasksCarouselComponent {
* Global resource managing open reward tasks data
*/
readonly openTasksResource = inject(OpenRewardTasksResource);
/**
* Deduplicated tasks - shows only one task per orderId.
* When multiple order items exist for the same order, only the first one is displayed.
*
* @returns Array of unique tasks filtered by orderId
*/
readonly uniqueTasks = computed(() => {
const tasks = this.openTasksResource.tasks();
const seenOrderIds = new Set<number>();
return tasks.filter(task => {
if (!task.orderId || seenOrderIds.has(task.orderId)) {
return false;
}
seenOrderIds.add(task.orderId);
return true;
});
});
}

View File

@@ -12,7 +12,7 @@
type="button"
color="subtle"
size="small"
(click)="resetCustomerAndCart()"
(click)="resetCustomerAndRewardCart()"
>
Zurücksetzen
</ui-text-button>

View File

@@ -16,8 +16,9 @@ import {
SelectedRewardShoppingCartResource,
} from '@isa/checkout/data-access';
import { RouterLink } from '@angular/router';
import { injectTabId } from '@isa/core/tabs';
import { TabService, getNextTabNameHelper } from '@isa/core/tabs';
import { formatName } from '@isa/utils/format-name';
import { DomainCheckoutService } from '@domain/checkout';
@Component({
selector: 'reward-customer-card',
@@ -34,7 +35,8 @@ import { formatName } from '@isa/utils/format-name';
export class RewardCustomerCardComponent {
#crmTabMetadataService = inject(CrmTabMetadataService);
#checkoutMetadataService = inject(CheckoutMetadataService);
tabId = injectTabId();
#domainCheckoutService = inject(DomainCheckoutService);
#tabService = inject(TabService);
#customerResource = inject(SelectedCustomerResource).resource;
#shoppingCartResource = inject(SelectedRewardShoppingCartResource).resource;
#primaryCustomerCardResource = inject(PrimaryCustomerCardResource);
@@ -65,11 +67,71 @@ export class RewardCustomerCardComponent {
});
});
resetCustomerAndCart() {
this.#crmTabMetadataService.setSelectedCustomerId(this.tabId()!, undefined);
this.#checkoutMetadataService.setRewardShoppingCartId(
this.tabId()!,
undefined,
);
resetCustomerAndRewardCart() {
const tabId = this.#tabService.activatedTabId()!;
// Clear all customer-related checkout data
this.#clearCheckoutData(tabId);
// Clear all customer-related metadata
this.#clearCustomerMetadata(tabId);
// Clear reward shopping cart ID from metadata
this.#checkoutMetadataService.setRewardShoppingCartId(tabId, undefined);
// Rename tab to "Vorgang X"
const tabName = getNextTabNameHelper(this.#tabService.entityMap());
this.#tabService.patchTab(tabId, { name: tabName });
}
/**
* Clears all checkout data set by the continue() flow (customer, buyer, payer, shipping address, notification channels).
* This is the reverse operation of what happens in details-main-view.component.ts continue().
*/
#clearCheckoutData(tabId: number): void {
// Reset customer (reverse of _setCustomer)
this.#domainCheckoutService.setCustomer({
processId: tabId,
customerDto: null as any,
});
// Reset buyer (reverse of _setBuyer)
this.#domainCheckoutService.setBuyer({
processId: tabId,
buyer: null as any,
});
// Reset payer (reverse of _setPayer)
this.#domainCheckoutService.setPayer({
processId: tabId,
payer: null as any,
});
// Reset shipping address (reverse of _setShippingAddress)
this.#domainCheckoutService.setShippingAddress({
processId: tabId,
shippingAddress: null as any,
});
// Reset notification channels (reverse of _updateNotifcationChannelsAsync)
this.#domainCheckoutService.setNotificationChannels({
processId: tabId,
notificationChannels: 0,
});
}
/**
* Clears all customer-related metadata from the tab (customer ID, payer ID, shipping address ID).
* This is the reverse operation of what happens in details-main-view.component.ts continue().
*/
#clearCustomerMetadata(tabId: number): void {
// Clear customer ID from metadata (reverse of _setSelectedCustomerIdInTab)
this.#crmTabMetadataService.setSelectedCustomerId(tabId, undefined);
// Clear payer ID from metadata
this.#crmTabMetadataService.setSelectedPayerId(tabId, undefined);
// Clear shipping address ID from metadata
this.#crmTabMetadataService.setSelectedShippingAddressId(tabId, undefined);
}
}

View File

@@ -1,9 +1,9 @@
@if (displayActionCard()) {
<div
class="w-72 desktop-large:w-[24.5rem] justify-between h-full p-4 flex flex-col gap-4 rounded-lg bg-isa-secondary-100"
[class.confirmation-list-item-done]="item().status !== 1"
data-which="action-card"
data-what="action-card"
*ifNotRole="Role.CallCenter"
>
@if (!isComplete()) {
<div

View File

@@ -31,7 +31,9 @@ import {
import {
hasOrderTypeFeature,
buildItemQuantityMap,
hasLoyaltyCollectCommand,
} from '@isa/checkout/data-access';
import { IfRoleDirective, Role } from '@isa/core/auth';
@Component({
selector: 'checkout-confirmation-list-item-action-card',
@@ -43,6 +45,7 @@ import {
ButtonComponent,
DropdownButtonComponent,
DropdownOptionComponent,
IfRoleDirective,
],
providers: [
provideIcons({ isaActionCheck }),
@@ -52,6 +55,7 @@ import {
],
})
export class ConfirmationListItemActionCardComponent {
protected readonly Role = Role;
LoyaltyCollectType = LoyaltyCollectType;
ProcessingStatusState = ProcessingStatusState;
#orderRewardCollectFacade = inject(OrderRewardCollectFacade);
@@ -89,11 +93,25 @@ export class ConfirmationListItemActionCardComponent {
});
isComplete = computed(() => {
return this.processingStatus() !== undefined;
return (
this.processingStatus() !== undefined &&
this.processingStatus() !== ProcessingStatusState.Ordered
);
});
displayActionCard = computed(() =>
hasOrderTypeFeature(this.item().features, ['Rücklage']),
/**
* #5459 - Determines whether the action card should be displayed for this order item.
*
* The action card is shown when ALL of the following conditions are met:
* - The item MUST have the 'Rücklage' order type feature
* - AND one of the following:
* - The item has a loyalty collect command available (for collecting rewards)
* - OR the item processing is complete (for displaying the completed state)
*/
displayActionCard = computed(
() =>
hasOrderTypeFeature(this.item().features, ['Rücklage']) &&
(hasLoyaltyCollectCommand(this.item().subsetItems) || this.isComplete()),
);
constructor() {

View File

@@ -10,8 +10,7 @@ import {
DisplayOrderDestinationInfoComponent,
} from '@isa/checkout/shared/product-info';
import { DisplayOrderItemDTO } from '@generated/swagger/oms-api';
import { Product } from '@isa/common/data-access';
import { type OrderItemGroup } from '@isa/checkout/data-access';
import { type OrderItemGroup, type Product } from '@isa/checkout/data-access';
@Component({
selector: 'checkout-order-confirmation-item-list-item',

View File

@@ -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],
},
];

View File

@@ -7,6 +7,8 @@ import {
import {
SelectedCustomerResource,
getCustomerName,
SelectedCustomerShippingAddressResource,
SelectedCustomerPayerAddressResource,
} from '@isa/crm/data-access';
import { isaActionEdit } from '@isa/icons';
import { IconButtonComponent } from '@isa/ui/buttons';
@@ -25,10 +27,19 @@ import { NavigationStateService } from '@isa/core/navigation';
})
export class BillingAndShippingAddressCardComponent {
#navigationState = inject(NavigationStateService);
#shippingAddressResource = inject(SelectedCustomerShippingAddressResource);
#payerAddressResource = inject(SelectedCustomerPayerAddressResource);
tabId = injectTabId();
#customerResource = inject(SelectedCustomerResource).resource;
isLoading = this.#customerResource.isLoading;
isLoading = computed(() => {
return (
this.#customerResource.isLoading() ||
this.#shippingAddressResource.resource.isLoading() ||
this.#payerAddressResource.resource.isLoading()
);
});
customer = computed(() => {
return this.#customerResource.value();
@@ -49,26 +60,44 @@ export class BillingAndShippingAddressCardComponent {
}
payer = computed(() => {
// Prefer selected payer from metadata over customer as payer
const selectedPayer = this.#payerAddressResource.resource.value();
if (selectedPayer) {
return selectedPayer;
}
// Fallback to customer as payer
return this.customer();
});
payerName = computed(() => {
return getCustomerName(this.payer());
const payer = this.payer();
return getCustomerName(payer);
});
payerAddress = computed(() => {
return this.customer()?.address;
const payer = this.payer();
if (!payer) return undefined;
return payer.address;
});
shippingAddress = computed(() => {
// Prefer selected shipping address from metadata over customer default
const selectedAddress = this.#shippingAddressResource.resource.value();
if (selectedAddress) {
return selectedAddress;
}
// Fallback to customer
return this.customer();
});
shippingName = computed(() => {
return getCustomerName(this.shippingAddress());
const shipping = this.shippingAddress();
return getCustomerName(shipping);
});
shippingAddressAddress = computed(() => {
return this.shippingAddress()?.address;
const shipping = this.shippingAddress();
if (!shipping) return undefined;
return shipping.address;
});
}

View File

@@ -9,6 +9,7 @@ import { logger } from '@isa/core/logging';
import { HttpErrorResponse } from '@angular/common/http';
import { isResponseArgs } from '@isa/common/data-access';
import { DisplayOrderDTO } from '@generated/swagger/oms-api';
import { injectFeedbackErrorDialog } from '@isa/ui/dialog';
/**
* Orchestrates checkout completion and order creation.
@@ -37,6 +38,7 @@ export class CheckoutCompletionOrchestratorService {
#shoppingCartFacade = inject(ShoppingCartFacade);
#orderCreationFacade = inject(OrderCreationFacade);
#checkoutMetadataService = inject(CheckoutMetadataService);
#errorFeedbackDialog = injectFeedbackErrorDialog();
/**
* Complete checkout with CRM data and create orders.
@@ -114,6 +116,12 @@ export class CheckoutCompletionOrchestratorService {
) {
const responseArgs = error.error;
orders = responseArgs.result;
this.#errorFeedbackDialog({
data: {
errorMessage: responseArgs.message,
},
});
// Wenn Bestellungen erstellt wurden, loggen wir eine Warnung aber fahren fort
if (orders.length > 0) {
this.#logger.warn(

View File

@@ -4,11 +4,14 @@ import {
computed,
input,
} from '@angular/core';
import { Product } from '@isa/common/data-access';
import { ProductImageDirective } from '@isa/shared/product-image';
import { ProductRouterLinkDirective } from '@isa/shared/product-router-link';
export type ProductInfoItem = Pick<Product, 'ean' | 'name' | 'contributors'>;
export type ProductInfoItem = {
ean?: string;
name?: string;
contributors?: string;
};
export type ProductNameSize = 'small' | 'medium' | 'large';

View File

@@ -9,7 +9,6 @@ export * from './order-type-feature';
export * from './payer-type';
export * from './price-value';
export * from './price';
export * from './product';
export * from './response-args';
export * from './return-value';
export * from './vat-type';

View File

@@ -1,5 +0,0 @@
import { ProductDTO as CatProductDTO } from '@generated/swagger/cat-search-api';
import { ProductDTO as CheckoutProductDTO } from '@generated/swagger/checkout-api';
import { ProductDTO as OmsProductDTO } from '@generated/swagger/oms-api';
export type Product = CatProductDTO | CheckoutProductDTO | OmsProductDTO;

816
libs/core/auth/README.md Normal file
View File

@@ -0,0 +1,816 @@
# @isa/core/auth
Type-safe role-based authorization utilities with Angular signals integration for the ISA Frontend application.
## Table of Contents
- [Overview](#overview)
- [Features](#features)
- [Quick Start](#quick-start)
- [Core Concepts](#core-concepts)
- [API Reference](#api-reference)
- [Role (Enum)](#role-enum)
- [RoleService](#roleservice)
- [IfRoleDirective](#ifroledirective)
- [TokenProvider](#tokenprovider)
- [Usage Examples](#usage-examples)
- [Configuration](#configuration)
- [Testing](#testing)
- [Architecture](#architecture)
- [Dependencies](#dependencies)
## Overview
`@isa/core/auth` provides a lightweight, type-safe system for managing role-based authorization in Angular applications. Built with modern Angular patterns (signals, standalone components), it integrates seamlessly with OAuth2 authentication flows.
### The Problem It Solves
Traditional role-checking often involves:
- ❌ String literals scattered throughout templates and components
- ❌ No compile-time safety for role names
- ❌ Manual token parsing and claim extraction
- ❌ Repetitive conditional rendering logic
This library provides:
- ✅ Type-safe `Role` enum with autocomplete
- ✅ Automatic JWT token parsing via `OAuthService`
- ✅ Declarative role-based rendering with `*ifRole` directive
- ✅ Reactive updates using Angular signals
- ✅ Centralized role management
## Features
- 🔐 **Type-Safe Roles** - Enum-based role definitions prevent typos
- 🎯 **Declarative Templates** - `*ifRole` and `*ifNotRole` structural directives
-**Signal-Based** - Reactive role checking with Angular signals
- 🔄 **Flexible Token Provider** - Injectable abstraction with OAuth2 default
- 📝 **Comprehensive Logging** - Integrated with `@isa/core/logging`
- 🧪 **Fully Tested** - 18 unit tests with Vitest
- 🎨 **Standalone** - No module imports required
## Quick Start
**1. Import the directive and Role enum:**
```typescript
import { Component } from '@angular/core';
import { IfRoleDirective, Role } from '@isa/core/auth';
@Component({
selector: 'app-dashboard',
standalone: true,
imports: [IfRoleDirective],
template: `
<!-- Show content only for Store users -->
<div *ifRole="Role.Store">
<h2>Store Dashboard</h2>
<!-- Store-specific features -->
</div>
<!-- Show content only for CallCenter users -->
<div *ifRole="Role.CallCenter">
<h2>CallCenter Dashboard</h2>
<!-- CallCenter-specific features -->
</div>
<!-- Hide content from CallCenter users -->
<div *ifNotRole="Role.CallCenter">
<button>Complete Order</button>
</div>
`
})
export class DashboardComponent {
protected readonly Role = Role; // Expose to template
}
```
**2. Use RoleService programmatically:**
```typescript
import { Component, inject } from '@angular/core';
import { RoleService, Role } from '@isa/core/auth';
@Component({
selector: 'app-nav',
template: `...`
})
export class NavComponent {
private readonly roleService = inject(RoleService);
ngOnInit() {
if (this.roleService.hasRole(Role.Store)) {
// Enable store-specific navigation
}
}
}
```
**3. No configuration needed!** The library automatically uses `OAuthService` to parse JWT tokens.
## Core Concepts
### Role Enum
Roles are defined as a const object with TypeScript type safety:
```typescript
export const Role = {
CallCenter: 'CallCenter', // HSC (Hugendubel Service Center)
Store: 'Store', // Store/Branch users
} as const;
export type Role = (typeof Role)[keyof typeof Role];
```
**Benefits:**
- Autocomplete in IDEs
- Compile-time checking prevents invalid roles
- Easy to extend with new roles
### Token Provider Pattern
The library uses an injectable `TokenProvider` abstraction to decouple from specific authentication implementations:
```typescript
export interface TokenProvider {
getClaimByKey(key: string): unknown;
}
```
**Default Implementation:**
- Automatically provided via `InjectionToken` factory
- Uses `OAuthService.getAccessToken()` to fetch JWT
- Parses token using `parseJwt()` utility
- No manual configuration required
### Signal-Based Reactivity
The `IfRoleDirective` uses Angular effects for automatic re-rendering when roles change:
```typescript
constructor() {
effect(() => {
this.render(); // Re-render when ifRole/ifNotRole inputs change
});
}
```
## API Reference
### Role (Enum)
Type-safe role definitions for the application.
```typescript
export const Role = {
CallCenter: 'CallCenter', // HSC users
Store: 'Store', // Store users
} as const;
```
**Usage:**
```typescript
import { Role } from '@isa/core/auth';
if (roleService.hasRole(Role.Store)) {
// Type-safe!
}
```
---
### RoleService
Service for programmatic role checks.
#### Methods
##### `hasRole(role: Role | Role[]): boolean`
Check if the authenticated user has specific role(s).
**Parameters:**
- `role` - Single role or array of roles to check (AND logic for arrays)
**Returns:** `true` if user has all specified roles, `false` otherwise
**Examples:**
```typescript
import { inject } from '@angular/core';
import { RoleService, Role } from '@isa/core/auth';
export class ExampleComponent {
private readonly roleService = inject(RoleService);
checkAccess() {
// Single role check
if (this.roleService.hasRole(Role.Store)) {
console.log('User is a store employee');
}
// Multiple roles (AND logic)
if (this.roleService.hasRole([Role.Store, Role.CallCenter])) {
console.log('User has BOTH store AND call center access');
}
// Multiple checks
const isStore = this.roleService.hasRole(Role.Store);
const isCallCenter = this.roleService.hasRole(Role.CallCenter);
if (isStore || isCallCenter) {
console.log('User has at least one role (OR logic)');
}
}
}
```
**Logging:**
The service logs all role checks at `debug` level:
```
[RoleService] Role check: Store => true
[RoleService] Role check: Store, CallCenter => false
```
---
### IfRoleDirective
Structural directive for declarative role-based rendering.
**Selector:** `[ifRole]`, `[ifRoleElse]`, `[ifNotRole]`, `[ifNotRoleElse]`
#### Inputs
| Input | Type | Description |
|-------|------|-------------|
| `ifRole` | `Role \| Role[]` | Role(s) required to show template |
| `ifRoleElse` | `TemplateRef` | Alternative template if user lacks role |
| `ifNotRole` | `Role \| Role[]` | Role(s) that should NOT be present |
| `ifNotRoleElse` | `TemplateRef` | Alternative template if user has role |
#### Examples
**Basic Usage:**
```html
<!-- Show for Store users -->
<div *ifRole="Role.Store">
Store-specific content
</div>
<!-- Show for CallCenter users -->
<div *ifRole="Role.CallCenter">
CallCenter-specific content
</div>
```
**With Else Template:**
```html
<div *ifRole="Role.Store; else noAccess">
<button>Complete Order</button>
</div>
<ng-template #noAccess>
<p>You don't have permission to complete orders</p>
</ng-template>
```
**Negation (`ifNotRole`):**
```html
<!-- Hide from CallCenter users -->
<div *ifNotRole="Role.CallCenter">
<button>Release Reward</button>
<button>Mark Not Found</button>
<button>Cancel</button>
</div>
```
**Multiple Roles (AND logic):**
```html
<!-- Only show if user has BOTH roles -->
<div *ifRole="[Role.Store, Role.CallCenter]">
Advanced features requiring both roles
</div>
```
**Component Integration:**
```typescript
import { Component } from '@angular/core';
import { IfRoleDirective, Role } from '@isa/core/auth';
@Component({
selector: 'app-actions',
standalone: true,
imports: [IfRoleDirective],
template: `
<div *ifNotRole="Role.CallCenter">
<button (click)="completeOrder()">Complete</button>
</div>
`
})
export class ActionsComponent {
// Expose Role to template
protected readonly Role = Role;
completeOrder() {
// ...
}
}
```
---
### TokenProvider
Injectable abstraction for JWT token parsing.
```typescript
export interface TokenProvider {
getClaimByKey(key: string): unknown;
}
```
**Default Implementation:**
Automatically provided via `InjectionToken` factory:
```typescript
export const TOKEN_PROVIDER = new InjectionToken<TokenProvider>(
'TOKEN_PROVIDER',
{
providedIn: 'root',
factory: () => {
const oAuthService = inject(OAuthService);
return {
getClaimByKey: (key: string) => {
const claims = parseJwt(oAuthService.getAccessToken());
return claims?.[key] ?? null;
},
};
},
},
);
```
**Custom Provider (Advanced):**
Override the default implementation:
```typescript
import { TOKEN_PROVIDER, TokenProvider } from '@isa/core/auth';
providers: [
{
provide: TOKEN_PROVIDER,
useValue: {
getClaimByKey: (key: string) => {
// Custom token parsing logic
return myCustomAuthService.getClaim(key);
}
} as TokenProvider
}
]
```
---
### parseJwt()
Utility function to parse JWT tokens.
```typescript
export function parseJwt(
token: string | null
): Record<string, unknown> | null
```
**Parameters:**
- `token` - JWT token string or null
**Returns:** Parsed claims object or null
**Example:**
```typescript
import { parseJwt } from '@isa/core/auth';
const token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...';
const claims = parseJwt(token);
console.log(claims?.['role']); // ['Store']
console.log(claims?.['sub']); // User ID
```
## Usage Examples
### Example 1: Conditional Navigation
```typescript
import { Component, inject } from '@angular/core';
import { RouterLink } from '@angular/router';
import { IfRoleDirective, Role } from '@isa/core/auth';
@Component({
selector: 'app-side-menu',
standalone: true,
imports: [RouterLink, IfRoleDirective],
template: `
<nav>
<!-- Store-only navigation -->
<a *ifRole="Role.Store" routerLink="/inventory">
Inventory Management
</a>
<a *ifRole="Role.Store" routerLink="/store-orders">
Store Orders
</a>
<!-- CallCenter-only navigation -->
<a *ifRole="Role.CallCenter" routerLink="/customer-service">
Customer Service
</a>
<!-- Show for both roles -->
<a routerLink="/dashboard">
Dashboard
</a>
</nav>
`
})
export class SideMenuComponent {
protected readonly Role = Role;
}
```
### Example 2: Guard with RoleService
```typescript
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { RoleService, Role } from '@isa/core/auth';
export const storeGuard: CanActivateFn = () => {
const roleService = inject(RoleService);
const router = inject(Router);
if (roleService.hasRole(Role.Store)) {
return true;
}
// Redirect to unauthorized page
return router.createUrlTree(['/unauthorized']);
};
// Route configuration
export const routes = [
{
path: 'inventory',
component: InventoryComponent,
canActivate: [storeGuard]
}
];
```
### Example 3: Computed Signals with Roles
```typescript
import { Component, inject, computed } from '@angular/core';
import { RoleService, Role } from '@isa/core/auth';
import { toSignal } from '@angular/core/rxjs-interop';
@Component({
selector: 'app-dashboard',
template: `
@if (canManageInventory()) {
<button (click)="openInventory()">Manage Inventory</button>
}
@if (canProcessReturns()) {
<button (click)="openReturns()">Process Returns</button>
}
`
})
export class DashboardComponent {
private readonly roleService = inject(RoleService);
// Computed permissions
canManageInventory = computed(() =>
this.roleService.hasRole(Role.Store)
);
canProcessReturns = computed(() =>
this.roleService.hasRole([Role.Store, Role.CallCenter])
);
openInventory() { /* ... */ }
openReturns() { /* ... */ }
}
```
### Example 4: Real-World Component (Reward Order Confirmation)
```typescript
import { Component } from '@angular/core';
import { IfRoleDirective, Role } from '@isa/core/auth';
import { ButtonComponent } from '@isa/ui/buttons';
@Component({
selector: 'checkout-confirmation-actions',
standalone: true,
imports: [IfRoleDirective, ButtonComponent],
template: `
<div class="action-card">
<div class="message">
Please complete the order or select an action.
</div>
<!-- Hide actions from CallCenter (HSC) users -->
<div *ifNotRole="Role.CallCenter" class="actions">
<select [(ngModel)]="selectedAction">
<option value="collect">Release Reward</option>
<option value="not-found">Not Found</option>
<option value="cancel">Cancel</option>
</select>
<button uiButton color="primary" (click)="complete()">
Complete
</button>
</div>
</div>
`
})
export class ConfirmationActionsComponent {
protected readonly Role = Role;
selectedAction = 'collect';
complete() {
// Complete order logic
}
}
```
## Configuration
### Default Configuration (Recommended)
No configuration needed! The library automatically uses `OAuthService`:
```typescript
import { Component } from '@angular/core';
import { IfRoleDirective, Role } from '@isa/core/auth';
@Component({
standalone: true,
imports: [IfRoleDirective],
// Works out of the box!
})
export class MyComponent {}
```
### Custom TokenProvider (Advanced)
Override the default token provider:
```typescript
import { ApplicationConfig } from '@angular/core';
import { TOKEN_PROVIDER, TokenProvider } from '@isa/core/auth';
export const appConfig: ApplicationConfig = {
providers: [
{
provide: TOKEN_PROVIDER,
useFactory: () => {
const customAuth = inject(CustomAuthService);
return {
getClaimByKey: (key: string) => customAuth.getClaim(key)
} as TokenProvider;
}
}
]
};
```
### JWT Token Structure
The library expects JWT tokens with a `role` claim:
```json
{
"sub": "user123",
"role": ["Store"],
"exp": 1234567890
}
```
**Supported formats:**
- Single role: `"role": "Store"`
- Multiple roles: `"role": ["Store", "CallCenter"]`
## Testing
### Run Tests
```bash
# Run all tests
npx nx test core-auth
# Run with coverage
npx nx test core-auth --coverage.enabled=true
# Skip cache (fresh run)
npx nx test core-auth --skip-nx-cache
```
### Test Results
```
✓ src/lib/role.service.spec.ts (11 tests)
✓ src/lib/if-role.directive.spec.ts (7 tests)
Test Files 2 passed (2)
Tests 18 passed (18)
```
### Testing in Your App
**Mock RoleService:**
```typescript
import { describe, it, expect, vi } from 'vitest';
import { TestBed } from '@angular/core/testing';
import { RoleService, Role } from '@isa/core/auth';
describe('MyComponent', () => {
let roleService: RoleService;
beforeEach(() => {
roleService = {
hasRole: vi.fn().mockReturnValue(true)
} as any;
TestBed.configureTestingModule({
providers: [
{ provide: RoleService, useValue: roleService }
]
});
});
it('should show store content for store users', () => {
vi.spyOn(roleService, 'hasRole').mockReturnValue(true);
const fixture = TestBed.createComponent(MyComponent);
fixture.detectChanges();
expect(roleService.hasRole).toHaveBeenCalledWith(Role.Store);
// Assert UI changes
});
});
```
**Mock TokenProvider:**
```typescript
import { TOKEN_PROVIDER, TokenProvider, Role } from '@isa/core/auth';
const mockTokenProvider: TokenProvider = {
getClaimByKey: vi.fn().mockReturnValue([Role.Store])
};
TestBed.configureTestingModule({
providers: [
{ provide: TOKEN_PROVIDER, useValue: mockTokenProvider }
]
});
```
## Architecture
### Design Patterns
**1. Token Provider Pattern**
- Abstracts JWT parsing behind injectable interface
- Allows custom implementations without changing consumers
- Default factory provides OAuthService integration
**2. Signal-Based Reactivity**
- Uses Angular signals for reactive role checks
- Effect-driven template updates
- Minimal re-renders with fine-grained reactivity
**3. Type-Safe Enum Pattern**
- Const object with `as const` assertion
- Provides autocomplete and compile-time safety
- Prevents typos and invalid role strings
### Architecture Diagram
```
┌─────────────────────────────────────────────────────┐
│ Application Layer │
│ ┌─────────────────┐ ┌──────────────────┐ │
│ │ Components │ │ Route Guards │ │
│ │ (Templates) │ │ │ │
│ └────────┬────────┘ └────────┬─────────┘ │
│ │ │ │
│ │ *ifRole │ hasRole() │
│ ▼ ▼ │
├───────────────────────────────────────────────────┤
│ @isa/core/auth Library │
│ ┌──────────────────┐ ┌─────────────────┐ │
│ │ IfRoleDirective │ │ RoleService │ │
│ │ (Signals) │──────▶│ (Injectable) │ │
│ └──────────────────┘ └────────┬────────┘ │
│ │ │
│ hasRole(Role[]) │
│ │ │
│ ▼ │
│ ┌────────────────────┐ │
│ │ TokenProvider │ │
│ │ (InjectionToken) │ │
│ └────────┬───────────┘ │
│ │ │
│ getClaimByKey('role') │
│ │ │
├───────────────────────────────────┼──────────────┤
│ ▼ │
│ ┌──────────────────────────┐ │
│ │ OAuthService │ │
│ │ (angular-oauth2-oidc) │ │
│ └──────────┬───────────────┘ │
│ │ │
│ getAccessToken() │
│ │ │
└──────────────────────────┼────────────────────────┘
┌───────────────┐
│ JWT Token │
│ { role: ... }│
└───────────────┘
```
### Role Claim Handling
The library handles both single and multiple role formats:
```typescript
// Single role (string)
{ "role": "Store" }
// Multiple roles (array)
{ "role": ["Store", "CallCenter"] }
// Internal normalization using coerceArray()
const userRolesArray = coerceArray(userRoles);
```
## Dependencies
### External Dependencies
- **`@angular/core`** - Angular framework
- **`@angular/cdk/coercion`** - Array coercion utility
- **`angular-oauth2-oidc`** - OAuth2/OIDC authentication
- **`@isa/core/logging`** - Logging integration
### Internal Dependencies
No other ISA libraries required beyond `@isa/core/logging`.
### Import Path
```typescript
import {
RoleService,
IfRoleDirective,
Role,
TokenProvider,
TOKEN_PROVIDER,
parseJwt
} from '@isa/core/auth';
```
**Path Alias:** `@isa/core/auth``libs/core/auth/src/index.ts`
## Related Documentation
- [CLAUDE.md](../../../CLAUDE.md) - Project guidelines
- [Testing Guidelines](../../../docs/guidelines/testing.md) - Vitest setup
- [Library Reference](../../../docs/library-reference.md) - All libraries
## Related Libraries
- [`@isa/core/logging`](../logging/README.md) - Structured logging
- [`@isa/core/config`](../config/README.md) - Configuration management
- [`@isa/core/storage`](../storage/README.md) - State persistence
---
**License:** ISC
**Version:** 1.0.0
**Last Updated:** 2025-01-10

View File

@@ -0,0 +1,34 @@
const nx = require('@nx/eslint-plugin');
const baseConfig = require('../../../eslint.config.js');
module.exports = [
...baseConfig,
...nx.configs['flat/angular'],
...nx.configs['flat/angular-template'],
{
files: ['**/*.ts'],
rules: {
'@angular-eslint/directive-selector': [
'error',
{
type: 'attribute',
prefix: 'lib',
style: 'camelCase',
},
],
'@angular-eslint/component-selector': [
'error',
{
type: 'element',
prefix: 'lib',
style: 'kebab-case',
},
],
},
},
{
files: ['**/*.html'],
// Override or add rules here
rules: {},
},
];

View File

@@ -0,0 +1,20 @@
{
"name": "core-auth",
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/core/auth/src",
"prefix": "lib",
"projectType": "library",
"tags": [],
"targets": {
"test": {
"executor": "@nx/vite:test",
"outputs": ["{options.reportsDirectory}"],
"options": {
"reportsDirectory": "../../../coverage/libs/core/auth"
}
},
"lint": {
"executor": "@nx/eslint:lint"
}
}
}

View File

@@ -0,0 +1,10 @@
/**
* Core Auth Library
*
* Provides role-based authorization utilities for the ISA Frontend application.
*/
export { RoleService } from './lib/role.service';
export { IfRoleDirective } from './lib/if-role.directive';
export { TokenProvider, TOKEN_PROVIDER, parseJwt } from './lib/token-provider';
export { Role } from './lib/role';

View File

@@ -0,0 +1,157 @@
import { Component } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { IfRoleDirective } from './if-role.directive';
import { RoleService } from './role.service';
import { Role } from './role';
@Component({
standalone: true,
imports: [IfRoleDirective],
template: `
<div *ifRole="role" data-test="content">Store Content</div>
`,
})
class TestIfRoleComponent {
role = Role.Store;
}
@Component({
standalone: true,
imports: [IfRoleDirective],
template: `
<div *ifRole="role; else noAccess" data-test="content">Store Content</div>
<ng-template #noAccess>
<div data-test="else">No Access</div>
</ng-template>
`,
})
class TestIfRoleElseComponent {
role = Role.Store;
}
@Component({
standalone: true,
imports: [IfRoleDirective],
template: `
<div *ifNotRole="role" data-test="content">Non-Store Content</div>
`,
})
class TestIfNotRoleComponent {
role = Role.Store;
}
describe('IfRoleDirective', () => {
let roleService: { hasRole: ReturnType<typeof vi.fn> };
beforeEach(() => {
roleService = {
hasRole: vi.fn(),
};
TestBed.configureTestingModule({
providers: [{ provide: RoleService, useValue: roleService }],
});
});
describe('ifRole', () => {
it('should render content when user has role', () => {
roleService.hasRole.mockReturnValue(true);
const fixture = TestBed.createComponent(TestIfRoleComponent);
fixture.detectChanges();
const content = fixture.nativeElement.querySelector('[data-test="content"]');
expect(content).toBeTruthy();
expect(content?.textContent).toContain('Store Content');
});
it('should not render content when user does not have role', () => {
roleService.hasRole.mockReturnValue(false);
const fixture = TestBed.createComponent(TestIfRoleComponent);
fixture.detectChanges();
const content = fixture.nativeElement.querySelector('[data-test="content"]');
expect(content).toBeFalsy();
});
it('should render else template when user does not have role', () => {
roleService.hasRole.mockReturnValue(false);
const fixture = TestBed.createComponent(TestIfRoleElseComponent);
fixture.detectChanges();
const content = fixture.nativeElement.querySelector('[data-test="content"]');
const elseContent = fixture.nativeElement.querySelector('[data-test="else"]');
expect(content).toBeFalsy();
expect(elseContent).toBeTruthy();
expect(elseContent?.textContent).toContain('No Access');
});
it('should update when role input changes', () => {
roleService.hasRole.mockReturnValue(true);
const fixture = TestBed.createComponent(TestIfRoleComponent);
fixture.detectChanges();
let content = fixture.nativeElement.querySelector('[data-test="content"]');
expect(content).toBeTruthy();
// Change role and mock to return false
roleService.hasRole.mockReturnValue(false);
fixture.componentInstance.role = Role.CallCenter;
fixture.detectChanges();
content = fixture.nativeElement.querySelector('[data-test="content"]');
expect(content).toBeFalsy();
});
});
describe('ifNotRole', () => {
it('should render content when user does NOT have role', () => {
roleService.hasRole.mockReturnValue(false);
const fixture = TestBed.createComponent(TestIfNotRoleComponent);
fixture.detectChanges();
const content = fixture.nativeElement.querySelector('[data-test="content"]');
expect(content).toBeTruthy();
expect(content?.textContent).toContain('Non-Store Content');
});
it('should not render content when user has role', () => {
roleService.hasRole.mockReturnValue(true);
const fixture = TestBed.createComponent(TestIfNotRoleComponent);
fixture.detectChanges();
const content = fixture.nativeElement.querySelector('[data-test="content"]');
expect(content).toBeFalsy();
});
});
describe('multiple roles', () => {
it('should handle array of roles', () => {
roleService.hasRole.mockReturnValue(true);
@Component({
standalone: true,
imports: [IfRoleDirective],
template: `<div *ifRole="roles" data-test="content">Content</div>`,
})
class TestMultipleRolesComponent {
roles = [Role.Store, Role.CallCenter];
}
const fixture = TestBed.createComponent(TestMultipleRolesComponent);
fixture.detectChanges();
expect(roleService.hasRole).toHaveBeenCalledWith([Role.Store, Role.CallCenter]);
const content = fixture.nativeElement.querySelector('[data-test="content"]');
expect(content).toBeTruthy();
});
});
});

View File

@@ -0,0 +1,105 @@
import {
Directive,
effect,
inject,
input,
TemplateRef,
ViewContainerRef,
} from '@angular/core';
import { RoleService } from './role.service';
import { Role } from './role';
/**
* Structural directive for role-based conditional rendering using Angular signals
*
* @example
* ```html
* <!-- Show content if user has role -->
* <div *ifRole="Role.Store">Store content</div>
*
* <!-- Show content if user has multiple roles -->
* <div *ifRole="[Role.Store, Role.CallCenter]">Multiple roles</div>
*
* <!-- Show alternate content if user doesn't have role -->
* <div *ifRole="Role.Store; else noAccess">Store content</div>
* <ng-template #noAccess>No access</ng-template>
*
* <!-- Show content if user does NOT have role -->
* <div *ifNotRole="Role.CallCenter">Non-CallCenter content</div>
* ```
*/
@Directive({
selector: '[ifRole],[ifRoleElse],[ifNotRole],[ifNotRoleElse]',
standalone: true,
})
export class IfRoleDirective {
private readonly _templateRef = inject(TemplateRef<{ $implicit: Role | Role[] }>);
private readonly _viewContainer = inject(ViewContainerRef);
private readonly _roleService = inject(RoleService);
/**
* Role(s) required to show the template
*/
readonly ifRole = input<Role | Role[]>();
/**
* Alternative template to show if user doesn't have ifRole
*/
readonly ifRoleElse = input<TemplateRef<unknown>>();
/**
* Role(s) that should NOT be present to show the template
*/
readonly ifNotRole = input<Role | Role[]>();
/**
* Alternative template to show if user has ifNotRole
*/
readonly ifNotRoleElse = input<TemplateRef<unknown>>();
constructor() {
// Use effect to reactively update the view when inputs change
effect(() => {
this.render();
});
}
private get renderTemplateRef(): boolean {
const role = this.ifRole();
const notRole = this.ifNotRole();
if (role) {
return this._roleService.hasRole(role);
}
if (notRole) {
return !this._roleService.hasRole(notRole);
}
return false;
}
private get elseTemplateRef(): TemplateRef<unknown> | undefined {
return this.ifRoleElse() || this.ifNotRoleElse();
}
private render() {
if (this.renderTemplateRef) {
this._viewContainer.clear();
this._viewContainer.createEmbeddedView(this._templateRef, this.getContext());
return;
}
if (this.elseTemplateRef) {
this._viewContainer.clear();
this._viewContainer.createEmbeddedView(this.elseTemplateRef, this.getContext());
return;
}
this._viewContainer.clear();
}
private getContext(): { $implicit: Role | Role[] | undefined } {
return {
$implicit: this.ifRole() || this.ifNotRole(),
};
}
}

View File

@@ -0,0 +1,95 @@
import { TestBed } from '@angular/core/testing';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { RoleService } from './role.service';
import { TOKEN_PROVIDER, TokenProvider } from './token-provider';
import { Role } from './role';
describe('RoleService', () => {
let service: RoleService;
let tokenProvider: TokenProvider;
beforeEach(() => {
tokenProvider = {
getClaimByKey: vi.fn(),
};
TestBed.configureTestingModule({
providers: [RoleService, { provide: TOKEN_PROVIDER, useValue: tokenProvider }],
});
service = TestBed.inject(RoleService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
describe('hasRole', () => {
it('should return true when user has single required role', () => {
vi.spyOn(tokenProvider, 'getClaimByKey').mockReturnValue([Role.Store, Role.CallCenter]);
expect(service.hasRole(Role.Store)).toBe(true);
});
it('should return false when user does not have required role', () => {
vi.spyOn(tokenProvider, 'getClaimByKey').mockReturnValue([Role.CallCenter]);
expect(service.hasRole(Role.Store)).toBe(false);
});
it('should return true when user has all required roles (array)', () => {
vi.spyOn(tokenProvider, 'getClaimByKey').mockReturnValue([
Role.Store,
Role.CallCenter,
]);
expect(service.hasRole([Role.Store, Role.CallCenter])).toBe(true);
});
it('should return false when user is missing one of required roles', () => {
vi.spyOn(tokenProvider, 'getClaimByKey').mockReturnValue([Role.CallCenter]);
expect(service.hasRole([Role.Store, Role.CallCenter])).toBe(false);
});
it('should return false when user has no roles in token', () => {
vi.spyOn(tokenProvider, 'getClaimByKey').mockReturnValue(null);
expect(service.hasRole(Role.Store)).toBe(false);
});
it('should return false when user has undefined roles', () => {
vi.spyOn(tokenProvider, 'getClaimByKey').mockReturnValue(undefined);
expect(service.hasRole(Role.Store)).toBe(false);
});
it('should handle errors gracefully', () => {
vi.spyOn(tokenProvider, 'getClaimByKey').mockImplementation(() => {
throw new Error('Token parsing error');
});
expect(service.hasRole(Role.Store)).toBe(false);
});
it('should handle empty role array', () => {
vi.spyOn(tokenProvider, 'getClaimByKey').mockReturnValue([Role.Store]);
expect(service.hasRole([])).toBe(true); // empty array means no requirements
});
it('should handle single role as string (not array)', () => {
// JWT might return a single string instead of array for single role
vi.spyOn(tokenProvider, 'getClaimByKey').mockReturnValue(Role.Store);
expect(service.hasRole(Role.Store)).toBe(true);
expect(service.hasRole(Role.CallCenter)).toBe(false);
});
it('should handle single role string when checking multiple roles', () => {
vi.spyOn(tokenProvider, 'getClaimByKey').mockReturnValue(Role.Store);
expect(service.hasRole([Role.Store, Role.CallCenter])).toBe(false);
});
});
});

View File

@@ -0,0 +1,71 @@
import { coerceArray } from '@angular/cdk/coercion';
import { inject, Injectable } from '@angular/core';
import { logger } from '@isa/core/logging';
import { TOKEN_PROVIDER } from './token-provider';
import { Role } from './role';
/**
* Service for role-based authorization checks
*
* @example
* ```typescript
* import { Role } from '@isa/core/auth';
*
* const roleService = inject(RoleService);
* if (roleService.hasRole(Role.Store)) {
* // Show store features
* }
* ```
*/
@Injectable({
providedIn: 'root',
})
export class RoleService {
private readonly _log = logger({ service: 'RoleService' });
private readonly _tokenProvider = inject(TOKEN_PROVIDER);
/**
* Check if the authenticated user has specific role(s)
*
* @param role Single role or array of roles to check
* @returns true if user has all specified roles, false otherwise
*
* @example
* ```typescript
* import { Role } from '@isa/core/auth';
*
* // Check single role
* hasRole(Role.Store) // true if user has Store role
*
* // Check multiple roles (AND logic)
* hasRole([Role.Store, Role.CallCenter]) // true only if user has BOTH roles
* ```
*/
hasRole(role: Role | Role[]): boolean {
const roles = coerceArray(role);
try {
const userRoles = this._tokenProvider.getClaimByKey('role');
if (!userRoles) {
this._log.debug('No roles found in token claims');
return false;
}
// Coerce userRoles to array in case it's a single string
const userRolesArray = coerceArray(userRoles);
const hasAllRoles = roles.every((r) => userRolesArray.includes(r));
this._log.debug(`Role check: ${roles.join(', ')} => ${hasAllRoles}`, () => ({
requiredRoles: roles,
userRoles: userRolesArray,
}));
return hasAllRoles;
} catch (error) {
this._log.error('Error checking roles', error as Error, () => ({ requiredRoles: roles }));
return false;
}
}
}

View File

@@ -0,0 +1,13 @@
export const Role = {
/**
* HSC
*/
CallCenter: 'CallCenter',
/**
* Filiale
*/
Store: 'Store',
} as const;
export type Role = (typeof Role)[keyof typeof Role];

View File

@@ -0,0 +1,67 @@
import { inject, InjectionToken } from '@angular/core';
import { OAuthService } from 'angular-oauth2-oidc';
/**
* Token provider interface for role checking
* The app can provide a custom implementation that returns user roles from the auth token
*/
export interface TokenProvider {
/**
* Get a claim value from the authentication token
* @param key The claim key (e.g., 'role')
* @returns The claim value or null if not found
*/
getClaimByKey(key: string): unknown;
}
/**
* Parse JWT token to extract claims
*/
export function parseJwt(token: string | null): Record<string, unknown> | null {
if (!token) {
return null;
}
try {
const base64Url = token.split('.')[1];
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
const encoded = window.atob(base64);
return JSON.parse(encoded);
} catch {
return null;
}
}
/**
* Injection token for TokenProvider with default OAuthService implementation
*
* By default, this uses OAuthService to extract claims from the access token.
* You can override this by providing your own implementation:
*
* @example
* ```typescript
* providers: [
* {
* provide: TOKEN_PROVIDER,
* useValue: {
* getClaimByKey: (key) => customTokenService.getClaim(key)
* }
* }
* ]
* ```
*/
export const TOKEN_PROVIDER = new InjectionToken<TokenProvider>(
'TOKEN_PROVIDER',
{
providedIn: 'root',
factory: () => {
const oAuthService = inject(OAuthService);
return {
getClaimByKey: (key: string) => {
const claims = parseJwt(oAuthService.getAccessToken());
return claims?.[key] ?? null;
},
};
},
},
);

View File

@@ -0,0 +1,13 @@
import '@angular/compiler';
import '@analogjs/vitest-angular/setup-zone';
import {
BrowserTestingModule,
platformBrowserTesting,
} from '@angular/platform-browser/testing';
import { getTestBed } from '@angular/core/testing';
getTestBed().initTestEnvironment(
BrowserTestingModule,
platformBrowserTesting(),
);

View File

@@ -0,0 +1,30 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"importHelpers": true,
"moduleResolution": "bundler",
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"module": "preserve"
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"typeCheckHostBindings": true,
"strictTemplates": true
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}

View File

@@ -0,0 +1,27 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../dist/out-tsc",
"declaration": true,
"declarationMap": true,
"inlineSources": true,
"types": []
},
"exclude": [
"src/**/*.spec.ts",
"src/test-setup.ts",
"jest.config.ts",
"src/**/*.test.ts",
"vite.config.ts",
"vite.config.mts",
"vitest.config.ts",
"vitest.config.mts",
"src/**/*.test.tsx",
"src/**/*.spec.tsx",
"src/**/*.test.js",
"src/**/*.spec.js",
"src/**/*.test.jsx",
"src/**/*.spec.jsx"
],
"include": ["src/**/*.ts"]
}

View File

@@ -0,0 +1,29 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../dist/out-tsc",
"types": [
"vitest/globals",
"vitest/importMeta",
"vite/client",
"node",
"vitest"
]
},
"include": [
"vite.config.ts",
"vite.config.mts",
"vitest.config.ts",
"vitest.config.mts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.test.tsx",
"src/**/*.spec.tsx",
"src/**/*.test.js",
"src/**/*.spec.js",
"src/**/*.test.jsx",
"src/**/*.spec.jsx",
"src/**/*.d.ts"
],
"files": ["src/test-setup.ts"]
}

View File

@@ -0,0 +1,33 @@
/// <reference types='vitest' />
import { defineConfig } from 'vite';
import angular from '@analogjs/vite-plugin-angular';
import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin';
import { nxCopyAssetsPlugin } from '@nx/vite/plugins/nx-copy-assets.plugin';
export default
// @ts-expect-error - Vitest reporter tuple types have complex inference issues
defineConfig(() => ({
root: __dirname,
cacheDir: '../../../node_modules/.vite/libs/core/auth',
plugins: [angular(), nxViteTsPaths(), nxCopyAssetsPlugin(['*.md'])],
// Uncomment this if you are using workers.
// worker: {
// plugins: [ nxViteTsPaths() ],
// },
test: {
watch: false,
globals: true,
environment: 'jsdom',
include: ['{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
setupFiles: ['src/test-setup.ts'],
reporters: [
'default',
['junit', { outputFile: '../../../testresults/junit-core-auth.xml' }],
],
coverage: {
reportsDirectory: '../../../coverage/libs/core/auth',
provider: 'v8' as const,
reporter: ['text', 'cobertura'],
},
},
}));

View File

@@ -1,5 +1,4 @@
import { inject, Injectable } from '@angular/core';
import { toObservable } from '@angular/core/rxjs-interop';
import { StorageProvider } from './storage-provider';
import {
ResponseArgsOfUserState,
@@ -15,8 +14,8 @@ import {
throwError,
timer,
} from 'rxjs';
import { USER_SUB } from '../tokens';
import { Debounce, ValidateParam } from '@isa/common/decorators';
import { AuthService } from '@core/auth';
import z from 'zod';
import { logger } from '@isa/core/logging';
import { HttpErrorResponse } from '@angular/common/http';
@@ -31,14 +30,12 @@ const DEFAULT_USER_STATE_RESPONSE: ResponseArgsOfUserState = {
@Injectable({ providedIn: 'root' })
export class UserStorageProvider implements StorageProvider {
#logger = logger(() => ({
context: 'UserStorageProvider',
}));
#logger = logger(() => ({ service: 'UserStorageProvider' }));
#userStateService = inject(UserStateService);
#userSub = toObservable(inject(USER_SUB));
#authService = inject(AuthService);
#loadUserState = this.#userSub.pipe(
filter((sub) => sub !== 'anonymous'),
#loadUserState = this.#authService.authenticated$.pipe(
filter((authenticated) => authenticated),
switchMap(() =>
this.#userStateService.UserStateGetUserState().pipe(
catchError((error) => {
@@ -50,10 +47,10 @@ export class UserStorageProvider implements StorageProvider {
retry({
count: 3,
delay: (error, retryCount) => {
this.#logger.warn(
`Retrying to load user state, attempt #${retryCount}`,
error,
);
this.#logger.warn('Retrying user state load', () => ({
attempt: retryCount,
error: error.message,
}));
return timer(1000 * retryCount); // Exponential backoff with timer
},
}),
@@ -74,11 +71,15 @@ export class UserStorageProvider implements StorageProvider {
#state: UserState = {};
async init() {
this.#logger.info('Initializing UserStorageProvider');
await firstValueFrom(this.#loadUserState);
this.#logger.info('UserStorageProvider initialized');
}
async reload(): Promise<void> {
this.#logger.info('Reloading user state');
await firstValueFrom(this.#loadUserState);
this.#logger.info('User state reloaded');
}
#setCurrentState(state: UserState) {
@@ -94,18 +95,24 @@ export class UserStorageProvider implements StorageProvider {
@Debounce({ wait: 1000 })
private postNewState(): void {
this.#logger.debug('Saving user state to server');
const state = JSON.stringify(this.#state);
firstValueFrom(
this.#userStateService.UserStateSetUserState({
content: state,
}),
).catch((error) => {
this.#logger.error('Error saving user state:', error);
});
)
.then(() => {
this.#logger.debug('User state saved successfully');
})
.catch((error) => {
this.#logger.error('Failed to save user state', error);
});
}
@ValidateParam(0, z.string().min(1))
set(key: string, value: Record<string, unknown>): void {
this.#logger.debug('Setting user state key', () => ({ key }));
const current = this.#state;
const content = structuredClone(current);
content[key] = value;
@@ -115,12 +122,13 @@ export class UserStorageProvider implements StorageProvider {
@ValidateParam(0, z.string().min(1))
get(key: string): unknown {
const data = structuredClone(this.#state[key]);
return data;
this.#logger.trace('Getting user state key', () => ({ key }));
return structuredClone(this.#state[key]);
}
@ValidateParam(0, z.string().min(1))
clear(key: string): void {
this.#logger.debug('Clearing user state key', () => ({ key }));
const current = this.#state;
if (key in current) {
const content = structuredClone(current);

View File

@@ -1,10 +1,20 @@
import { InjectionToken, signal, Signal } from '@angular/core';
import { inject, InjectionToken, Signal } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { AuthService } from '@core/auth';
import { map } from 'rxjs';
export const USER_SUB = new InjectionToken<Signal<string>>(
'core.storage.user-sub',
{
factory: () => {
return signal('anonymous');
const auth = inject(AuthService);
return toSignal(
auth.authenticated$.pipe(
// Map to user sub or 'anonymous' if not authenticated
// This ensures that the signal updates when authentication state changes
map(() => auth.getClaimByKey('sub') ?? 'anonymous'),
),
);
},
},
);

View File

@@ -7,3 +7,5 @@ export * from './lib/tab-navigation.service';
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';

View File

@@ -0,0 +1,31 @@
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { logger } from '@isa/core/logging';
export const hasTabIdGuard: CanActivateFn = (route) => {
const router = inject(Router);
const log = logger(() => ({
context: 'hasTabIdGuard',
url: route.url.map((s) => s.path).join('/'),
params: JSON.stringify(route.params),
queryParams: JSON.stringify(route.queryParams),
}));
const tabId = route.params['tabId'];
const isValidTabId = tabId && !isNaN(parseInt(tabId)) && parseInt(tabId) > 0;
if (isValidTabId) {
log.debug('Valid tabId - allowing navigation', () => ({
tabId,
parsedValue: parseInt(tabId),
}));
return true;
}
log.warn('Invalid or missing tabId - redirecting to dashboard', () => ({
tabId,
isNaN: isNaN(parseInt(tabId)),
parsedValue: parseInt(tabId),
redirectTo: '/kunde/dashboard',
}));
return router.parseUrl('/kunde/dashboard');
};

View File

@@ -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<Tab> = {};
const result = getNextTabNameHelper(entities);
expect(result).toBe('Vorgang 1');
});
it('should return "Vorgang 1" when no Vorgang tabs exist', () => {
const entities: EntityMap<Tab> = {
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<Tab> = {
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<Tab> = {
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<Tab> = {
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<Tab> = {
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');
});
});

View File

@@ -27,3 +27,75 @@ export function getMetadataHelper<T extends z.ZodTypeAny>(
}
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<Tab>): 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;
};

View File

@@ -0,0 +1,195 @@
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 { CrmTabMetadataService } from '@isa/crm/data-access';
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<unknown> = async () => {
const tabService = inject(TabService);
const checkoutMetadataService = inject(CheckoutMetadataService);
const crmTabMetadataService = inject(CrmTabMetadataService);
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 and if it matches the current tab
// Routes without tab ID (e.g., /filiale/package-inspection, /kunde/dashboard) are global areas
// Routes with different tab ID (e.g., creating new process) should not affect current tab
const nextUrl = router.getCurrentNavigation()?.finalUrl?.toString() ?? '';
const tabIdMatch = nextUrl.match(/\/(\d{10,})\//);
const targetTabId = tabIdMatch ? parseInt(tabIdMatch[1], 10) : null;
// Skip cleanup if navigating to global area or different tab
if (!targetTabId || targetTabId !== tabId) {
log.debug(
targetTabId
? 'Navigating to different tab, keeping current tab unchanged'
: 'Navigating to global area (no tab ID), keeping tab unchanged',
() => ({
currentTabId: tabId,
targetTabId,
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 });
// Clear customer-related metadata (prevents old customer data from being reused)
crmTabMetadataService.setSelectedCustomerId(tabId, undefined);
crmTabMetadataService.setSelectedPayerId(tabId, undefined);
crmTabMetadataService.setSelectedShippingAddressId(tabId, undefined);
// Create new shopping cart and update Store (this automatically dispatches setShoppingCart action)
await firstValueFrom(
domainCheckoutService.createShoppingCart({ processId: tabId }),
);
// 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,
}));
return true;
} catch (error) {
log.error('Error in checkout cleanup guard', error as Error, () => ({
tabId,
}));
return true; // Allow navigation even if cleanup fails
}
};

View File

@@ -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<Tab> = (route) => {
const log = logger(() => ({
@@ -22,9 +23,10 @@ export const tabResolverFn: ResolveFn<Tab> = (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',
},

View File

@@ -27,6 +27,7 @@ import { computed, inject } from '@angular/core';
import { withDevtools } from '@angular-architects/ngrx-toolkit';
import { CORE_TAB_ID_GENERATOR } from './tab-id.generator';
import { withStorage, UserStorageProvider } from '@isa/core/storage';
import { logger } from '@isa/core/logging';
export const TabService = signalStore(
{ providedIn: 'root' },
@@ -44,6 +45,7 @@ export const TabService = signalStore(
) => ({
_generateId: idGenerator,
_config: config,
_logger: logger({ service: 'TabService' }),
}),
),
withComputed((store) => ({
@@ -68,16 +70,39 @@ export const TabService = signalStore(
location: { current: -1, locations: [] },
};
patchState(store, addEntity(tab));
store._logger.info('Tab added', () => ({
tabId: tab.id,
name: tab.name,
tags: tab.tags,
}));
return tab;
},
activateTab(id: number) {
const tab = store.entityMap()[id];
patchState(store, { activatedTabId: id });
if (!tab) {
store._logger.warn('Cannot activate non-existent tab', () => ({
tabId: id,
}));
return;
}
const changes: Partial<Tab> = { activatedAt: Date.now() };
patchState(store, updateEntity({ id, changes }));
patchState(store, { activatedTabId: id });
store._logger.debug('Tab activated', () => ({ tabId: id }));
},
patchTab(id: number, changes: z.infer<typeof PatchTabSchema>) {
const currentTab = store.entityMap()[id];
if (!currentTab) {
store._logger.warn('Cannot patch non-existent tab', () => ({
tabId: id,
}));
return;
}
const patchedMetadata = changes.metadata
? { ...currentTab.metadata, ...changes.metadata }
: currentTab.metadata;
@@ -88,16 +113,38 @@ export const TabService = signalStore(
};
patchState(store, updateEntity({ id, changes: entityChanges }));
store._logger.debug('Tab patched', () => ({
tabId: id,
changedFields: Object.keys(changes),
}));
},
patchTabMetadata(id: number, metadata: Record<string, unknown>) {
const currentTab = store.entityMap()[id];
if (!currentTab) {
store._logger.warn(
'Cannot patch metadata for non-existent tab',
() => ({ tabId: id }),
);
return;
}
const changes: Partial<Tab> = {
metadata: { ...currentTab.metadata, ...metadata },
};
patchState(store, updateEntity({ id, changes }));
store._logger.debug('Tab metadata patched', () => ({
tabId: id,
metadataKeys: Object.keys(metadata),
}));
},
removeTab(id: number) {
const wasActive = store.activatedTabId() === id;
patchState(store, removeEntity(id));
if (wasActive) {
patchState(store, { activatedTabId: null });
}
store._logger.info('Tab removed', () => ({ tabId: id, wasActive }));
},
navigateToLocation(
id: number,
@@ -105,14 +152,21 @@ export const TabService = signalStore(
) {
const parsed: TabLocation = TabLocationSchema.parse(location);
const currentTab = store.entityMap()[id];
if (!currentTab) return null;
if (!currentTab) {
store._logger.warn(
'Cannot navigate to location for non-existent tab',
() => ({ tabId: id }),
);
return null;
}
const currentLocation = currentTab.location;
// First, limit forward history if configured
const maxForwardHistory =
(currentTab.metadata as any)?.maxForwardHistory ??
store._config.maxForwardHistory;
typeof currentTab.metadata['maxForwardHistory'] === 'number'
? currentTab.metadata['maxForwardHistory']
: store._config.maxForwardHistory;
const { locations: limitedLocations } =
TabHistoryPruner.pruneForwardHistory(
@@ -136,14 +190,16 @@ export const TabService = signalStore(
const pruningResult = TabHistoryPruner.pruneHistory(
newLocationHistory,
store._config,
currentTab.metadata as any,
currentTab.metadata,
);
if (pruningResult.entriesRemoved > 0) {
if (store._config.logPruning) {
console.log(
`Tab ${id}: Pruned ${pruningResult.entriesRemoved} entries using ${pruningResult.strategy} strategy`,
);
store._logger.info('Tab history pruned', () => ({
tabId: id,
entriesRemoved: pruningResult.entriesRemoved,
strategy: pruningResult.strategy,
}));
}
newLocationHistory = {
@@ -160,9 +216,11 @@ export const TabService = signalStore(
);
if (wasInvalid && store._config.enableIndexValidation) {
console.warn(
`Tab ${id}: Invalid location index corrected from ${newLocationHistory.current} to ${validatedCurrent}`,
);
store._logger.warn('Invalid location index corrected', () => ({
tabId: id,
invalidIndex: newLocationHistory.current,
correctedIndex: validatedCurrent,
}));
newLocationHistory.current = validatedCurrent;
}
@@ -171,11 +229,23 @@ export const TabService = signalStore(
};
patchState(store, updateEntity({ id, changes }));
store._logger.debug('Navigated to location', () => ({
tabId: id,
url: parsed.url,
historyLength: newLocationHistory.locations.length,
currentIndex: newLocationHistory.current,
}));
return parsed;
},
navigateBack(id: number) {
const currentTab = store.entityMap()[id];
if (!currentTab) return null;
if (!currentTab) {
store._logger.warn('Cannot navigate back for non-existent tab', () => ({
tabId: id,
}));
return null;
}
const currentLocation = currentTab.location;
@@ -186,7 +256,13 @@ export const TabService = signalStore(
currentLocation.current,
);
if (validatedCurrent <= 0) return null;
if (validatedCurrent <= 0) {
store._logger.debug(
'Cannot navigate back - at beginning of history',
() => ({ tabId: id }),
);
return null;
}
const newCurrent = validatedCurrent - 1;
const previousLocation = currentLocation.locations[newCurrent];
@@ -199,11 +275,23 @@ export const TabService = signalStore(
};
patchState(store, updateEntity({ id, changes }));
store._logger.debug('Navigated back', () => ({
tabId: id,
newIndex: newCurrent,
url: previousLocation.url,
}));
return previousLocation;
},
navigateForward(id: number) {
const currentTab = store.entityMap()[id];
if (!currentTab) return null;
if (!currentTab) {
store._logger.warn(
'Cannot navigate forward for non-existent tab',
() => ({ tabId: id }),
);
return null;
}
const currentLocation = currentTab.location;
@@ -214,7 +302,13 @@ export const TabService = signalStore(
currentLocation.current,
);
if (validatedCurrent >= currentLocation.locations.length - 1) return null;
if (validatedCurrent >= currentLocation.locations.length - 1) {
store._logger.debug(
'Cannot navigate forward - at end of history',
() => ({ tabId: id }),
);
return null;
}
const newCurrent = validatedCurrent + 1;
const nextLocation = currentLocation.locations[newCurrent];
@@ -227,11 +321,25 @@ export const TabService = signalStore(
};
patchState(store, updateEntity({ id, changes }));
store._logger.debug('Navigated forward', () => ({
tabId: id,
newIndex: newCurrent,
url: nextLocation.url,
}));
return nextLocation;
},
clearLocationHistory(id: number) {
const currentTab = store.entityMap()[id];
if (!currentTab) return;
if (!currentTab) {
store._logger.warn(
'Cannot clear location history for non-existent tab',
() => ({ tabId: id }),
);
return;
}
const historyLength = currentTab.location.locations.length;
const changes: Partial<Tab> = {
location: {
@@ -240,7 +348,22 @@ export const TabService = signalStore(
},
};
patchState(store, updateEntity({ id, changes }));
store._logger.debug('Location history cleared', () => ({
tabId: id,
clearedEntries: historyLength,
}));
},
/**
* Gets the current location for a tab.
*
* IMPORTANT: This method has a side effect - if index validation is enabled
* and an invalid index is detected, it will automatically correct the index
* in the store, triggering state updates and storage autosave.
*
* @param id - The tab ID
* @returns The current location or null if tab doesn't exist or history is empty
*/
getCurrentLocation(id: number) {
const currentTab = store.entityMap()[id];
if (!currentTab) return null;
@@ -255,11 +378,16 @@ export const TabService = signalStore(
);
if (wasInvalid && store._config.enableIndexValidation) {
console.warn(
`Tab ${id}: Invalid location index corrected in getCurrentLocation from ${currentLocation.current} to ${validatedCurrent}`,
store._logger.warn(
'Invalid location index corrected in getCurrentLocation',
() => ({
tabId: id,
invalidIndex: currentLocation.current,
correctedIndex: validatedCurrent,
}),
);
// Correct the invalid index in store
// Correct the invalid index in store (SIDE EFFECT)
const changes: Partial<Tab> = {
location: {
...currentLocation,
@@ -280,13 +408,27 @@ export const TabService = signalStore(
},
updateCurrentLocation(id: number, updates: Partial<TabLocation>) {
const currentTab = store.entityMap()[id];
if (!currentTab) return null;
if (!currentTab) {
store._logger.warn(
'Cannot update current location for non-existent tab',
() => ({ tabId: id }),
);
return null;
}
const currentLocation = currentTab.location;
if (
currentLocation.current < 0 ||
currentLocation.current >= currentLocation.locations.length
) {
store._logger.warn(
'Cannot update current location - invalid index',
() => ({
tabId: id,
currentIndex: currentLocation.current,
historyLength: currentLocation.locations.length,
}),
);
return null;
}
@@ -306,6 +448,12 @@ export const TabService = signalStore(
};
patchState(store, updateEntity({ id, changes }));
store._logger.debug('Current location updated', () => ({
tabId: id,
updatedFields: Object.keys(updates),
url: updatedLocation.url,
}));
return updatedLocation;
},
})),

View File

@@ -0,0 +1,31 @@
import { inject, Injectable } from '@angular/core';
import { CrmSearchService } from '../services/crm-search.service';
import { AddBookingInput } from '../schemas';
import {
KeyValueDTOOfStringAndInteger,
KeyValueDTOOfStringAndString,
LoyaltyBookingInfoDTO,
} from '@generated/swagger/crm-api';
@Injectable({ providedIn: 'root' })
export class CustomerCardBookingFacade {
#crmSearchService = inject(CrmSearchService);
async fetchBookingReasons(
abortSignal?: AbortSignal,
): Promise<KeyValueDTOOfStringAndInteger[]> {
return this.#crmSearchService.fetchBookingReasons(abortSignal);
}
async fetchCurrentBookingPartnerStore(
abortSignal?: AbortSignal,
): Promise<KeyValueDTOOfStringAndString | undefined> {
return this.#crmSearchService.fetchCurrentBookingPartnerStore(abortSignal);
}
async addBooking(
params: AddBookingInput,
): Promise<LoyaltyBookingInfoDTO | undefined> {
return this.#crmSearchService.addBooking(params);
}
}

View File

@@ -1,2 +1,3 @@
export * from './customer-cards.facade';
export * from './customer.facade';
export * from './customer-card-booking.facade';

View File

@@ -0,0 +1,89 @@
import { Injectable, inject, resource, signal, computed } from '@angular/core';
import { logger } from '@isa/core/logging';
import { CrmSearchService } from '../services/crm-search.service';
import { BonusCardInfo } from '../models';
/**
* Resource for loading customer bonus cards (Kundenkarten).
*
* Provides reactive loading of all bonus cards for a given customer ID.
* Customer ID can be changed dynamically via `params()` method.
*
* **Note:** This resource should be provided at the component level,
* not in root. Provide it in the `providers` array of the component
* that needs scoped access to customer bonus cards.
*
* @example
* ```typescript
* @Component({
* providers: [CustomerBonusCardsResource],
* })
* export class MyFeatureComponent {
* #bonusCardsResource = inject(CustomerBonusCardsResource);
*
* cards = this.#bonusCardsResource.resource.value;
* isLoading = this.#bonusCardsResource.resource.isLoading;
*
* loadCards(customerId: number) {
* this.#bonusCardsResource.params({ customerId });
* }
* }
* ```
*/
@Injectable()
export class CustomerBonusCardsResource {
readonly #crmSearchService = inject(CrmSearchService);
readonly #logger = logger(() => ({ context: 'CustomerBonusCardsResource' }));
readonly #customerId = signal<number | undefined>(undefined);
/**
* Resource that loads bonus cards based on current parameters.
*
* Exposes:
* - `value()` - Array of bonus cards or undefined
* - `isLoading()` - Loading state
* - `error()` - Error state
* - `status()` - Current status ('idle' | 'loading' | 'resolved' | 'error')
*/
readonly resource = resource({
params: computed(() => ({ customerId: this.#customerId() })),
loader: async ({
params,
abortSignal,
}): Promise<BonusCardInfo[] | undefined> => {
const { customerId } = params;
if (!customerId) {
this.#logger.debug('No customerId provided, skipping load');
return undefined;
}
this.#logger.debug('Loading bonus cards', () => ({ customerId }));
const response = await this.#crmSearchService.fetchCustomerCards(
{ customerId },
abortSignal,
);
this.#logger.debug('Bonus cards loaded', () => ({
customerId,
count: response?.result?.length ?? 0,
}));
return response?.result;
},
defaultValue: undefined,
});
/**
* Update resource parameters to trigger a reload.
*
* @param params - Parameters for loading bonus cards
* @param params.customerId - Customer ID to load cards for (undefined clears data)
*/
params(params: { customerId?: number }): void {
this.#logger.debug('Updating params', () => params);
this.#customerId.set(params.customerId);
}
}

View File

@@ -0,0 +1,29 @@
import { Injectable, inject, resource } from '@angular/core';
import { logger } from '@isa/core/logging';
import { CrmSearchService } from '@isa/crm/data-access';
import { KeyValueDTOOfStringAndInteger } from '@generated/swagger/crm-api';
@Injectable()
export class CustomerBookingReasonsResource {
readonly #crmSearchService = inject(CrmSearchService);
readonly #logger = logger(() => ({
context: 'CustomerBookingReasonsResource',
}));
readonly resource = resource({
loader: async ({
abortSignal,
}): Promise<KeyValueDTOOfStringAndInteger[] | undefined> => {
this.#logger.debug('Loading Booking Reasons');
const reasons =
await this.#crmSearchService.fetchBookingReasons(abortSignal);
this.#logger.debug('Booking Reasons loaded', () => ({
count: reasons.length,
}));
return reasons;
},
});
}

View File

@@ -0,0 +1,90 @@
import { Injectable, inject, resource, signal, computed } from '@angular/core';
import { logger } from '@isa/core/logging';
import { CrmSearchService } from '@isa/crm/data-access';
import { LoyaltyBookingInfoDTO } from '@generated/swagger/crm-api';
/**
* Resource for loading customer loyalty card transactions.
*
* Provides reactive loading of the last 5 transactions for a given card code.
* Card code can be changed dynamically via `params()` method.
*
* **Note:** This resource should be provided at the component level,
* not in root. Provide it in the `providers` array of the component
* that needs scoped access to transactions.
*
* @example
* ```typescript
* export class MyFeatureComponent {
* #transactionsResource = inject(CustomerCardTransactionsResource);
*
* transactions = this.#transactionsResource.resource.value;
* isLoading = this.#transactionsResource.resource.isLoading;
*
* loadTransactions(cardCode: string) {
* this.#transactionsResource.params({ cardCode });
* }
* }
* ```
*/
@Injectable({ providedIn: 'root' })
export class CustomerCardTransactionsResource {
readonly #crmSearchService = inject(CrmSearchService);
readonly #logger = logger(() => ({
context: 'CustomerCardTransactionsResource',
}));
readonly #cardCode = signal<string | undefined>(undefined);
/**
* Resource that loads transactions based on current parameters.
*
* Exposes:
* - `value()` - Array of transactions or undefined
* - `isLoading()` - Loading state
* - `error()` - Error state
* - `status()` - Current status ('idle' | 'loading' | 'resolved' | 'error')
*/
readonly resource = resource({
params: computed(() => ({ cardCode: this.#cardCode() })),
loader: async ({
params,
abortSignal,
}): Promise<LoyaltyBookingInfoDTO[] | undefined> => {
const { cardCode } = params;
if (!cardCode) {
this.#logger.debug('No cardCode provided, skipping load');
return undefined;
}
this.#logger.debug('Loading loyalty card transactions', () => ({
cardCode,
}));
const transactions = await this.#crmSearchService.fetchLoyaltyBookings(
cardCode,
abortSignal,
);
this.#logger.debug('Transactions loaded', () => ({
cardCode,
count: transactions.length,
}));
return transactions;
},
defaultValue: undefined,
});
/**
* Update resource parameters to trigger a reload.
*
* @param params - Parameters for loading transactions
* @param params.cardCode - Card code to load transactions for (undefined clears data)
*/
params(params: { cardCode?: string }): void {
this.#logger.debug('Updating params', () => params);
this.#cardCode.set(params.cardCode);
}
}

View File

@@ -0,0 +1,54 @@
import { effect, inject, Injectable, resource, signal } from '@angular/core';
import { CrmTabMetadataService, PayerService } from '../services';
import { TabService } from '@isa/core/tabs';
import { CrmPayer } from '../schemas';
@Injectable()
export class CustomerPayerAddressResource {
#payerService = inject(PayerService);
#params = signal<{
payerId: number | undefined;
}>({
payerId: undefined,
});
params(params: { payerId?: number }) {
this.#params.update((p) => ({ ...p, ...params }));
}
readonly resource = resource({
params: () => this.#params(),
loader: async ({ params, abortSignal }): Promise<CrmPayer | undefined> => {
if (!params.payerId) {
return undefined;
}
const res = await this.#payerService.fetchPayer(
{
payerId: params.payerId,
},
abortSignal,
);
return res.result as CrmPayer;
},
});
}
@Injectable({ providedIn: 'root' })
export class SelectedCustomerPayerAddressResource extends CustomerPayerAddressResource {
#tabId = inject(TabService).activatedTabId;
#customerMetadata = inject(CrmTabMetadataService);
constructor() {
super();
effect(() => {
const tabId = this.#tabId();
const payerId = tabId
? this.#customerMetadata.selectedPayerId(tabId)
: undefined;
this.params({ payerId });
});
}
}

View File

@@ -1,5 +1,10 @@
export * from './country.resource';
export * from './customer-bonus-cards.resource';
export * from './customer-card-transactions.resource';
export * from './customer-payer-address.resource';
export * from './primary-customer-card.resource';
export * from './customer-shipping-address.resource';
export * from './customer-shipping-addresses.resource';
export * from './customer.resource';
export * from './payer.resource';
export * from './customer-booking-reasons.resource';

View File

@@ -0,0 +1,52 @@
import { effect, inject, Injectable, resource, signal } from '@angular/core';
import { CrmTabMetadataService } from '../services';
import { TabService } from '@isa/core/tabs';
import { CrmCustomerService } from '@domain/crm';
import { PayerDTO } from '@generated/swagger/crm-api';
@Injectable()
export class PayerResource {
#customerService = inject(CrmCustomerService);
#params = signal<{
payerId: number | undefined;
}>({
payerId: undefined,
});
params(params: { payerId?: number }) {
this.#params.update((p) => ({ ...p, ...params }));
}
readonly resource = resource({
params: () => this.#params(),
loader: async ({ params, abortSignal }): Promise<PayerDTO | undefined> => {
if (!params.payerId) {
return undefined;
}
const res = await this.#customerService
.getPayer(params.payerId)
.toPromise();
return res?.result;
},
});
}
@Injectable({ providedIn: 'root' })
export class SelectedPayerResource extends PayerResource {
#tabId = inject(TabService).activatedTabId;
#customerMetadata = inject(CrmTabMetadataService);
constructor() {
super();
effect(() => {
const tabId = this.#tabId();
const payerId = tabId
? this.#customerMetadata.selectedPayerId(tabId)
: undefined;
this.params({ payerId });
});
}
}

View File

@@ -0,0 +1,18 @@
import { z } from 'zod';
export const AddBookingSchema = z.object({
cardCode: z.string().describe('Unique card code identifier'),
booking: z
.object({
points: z.number().describe('Booking points'),
reason: z.string().optional().describe('Booking Reason'),
storeId: z
.string()
.optional()
.describe('Booking store (convercus store id)'),
})
.describe('Booking details'),
});
export type AddBooking = z.infer<typeof AddBookingSchema>;
export type AddBookingInput = z.input<typeof AddBookingSchema>;

View File

@@ -91,6 +91,7 @@ export const CustomerSchema = z
.describe('User information')
.optional(),
})
.extend(EntitySchema.shape);
.extend(EntitySchema.shape)
.describe('Customer');
export type Customer = z.infer<typeof CustomerSchema>;

View File

@@ -0,0 +1,8 @@
import { z } from 'zod';
export const FetchPayerSchema = z.object({
payerId: z.number().int().describe('Payer identifier'),
});
export type FetchPayer = z.infer<typeof FetchPayerSchema>;
export type FetchPayerInput = z.input<typeof FetchPayerSchema>;

View File

@@ -8,6 +8,7 @@ export * from './customer-feature-groups.schema';
export * from './fetch-customer-cards.schema';
export * from './fetch-customer-shipping-addresses.schema';
export * from './fetch-customer.schema';
export * from './fetch-payer.schema';
export * from './fetch-shipping-address.schema';
export * from './linked-record.schema';
export * from './notification-channel.schema';
@@ -16,3 +17,4 @@ export * from './payer.schema';
export * from './payment-settings.schema';
export * from './shipping-address.schema';
export * from './user.schema';
export * from './add-booking.schema';

View File

@@ -16,23 +16,43 @@ export const PayerSchema = z
.object({
address: AddressSchema.describe('Address').optional(),
agentComment: z.string().describe('Agent comment').optional(),
communicationDetails: CommunicationDetailsSchema.describe('Communication details').optional(),
communicationDetails: CommunicationDetailsSchema.describe(
'Communication details',
).optional(),
deactivationComment: z.string().describe('Deactivation comment').optional(),
defaultPaymentPeriod: z.number().describe('Default payment period').optional(),
defaultPaymentPeriod: z
.number()
.describe('Default payment period')
.optional(),
firstName: z.string().describe('First name').optional(),
gender: GenderSchema.describe('Gender').optional(),
isGuestAccount: z.boolean().describe('Whether guestAccount').optional(),
label: EntityContainerSchema(LabelSchema).describe('Label').optional(),
lastName: z.string().describe('Last name').optional(),
organisation: OrganisationSchema.describe('Organisation information').optional(),
organisation: OrganisationSchema.describe(
'Organisation information',
).optional(),
payerGroup: z.string().describe('Payer group').optional(),
payerNumber: z.string().describe('Unique payer account number').optional(),
payerStatus: PayerStatusSchema.describe('Current status of the payer account').optional(),
payerStatus: PayerStatusSchema.describe(
'Current status of the payer account',
).optional(),
payerType: z.nativeEnum(PayerType).describe('Payer type').optional(),
paymentTypes: z.array(PaymentSettingsSchema).describe('Payment types').optional(),
standardInvoiceText: z.string().describe('Standard invoice text').optional(),
statusChangeComment: z.string().describe('Status change comment').optional(),
paymentTypes: z
.array(PaymentSettingsSchema)
.describe('Payment types')
.optional(),
standardInvoiceText: z
.string()
.describe('Standard invoice text')
.optional(),
statusChangeComment: z
.string()
.describe('Status change comment')
.optional(),
statusComment: z.string().describe('Status comment').optional(),
title: z.string().describe('Title').optional(),
})
.extend(EntitySchema.shape);
export type CrmPayer = z.infer<typeof PayerSchema>;

View File

@@ -1,6 +1,15 @@
import { inject, Injectable } from '@angular/core';
import { CustomerService } from '@generated/swagger/crm-api';
import {
CustomerService,
LoyaltyCardService,
LoyaltyBookingInfoDTO,
KeyValueDTOOfStringAndString,
KeyValueDTOOfStringAndInteger,
} from '@generated/swagger/crm-api';
import {
AddBooking,
AddBookingInput,
AddBookingSchema,
Customer,
FetchCustomerCardsInput,
FetchCustomerCardsSchema,
@@ -10,6 +19,7 @@ import {
import {
catchResponseArgsErrorPipe,
ResponseArgs,
ResponseArgsError,
takeUntilAborted,
} from '@isa/common/data-access';
import { firstValueFrom } from 'rxjs';
@@ -19,6 +29,7 @@ import { logger } from '@isa/core/logging';
@Injectable({ providedIn: 'root' })
export class CrmSearchService {
#customerService = inject(CustomerService);
#loyaltyCardService = inject(LoyaltyCardService);
#logger = logger(() => ({
service: 'CrmSearchService',
}));
@@ -72,4 +83,104 @@ export class CrmSearchService {
return [] as unknown as ResponseArgs<BonusCardInfo[]>;
}
}
async fetchLoyaltyBookings(
cardCode: string,
abortSignal?: AbortSignal,
): Promise<LoyaltyBookingInfoDTO[]> {
this.#logger.info('Fetching loyalty bookings from API');
let req$ = this.#loyaltyCardService
.LoyaltyCardListBookings({ cardCode })
.pipe(catchResponseArgsErrorPipe());
if (abortSignal) {
req$ = req$.pipe(takeUntilAborted(abortSignal));
}
try {
const res = await firstValueFrom(req$);
this.#logger.debug('Successfully fetched loyalty bookings');
const transactions = res?.result?.data || [];
return transactions;
} catch (error) {
this.#logger.error('Error fetching loyalty bookings', error);
return [];
}
}
async fetchBookingReasons(
abortSignal?: AbortSignal,
): Promise<KeyValueDTOOfStringAndInteger[]> {
this.#logger.info('Fetching booking reasons from API');
let req$ = this.#loyaltyCardService
.LoyaltyCardBookingReason()
.pipe(catchResponseArgsErrorPipe());
if (abortSignal) {
req$ = req$.pipe(takeUntilAborted(abortSignal));
}
try {
const res = await firstValueFrom(req$);
this.#logger.debug('Successfully fetched booking reasons');
return res?.result || [];
} catch (error) {
this.#logger.error('Error fetching booking reasons', error);
return [];
}
}
async fetchCurrentBookingPartnerStore(
abortSignal?: AbortSignal,
): Promise<KeyValueDTOOfStringAndString | undefined> {
this.#logger.info('Fetching current booking partner store from API');
let req$ = this.#loyaltyCardService
.LoyaltyCardCurrentBookingPartnerStore()
.pipe(catchResponseArgsErrorPipe());
if (abortSignal) {
req$ = req$.pipe(takeUntilAborted(abortSignal));
}
try {
const res = await firstValueFrom(req$);
this.#logger.debug('Successfully fetched current booking partner store');
return res?.result;
} catch (error) {
this.#logger.error('Error fetching current booking partner store', error);
return undefined;
}
}
async addBooking(
params: AddBookingInput,
): Promise<LoyaltyBookingInfoDTO | undefined> {
const parsed = AddBookingSchema.parse(params);
const req$ = this.#loyaltyCardService.LoyaltyCardAddBooking({
cardCode: parsed.cardCode,
booking: {
points: parsed.booking.points,
reason: parsed.booking.reason,
storeId: parsed.booking.storeId,
},
});
const res = await firstValueFrom(req$);
if (res.error) {
const err = new ResponseArgsError(res);
this.#logger.error('Add Booking Failed', err);
throw err;
}
return res?.result;
}
}

View File

@@ -1,4 +1,5 @@
export * from './country.service';
export * from './crm-search.service';
export * from './crm-tab-metadata.service';
export * from './payer.service';
export * from './shipping-address.service';

View File

@@ -0,0 +1,43 @@
import { inject, Injectable } from '@angular/core';
import { PayerService as GeneratedPayerService } from '@generated/swagger/crm-api';
import {
catchResponseArgsErrorPipe,
ResponseArgs,
takeUntilAborted,
} from '@isa/common/data-access';
import { firstValueFrom } from 'rxjs';
import { logger } from '@isa/core/logging';
import { FetchPayerInput, FetchPayerSchema, CrmPayer } from '../schemas';
@Injectable({ providedIn: 'root' })
export class PayerService {
#payerService = inject(GeneratedPayerService);
#logger = logger(() => ({
service: 'PayerService',
}));
async fetchPayer(
params: FetchPayerInput,
abortSignal?: AbortSignal,
): Promise<ResponseArgs<CrmPayer>> {
this.#logger.info('Fetching payer from API');
const { payerId } = FetchPayerSchema.parse(params);
let req$ = this.#payerService
.PayerGetPayer(payerId)
.pipe(catchResponseArgsErrorPipe());
if (abortSignal) {
req$ = req$.pipe(takeUntilAborted(abortSignal));
}
try {
const res = await firstValueFrom(req$);
this.#logger.debug('Successfully fetched payer');
return res as ResponseArgs<CrmPayer>;
} catch (error) {
this.#logger.error('Error fetching payer', error);
return undefined as unknown as ResponseArgs<CrmPayer>;
}
}
}

View File

@@ -0,0 +1,7 @@
# crm-feature-customer-booking
This library was generated with [Nx](https://nx.dev).
## Running unit tests
Run `nx test crm-feature-customer-booking` to execute the unit tests.

Some files were not shown because too many files have changed in this diff Show More