mirror of
https://dev.azure.com/hugendubel/ISA/_git/ISA-Frontend
synced 2025-12-28 22:42:11 +01:00
Compare commits
34 Commits
fix/5411-R
...
feature/53
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
50363790c1 | ||
|
|
65491fb0d4 | ||
|
|
71af23544f | ||
|
|
1f2eff8615 | ||
|
|
442707774b | ||
|
|
e654a4d95e | ||
|
|
5057d56532 | ||
|
|
70ded96858 | ||
|
|
7c2c72745f | ||
|
|
2ea76b6796 | ||
|
|
83292836a3 | ||
|
|
212203fb04 | ||
|
|
b89cf57a8d | ||
|
|
b70f2798df | ||
|
|
0066e8baa1 | ||
|
|
999f61fcc0 | ||
|
|
b827a6f0a0 | ||
|
|
29b6091a30 | ||
|
|
989294cc90 | ||
|
|
c643d988fa | ||
|
|
463e46e17a | ||
|
|
c98d5666a4 | ||
|
|
835546a799 | ||
|
|
f261fc9987 | ||
|
|
cc186dbbe2 | ||
|
|
6df02d9e86 | ||
|
|
4a7b74a6c5 | ||
|
|
9c989055cb | ||
|
|
2e0853c91a | ||
|
|
c5ea5ed3ec | ||
|
|
7c29429040 | ||
|
|
c3e9a03169 | ||
|
|
3c13a230cc | ||
|
|
0a5b1dac71 |
44
.mcp.json
44
.mcp.json
@@ -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
13
AGENTS.md
Normal 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-->
|
||||
30
CLAUDE.md
30
CLAUDE.md
@@ -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-->
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)';
|
||||
@@ -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' },
|
||||
],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) });
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
/* tslint:disable */
|
||||
export interface AccountBalanceDTO {
|
||||
lockedPoints: number;
|
||||
points: number;
|
||||
}
|
||||
14
generated/swagger/crm-api/src/models/account-details-dto.ts
Normal file
14
generated/swagger/crm-api/src/models/account-details-dto.ts
Normal 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;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/* tslint:disable */
|
||||
export interface AddLoyaltyCardValues {
|
||||
|
||||
/**
|
||||
* Card code
|
||||
*/
|
||||
cardCode?: string;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
/* tslint:disable */
|
||||
export interface CustomPropertyDTO {
|
||||
name?: string;
|
||||
value?: string;
|
||||
}
|
||||
8
generated/swagger/crm-api/src/models/identifier-dto.ts
Normal file
8
generated/swagger/crm-api/src/models/identifier-dto.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/* tslint:disable */
|
||||
export interface IdentifierDTO {
|
||||
code?: string;
|
||||
displayCode?: string;
|
||||
identifierId?: string;
|
||||
status?: string;
|
||||
type?: string;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
5
generated/swagger/crm-api/src/models/optin-dto.ts
Normal file
5
generated/swagger/crm-api/src/models/optin-dto.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
/* tslint:disable */
|
||||
export interface OptinDTO {
|
||||
flag: boolean;
|
||||
type?: string;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
/* tslint:disable */
|
||||
import { ResponseArgs } from './response-args';
|
||||
import { AccountDetailsDTO } from './account-details-dto';
|
||||
export interface ResponseArgsOfAccountDetailsDTO extends ResponseArgs{
|
||||
result?: AccountDetailsDTO;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
/* tslint:disable */
|
||||
import { ResponseArgs } from './response-args';
|
||||
export interface ResponseArgsOfIEnumerableOfString extends ResponseArgs{
|
||||
result?: Array<string>;
|
||||
}
|
||||
10
generated/swagger/crm-api/src/models/state-level-dto.ts
Normal file
10
generated/swagger/crm-api/src/models/state-level-dto.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
/* tslint:disable */
|
||||
export interface StateLevelDTO {
|
||||
currentStatePoints?: number;
|
||||
name?: string;
|
||||
neededStatePoints?: number;
|
||||
neededStatePointsNextLevel?: number;
|
||||
requiredPointsToMaintainLevel?: number;
|
||||
requiredPointsToReachNextLevel?: number;
|
||||
validTo?: string;
|
||||
}
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
);
|
||||
};
|
||||
@@ -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';
|
||||
|
||||
@@ -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(),
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
type="button"
|
||||
color="subtle"
|
||||
size="small"
|
||||
(click)="resetCustomerAndCart()"
|
||||
(click)="resetCustomerAndRewardCart()"
|
||||
>
|
||||
Zurücksetzen
|
||||
</ui-text-button>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
.confirmation-list-item-done {
|
||||
@apply bg-transparent p-0;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
816
libs/core/auth/README.md
Normal 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
|
||||
34
libs/core/auth/eslint.config.cjs
Normal file
34
libs/core/auth/eslint.config.cjs
Normal 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: {},
|
||||
},
|
||||
];
|
||||
20
libs/core/auth/project.json
Normal file
20
libs/core/auth/project.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
10
libs/core/auth/src/index.ts
Normal file
10
libs/core/auth/src/index.ts
Normal 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';
|
||||
157
libs/core/auth/src/lib/if-role.directive.spec.ts
Normal file
157
libs/core/auth/src/lib/if-role.directive.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
105
libs/core/auth/src/lib/if-role.directive.ts
Normal file
105
libs/core/auth/src/lib/if-role.directive.ts
Normal 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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
95
libs/core/auth/src/lib/role.service.spec.ts
Normal file
95
libs/core/auth/src/lib/role.service.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
71
libs/core/auth/src/lib/role.service.ts
Normal file
71
libs/core/auth/src/lib/role.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
13
libs/core/auth/src/lib/role.ts
Normal file
13
libs/core/auth/src/lib/role.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export const Role = {
|
||||
/**
|
||||
* HSC
|
||||
*/
|
||||
CallCenter: 'CallCenter',
|
||||
|
||||
/**
|
||||
* Filiale
|
||||
*/
|
||||
Store: 'Store',
|
||||
} as const;
|
||||
|
||||
export type Role = (typeof Role)[keyof typeof Role];
|
||||
67
libs/core/auth/src/lib/token-provider.ts
Normal file
67
libs/core/auth/src/lib/token-provider.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
);
|
||||
13
libs/core/auth/src/test-setup.ts
Normal file
13
libs/core/auth/src/test-setup.ts
Normal 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(),
|
||||
);
|
||||
30
libs/core/auth/tsconfig.json
Normal file
30
libs/core/auth/tsconfig.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
27
libs/core/auth/tsconfig.lib.json
Normal file
27
libs/core/auth/tsconfig.lib.json
Normal 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"]
|
||||
}
|
||||
29
libs/core/auth/tsconfig.spec.json
Normal file
29
libs/core/auth/tsconfig.spec.json
Normal 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"]
|
||||
}
|
||||
33
libs/core/auth/vite.config.mts
Normal file
33
libs/core/auth/vite.config.mts
Normal 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'],
|
||||
},
|
||||
},
|
||||
}));
|
||||
@@ -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);
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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';
|
||||
|
||||
31
libs/core/tabs/src/lib/has-tab-id.guard.ts
Normal file
31
libs/core/tabs/src/lib/has-tab-id.guard.ts
Normal 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');
|
||||
};
|
||||
71
libs/core/tabs/src/lib/helpers.spec.ts
Normal file
71
libs/core/tabs/src/lib/helpers.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
195
libs/core/tabs/src/lib/tab-cleanup.guard.ts
Normal file
195
libs/core/tabs/src/lib/tab-cleanup.guard.ts
Normal 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
|
||||
}
|
||||
};
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
})),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from './customer-cards.facade';
|
||||
export * from './customer.facade';
|
||||
export * from './customer-card-booking.facade';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
52
libs/crm/data-access/src/lib/resources/payer.resource.ts
Normal file
52
libs/crm/data-access/src/lib/resources/payer.resource.ts
Normal 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 });
|
||||
});
|
||||
}
|
||||
}
|
||||
18
libs/crm/data-access/src/lib/schemas/add-booking.schema.ts
Normal file
18
libs/crm/data-access/src/lib/schemas/add-booking.schema.ts
Normal 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>;
|
||||
@@ -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>;
|
||||
|
||||
@@ -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>;
|
||||
@@ -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';
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
43
libs/crm/data-access/src/lib/services/payer.service.ts
Normal file
43
libs/crm/data-access/src/lib/services/payer.service.ts
Normal 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>;
|
||||
}
|
||||
}
|
||||
}
|
||||
7
libs/crm/feature/customer-booking/README.md
Normal file
7
libs/crm/feature/customer-booking/README.md
Normal 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
Reference in New Issue
Block a user