Merged PR 1991: feat(navigation): implement title management and enhance tab system

 feat(navigation): implement title management and enhance tab system

This commit introduces a comprehensive title management system and extends
the tab functionality with subtitle support, improving navigation clarity
and user experience across the application.

Key changes:

Title Management System:
- Add @isa/common/title-management library with dual approach:
  - IsaTitleStrategy for route-based static titles
  - usePageTitle() for component-based dynamic titles
- Implement TitleRegistryService for nested component hierarchies
- Automatic ISA prefix addition and TabService integration
- Comprehensive test coverage (1,158 lines of tests)

Tab System Enhancement:
- Add subtitle field to tab schema for additional context
- Update TabService API (addTab, patchTab) to support subtitles
- Extend Zod schemas with subtitle validation
- Update documentation with usage examples

Routing Modernization:
- Consolidate route guards using ActivateProcessIdWithConfigKeyGuard
- Replace 4+ specific guards with generic config-key-based approach
- Add title attributes to 100+ routes across all modules
- Remove deprecated ProcessIdGuard in favor of ActivateProcessIdGuard

Code Cleanup:
- Remove deprecated preview component and related routes
- Clean up unused imports and exports
- Update TypeScript path aliases

Dependencies:
- Update package.json and package-lock.json
- Add @isa/common/title-management to tsconfig path mappings

Refs: #5351, #5418, #5419, #5420
This commit is contained in:
Lorenz Hilpert
2025-12-02 12:38:28 +00:00
committed by Nino Righi
parent 0670dbfdb1
commit 68f50b911d
51 changed files with 3642 additions and 1148 deletions

View File

@@ -1,4 +1,4 @@
import { isDevMode, NgModule } from '@angular/core';
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import {
CanActivateCartGuard,
@@ -7,23 +7,17 @@ import {
CanActivateCustomerOrdersGuard,
CanActivateCustomerOrdersWithProcessIdGuard,
CanActivateCustomerWithProcessIdGuard,
CanActivateGoodsInGuard,
CanActivateProductGuard,
CanActivateProductWithProcessIdGuard,
CanActivateTaskCalendarGuard,
IsAuthenticatedGuard,
} from './guards';
import { CanActivateAssortmentGuard } from './guards/can-activate-assortment.guard';
import { CanActivatePackageInspectionGuard } from './guards/can-activate-package-inspection.guard';
import { MainComponent } from './main.component';
import { PreviewComponent } from './preview';
import {
BranchSectionResolver,
CustomerSectionResolver,
ProcessIdResolver,
} from './resolvers';
import { TokenLoginComponent, TokenLoginModule } from './token-login';
import { ProcessIdGuard } from './guards/process-id.guard';
import {
ActivateProcessIdGuard,
ActivateProcessIdWithConfigKeyGuard,
@@ -74,8 +68,12 @@ const routes: Routes = [
loadChildren: () =>
import('@page/catalog').then((m) => m.PageCatalogModule),
canActivate: [CanActivateProductWithProcessIdGuard],
resolve: { processId: ProcessIdResolver },
resolve: {
processId: ProcessIdResolver,
},
},
// TODO: Check if order and :processId/order is still being used
// If not, remove these routes and the related guards and resolvers
{
path: 'order',
loadChildren: () =>
@@ -87,7 +85,9 @@ const routes: Routes = [
loadChildren: () =>
import('@page/customer-order').then((m) => m.CustomerOrderModule),
canActivate: [CanActivateCustomerOrdersWithProcessIdGuard],
resolve: { processId: ProcessIdResolver },
resolve: {
processId: ProcessIdResolver,
},
},
{
path: 'customer',
@@ -100,7 +100,9 @@ const routes: Routes = [
loadChildren: () =>
import('@page/customer').then((m) => m.CustomerModule),
canActivate: [CanActivateCustomerWithProcessIdGuard],
resolve: { processId: ProcessIdResolver },
resolve: {
processId: ProcessIdResolver,
},
},
{
path: 'cart',
@@ -113,10 +115,13 @@ const routes: Routes = [
loadChildren: () =>
import('@page/checkout').then((m) => m.PageCheckoutModule),
canActivate: [CanActivateCartWithProcessIdGuard],
resolve: {
processId: ProcessIdResolver,
},
},
{
path: 'pickup-shelf',
canActivate: [ProcessIdGuard],
canActivate: [ActivateProcessIdGuard],
// NOTE: This is a workaround for the canActivate guard not being called
loadChildren: () =>
import('@page/pickup-shelf').then((m) => m.PickupShelfOutModule),
@@ -126,6 +131,9 @@ const routes: Routes = [
canActivate: [ActivateProcessIdGuard],
loadChildren: () =>
import('@page/pickup-shelf').then((m) => m.PickupShelfOutModule),
resolve: {
processId: ProcessIdResolver,
},
},
{ path: '**', redirectTo: 'dashboard', pathMatch: 'full' },
],
@@ -141,7 +149,7 @@ const routes: Routes = [
import('@page/task-calendar').then(
(m) => m.PageTaskCalendarModule,
),
canActivate: [CanActivateTaskCalendarGuard],
canActivate: [ActivateProcessIdWithConfigKeyGuard('taskCalendar')],
},
{
path: 'pickup-shelf',
@@ -154,27 +162,23 @@ const routes: Routes = [
path: 'goods/in',
loadChildren: () =>
import('@page/goods-in').then((m) => m.GoodsInModule),
canActivate: [CanActivateGoodsInGuard],
canActivate: [ActivateProcessIdWithConfigKeyGuard('goodsIn')],
},
// {
// path: 'remission',
// loadChildren: () =>
// import('@page/remission').then((m) => m.PageRemissionModule),
// canActivate: [CanActivateRemissionGuard],
// },
{
path: 'package-inspection',
loadChildren: () =>
import('@page/package-inspection').then(
(m) => m.PackageInspectionModule,
),
canActivate: [CanActivatePackageInspectionGuard],
canActivate: [
ActivateProcessIdWithConfigKeyGuard('packageInspection'),
],
},
{
path: 'assortment',
loadChildren: () =>
import('@page/assortment').then((m) => m.AssortmentModule),
canActivate: [CanActivateAssortmentGuard],
canActivate: [ActivateProcessIdWithConfigKeyGuard('assortment')],
},
{ path: '**', redirectTo: 'task-calendar', pathMatch: 'full' },
],
@@ -243,13 +247,6 @@ const routes: Routes = [
},
];
if (isDevMode()) {
routes.unshift({
path: 'preview',
component: PreviewComponent,
});
}
@NgModule({
imports: [
RouterModule.forRoot(routes, {

View File

@@ -1,4 +1,5 @@
import { version } from '../../../../package.json';
import { IsaTitleStrategy } from '@isa/common/title-management';
import {
HTTP_INTERCEPTORS,
provideHttpClient,
@@ -56,7 +57,6 @@ import {
ScanditScanAdapterModule,
} from '@adapter/scan';
import * as Commands from './commands';
import { PreviewComponent } from './preview';
import { NativeContainerService } from '@external/native-container';
import { ShellModule } from '@shared/shell';
import { MainComponent } from './main.component';
@@ -87,6 +87,7 @@ import {
import { Store } from '@ngrx/store';
import { OAuthService } from 'angular-oauth2-oidc';
import z from 'zod';
import { TitleStrategy } from '@angular/router';
import { TabNavigationService } from '@isa/core/tabs';
registerLocaleData(localeDe, localeDeExtra);
@@ -270,7 +271,6 @@ const USER_SUB_FACTORY = () => {
CoreCommandModule.forRoot(Object.values(Commands)),
CoreLoggerModule.forRoot(),
AppStoreModule,
PreviewComponent,
AuthModule.forRoot(),
CoreApplicationModule.forRoot(),
UiModalModule.forRoot(),
@@ -330,6 +330,7 @@ const USER_SUB_FACTORY = () => {
useValue: 'EUR',
},
provideUserSubFactory(USER_SUB_FACTORY),
{ provide: TitleStrategy, useClass: IsaTitleStrategy },
],
})
export class AppModule {}

View File

@@ -1,47 +1,82 @@
import { inject } from '@angular/core';
import { ActivatedRouteSnapshot, CanActivateFn, RouterStateSnapshot } from '@angular/router';
import { ApplicationService } from '@core/application';
import { Config } from '@core/config';
import { take } from 'rxjs/operators';
export const ActivateProcessIdGuard: CanActivateFn = async (
route: ActivatedRouteSnapshot,
state: RouterStateSnapshot,
) => {
const application = inject(ApplicationService);
const processIdStr = route.params.processId;
if (!processIdStr) {
return false;
}
const processId = Number(processIdStr);
// Check if Process already exists
const process = await application.getProcessById$(processId).pipe(take(1)).toPromise();
if (!process) {
application.createCustomerProcess(processId);
}
application.activateProcess(processId);
return true;
};
export const ActivateProcessIdWithConfigKeyGuard: (key: string) => CanActivateFn =
(key) => async (route: ActivatedRouteSnapshot, state: RouterStateSnapshot) => {
const application = inject(ApplicationService);
const config = inject(Config);
const processId = config.get(`process.ids.${key}`);
if (isNaN(processId)) {
return false;
}
application.activateProcess(processId);
return true;
};
import { inject } from '@angular/core';
import {
ActivatedRouteSnapshot,
CanActivateFn,
RouterStateSnapshot,
} from '@angular/router';
import { ApplicationService } from '@core/application';
import { Config } from '@core/config';
import { take } from 'rxjs/operators';
import z from 'zod';
export const ActivateProcessIdGuard: CanActivateFn = async (
route: ActivatedRouteSnapshot,
state: RouterStateSnapshot,
) => {
const application = inject(ApplicationService);
const processIdStr = route.params.processId;
if (!processIdStr) {
return false;
}
const processId = Number(processIdStr);
// Check if Process already exists
const process = await application
.getProcessById$(processId)
.pipe(take(1))
.toPromise();
if (!process) {
application.createProcess({
id: processId,
type: 'cart',
section: 'customer',
name: `Vorgang ${processId}`,
});
}
application.activateProcess(processId);
return true;
};
export const ActivateProcessIdWithConfigKeyGuard: (
key: string,
) => CanActivateFn =
(key) =>
async (route: ActivatedRouteSnapshot, state: RouterStateSnapshot) => {
const application = inject(ApplicationService);
const config = inject(Config);
const processId = config.get(`process.ids.${key}`, z.coerce.number());
if (typeof processId !== 'number' || isNaN(processId)) {
return false;
}
const processTitle = config.get(
`process.titles.${key}`,
z.string().default(key),
);
const process = await application
.getProcessById$(processId)
.pipe(take(1))
.toPromise();
if (!process) {
await application.createProcess({
id: processId,
type: key,
section: 'customer', // Not important anymore
name: processTitle,
});
}
application.activateProcess(processId);
return true;
};

View File

@@ -1,34 +1,34 @@
import { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router';
import { ApplicationService } from '@core/application';
import { Config } from '@core/config';
import { first } from 'rxjs/operators';
import { z } from 'zod';
@Injectable({ providedIn: 'root' })
export class CanActivateGoodsInGuard {
constructor(
private readonly _applicationService: ApplicationService,
private readonly _config: Config,
) {}
async canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot) {
const pid = this._config.get('process.ids.goodsIn', z.number());
const process = await this._applicationService
.getProcessById$(pid)
.pipe(first())
.toPromise();
if (!process) {
await this._applicationService.createProcess({
id: this._config.get('process.ids.goodsIn'),
type: 'goods-in',
section: 'branch',
name: '',
});
}
this._applicationService.activateProcess(
this._config.get('process.ids.goodsIn'),
);
return true;
}
}
import { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router';
import { ApplicationService } from '@core/application';
import { Config } from '@core/config';
import { first } from 'rxjs/operators';
import { z } from 'zod';
@Injectable({ providedIn: 'root' })
export class CanActivateGoodsInGuard {
constructor(
private readonly _applicationService: ApplicationService,
private readonly _config: Config,
) {}
async canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot) {
const pid = this._config.get('process.ids.goodsIn', z.number());
const process = await this._applicationService
.getProcessById$(pid)
.pipe(first())
.toPromise();
if (!process) {
await this._applicationService.createProcess({
id: this._config.get('process.ids.goodsIn'),
type: 'goods-in',
section: 'branch',
name: 'Abholfach',
});
}
this._applicationService.activateProcess(
this._config.get('process.ids.goodsIn'),
);
return true;
}
}

View File

@@ -1,3 +0,0 @@
// start:ng42.barrel
export * from './preview.component';
// end:ng42.barrel

View File

@@ -1,3 +0,0 @@
:host {
@apply grid min-h-screen content-center justify-center;
}

View File

@@ -1,10 +0,0 @@
<h1>Platform: {{ platform | json }}</h1>
<br />
<h1>{{ appVersion }}</h1>
<br />
<h1>{{ userAgent }}</h1>
<br />
<h1>Navigator: {{ navigator | json }}</h1>
<br />
<br />
<h1>Device: {{ device }}</h1>

View File

@@ -1,56 +0,0 @@
import { Platform, PlatformModule } from '@angular/cdk/platform';
import { CommonModule } from '@angular/common';
import { Component } from '@angular/core';
import { BranchDTO } from '@generated/swagger/checkout-api';
import { BehaviorSubject } from 'rxjs';
@Component({
selector: 'app-preview',
templateUrl: 'preview.component.html',
styleUrls: ['preview.component.css'],
imports: [CommonModule, PlatformModule],
})
export class PreviewComponent {
selectedBranch$ = new BehaviorSubject<BranchDTO>({});
get appVersion() {
return 'App Version: ' + (window.navigator as any).appVersion;
}
get userAgent() {
return 'User Agent: ' + (window.navigator as any).userAgent;
}
get navigator() {
const nav = {};
for (const i in window.navigator) nav[i] = navigator[i];
return nav;
}
get platform() {
return this._platform;
}
get device() {
const isIpadNative = this._platform.IOS && !this._platform.SAFARI;
const isIpadMini6Native = window?.navigator?.userAgent?.includes('Macintosh') && !this._platform.SAFARI;
const isNative = isIpadNative || isIpadMini6Native;
const isPWA = this._platform.IOS && this._platform.SAFARI;
const isDesktop = !isNative && !isPWA;
if (isNative) {
if (isIpadMini6Native) {
return 'IPAD mini 6 Native App';
} else if (isIpadNative) {
return 'IPAD mini 2 Native App or IPAD mini 5 Native App';
}
} else if (isPWA) {
return 'IPAD Safari PWA';
} else if (isDesktop) return 'Desktop or Macintosh';
}
constructor(private readonly _platform: Platform) {}
setNewBranch(branch: BranchDTO) {
this.selectedBranch$.next(branch);
}
}

View File

@@ -1,4 +1,2 @@
// start:ng42.barrel
export * from './process-id.resolver';
export * from './section.resolver';
// end:ng42.barrel
export * from './process-id.resolver';
export * from './section.resolver';

View File

@@ -1,87 +1,86 @@
{
"title": "ISA - Local",
"silentRefresh": {
"interval": 300000
},
"debug": true,
"dev-scanner": true,
"@cdn/product-image": {
"url": "https://produktbilder.paragon-data.net"
},
"@core/auth": {
"issuer": "https://sso-test.paragon-data.de",
"clientId": "isa-client",
"responseType": "code",
"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",
"showDebugInformation": true
},
"@core/logger": {
"logLevel": "debug"
},
"@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-test.paragon-data.net/checkout/v6"
},
"@swagger/crm": {
"rootUrl": "https://isa-test.paragon-data.net/crm/v6"
},
"@swagger/oms": {
"rootUrl": "https://isa-test.paragon-data.net/oms/v6"
},
"@swagger/print": {
"rootUrl": "https://isa-test.paragon-data.net/print/v1"
},
"@swagger/eis": {
"rootUrl": "https://filialinformationsystem-test.paragon-systems.de/eiswebapi/v1"
},
"@swagger/remi": {
"rootUrl": "https://isa-test.paragon-data.net/inv/v6"
},
"@swagger/wws": {
"rootUrl": "https://isa-test.paragon-data.net/wws/v1"
},
"@domain/checkout": {
"olaExpiration": "5m"
},
"hubs": {
"notifications": {
"url": "https://isa-test.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 - Local",
"silentRefresh": {
"interval": 300000
},
"debug": true,
"dev-scanner": true,
"@cdn/product-image": {
"url": "https://produktbilder.paragon-data.net"
},
"@core/auth": {
"issuer": "https://sso-test.paragon-data.de",
"clientId": "isa-client",
"responseType": "code",
"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",
"showDebugInformation": true
},
"@core/logger": {
"logLevel": "debug"
},
"@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-test.paragon-data.net/checkout/v6"
},
"@swagger/crm": {
"rootUrl": "https://isa-test.paragon-data.net/crm/v6"
},
"@swagger/oms": {
"rootUrl": "https://isa-test.paragon-data.net/oms/v6"
},
"@swagger/print": {
"rootUrl": "https://isa-test.paragon-data.net/print/v1"
},
"@swagger/eis": {
"rootUrl": "https://filialinformationsystem-test.paragon-systems.de/eiswebapi/v1"
},
"@swagger/remi": {
"rootUrl": "https://isa-test.paragon-data.net/inv/v6"
},
"@swagger/wws": {
"rootUrl": "https://isa-test.paragon-data.net/wws/v1"
},
"@domain/checkout": {
"olaExpiration": "5m"
},
"hubs": {
"notifications": {
"url": "https://isa-test.paragon-data.net/isa/v1/rt",
"enableAutomaticReconnect": false,
"httpOptions": {
"transport": 1,
"logMessageContent": true,
"skipNegotiation": true
}
}
},
"process": {
"ids": {
"goodsOut": 1000,
"goodsIn": 2000,
"taskCalendar": 3000,
"packageInspection": 5000,
"assortment": 6000,
"pickupShelf": 2000
}
},
"checkForUpdates": 3600000,
"licence": {
"scandit": "Ae8F2Wx2RMq5Lvn7UUAlWzVFZTt2+ubMAF8XtDpmPlNkBeG/LWs1M7AbgDW0LQqYLnszClEENaEHS56/6Ts2vrJ1Ux03CXUjK3jUvZpF5OchXR1CpnmpepJ6WxPCd7LMVHUGG1BbwPLDTFjP3y8uT0caTSmmGrYQWAs4CZcEF+ZBabP0z7vfm+hCZF/ebj9qqCJZcW8nH/n19hohshllzYBjFXjh87P2lIh1s6yZS3OaQWWXo/o0AKdxx7T6CVyR0/G5zq6uYJWf6rs3euUBEhpzOZHbHZK86Lvy2AVBEyVkkcttlDW1J2fA4l1W1JV/Xibz8AQV6kG482EpGF42KEoK48paZgX3e1AQsqUtmqzw294dcP4zMVstnw5/WrwKKi/5E/nOOJT2txYP1ZufIjPrwNFsqTlv7xCQlHjMzFGYwT816yD5qLRLbwOtjrkUPXNZLZ06T4upvWwJDmm8XgdeoDqMjHdcO4lwji1bl9EiIYJ/2qnsk9yZ2FqSaHzn4cbiL0f5u2HFlNAP0GUujGRlthGhHi6o4dFU+WAxKsFMKVt+SfoQUazNKHFVQgiAklTIZxIc/HUVzRvOLMxf+wFDerraBtcqGJg+g/5mrWYqeDBGhCBHtKiYf6244IJ4afzNTiH1/30SJcRzXwbEa3A7q1fJTx9/nLTOfVPrJKBQs7f/OQs2dA7LDCel8mzXdbjvsNQaeU5+iCIAq6zbTNKy1xT8wwj+VZrQmtNJs+qeznD+u29nCM24h8xCmRpvNPo4/Mww/lrTNrrNwLBSn1pMIwsH7yS9hH0v0oNAM3A6bVtk1D9qEkbyw+xZa+MZGpMP0D0CdcsqHalPcm5r/Ik="
},
"gender": {
"0": "Keine Anrede",
"1": "Enby",
"2": "Herr",
"4": "Frau"
},
"@shared/icon": "/assets/icons.json"
}

View File

@@ -1,174 +1,196 @@
import { Injectable } from '@angular/core';
import { Store } from '@ngrx/store';
import { BranchDTO } from '@generated/swagger/checkout-api';
import { isBoolean, isNumber } from '@utils/common';
import { BehaviorSubject, Observable } from 'rxjs';
import { first, map, switchMap } from 'rxjs/operators';
import { ApplicationProcess } from './defs';
import {
removeProcess,
selectSection,
selectProcesses,
setSection,
addProcess,
setActivatedProcess,
selectActivatedProcess,
patchProcess,
patchProcessData,
selectTitle,
setTitle,
} from './store';
@Injectable()
export class ApplicationService {
private activatedProcessIdSubject = new BehaviorSubject<number>(undefined);
get activatedProcessId() {
return this.activatedProcessIdSubject.value;
}
get activatedProcessId$() {
return this.activatedProcessIdSubject.asObservable();
}
constructor(private store: Store) {}
getProcesses$(section?: 'customer' | 'branch') {
const processes$ = this.store.select(selectProcesses);
return processes$.pipe(
map((processes) => processes.filter((process) => (section ? process.section === section : true))),
);
}
getProcessById$(processId: number): Observable<ApplicationProcess> {
return this.getProcesses$().pipe(map((processes) => processes.find((process) => process.id === processId)));
}
getSection$() {
return this.store.select(selectSection);
}
getTitle$() {
return this.getSection$().pipe(
map((section) => {
return section === 'customer' ? 'Kundenbereich' : 'Filialbereich';
}),
);
}
/** @deprecated */
getActivatedProcessId$() {
return this.store.select(selectActivatedProcess).pipe(map((process) => process?.id));
}
activateProcess(activatedProcessId: number) {
this.store.dispatch(setActivatedProcess({ activatedProcessId }));
this.activatedProcessIdSubject.next(activatedProcessId);
}
removeProcess(processId: number) {
this.store.dispatch(removeProcess({ processId }));
}
patchProcess(processId: number, changes: Partial<ApplicationProcess>) {
this.store.dispatch(patchProcess({ processId, changes }));
}
patchProcessData(processId: number, data: Record<string, any>) {
this.store.dispatch(patchProcessData({ processId, data }));
}
getSelectedBranch$(processId?: number): Observable<BranchDTO> {
if (!processId) {
return this.activatedProcessId$.pipe(
switchMap((processId) => this.getProcessById$(processId).pipe(map((process) => process?.data?.selectedBranch))),
);
}
return this.getProcessById$(processId).pipe(map((process) => process?.data?.selectedBranch));
}
readonly REGEX_PROCESS_NAME = /^Vorgang \d+$/;
async createCustomerProcess(processId?: number): Promise<ApplicationProcess> {
const processes = await this.getProcesses$('customer').pipe(first()).toPromise();
const processIds = processes.filter((x) => this.REGEX_PROCESS_NAME.test(x.name)).map((x) => +x.name.split(' ')[1]);
const maxId = processIds.length > 0 ? Math.max(...processIds) : 0;
const process: ApplicationProcess = {
id: processId ?? Date.now(),
type: 'cart',
name: `Vorgang ${maxId + 1}`,
section: 'customer',
closeable: true,
};
await this.createProcess(process);
return process;
}
async createProcess(process: ApplicationProcess) {
const existingProcess = await this.getProcessById$(process?.id).pipe(first()).toPromise();
if (existingProcess?.id === process?.id) {
throw new Error('Process Id existiert bereits');
}
if (!isNumber(process.id)) {
throw new Error('Process Id nicht gesetzt');
}
if (!isBoolean(process.closeable)) {
process.closeable = true;
}
if (!isBoolean(process.confirmClosing)) {
process.confirmClosing = true;
}
process.created = this._createTimestamp();
process.activated = 0;
this.store.dispatch(addProcess({ process }));
}
setSection(section: 'customer' | 'branch') {
this.store.dispatch(setSection({ section }));
}
getLastActivatedProcessWithSectionAndType$(
section: 'customer' | 'branch',
type: string,
): Observable<ApplicationProcess> {
return this.getProcesses$(section).pipe(
map((processes) =>
processes
?.filter((process) => process.type === type)
?.reduce((latest, current) => {
if (!latest) {
return current;
}
return latest?.activated > current?.activated ? latest : current;
}, undefined),
),
);
}
getLastActivatedProcessWithSection$(section: 'customer' | 'branch'): Observable<ApplicationProcess> {
return this.getProcesses$(section).pipe(
map((processes) =>
processes?.reduce((latest, current) => {
if (!latest) {
return current;
}
return latest?.activated > current?.activated ? latest : current;
}, undefined),
),
);
}
private _createTimestamp() {
return Date.now();
}
}
import { Injectable } from '@angular/core';
import { Store } from '@ngrx/store';
import { BranchDTO } from '@generated/swagger/checkout-api';
import { isBoolean, isNumber } from '@utils/common';
import { BehaviorSubject, Observable } from 'rxjs';
import { first, map, switchMap } from 'rxjs/operators';
import { ApplicationProcess } from './defs';
import {
removeProcess,
selectSection,
selectProcesses,
setSection,
addProcess,
setActivatedProcess,
selectActivatedProcess,
patchProcess,
patchProcessData,
selectTitle,
setTitle,
} from './store';
@Injectable()
export class ApplicationService {
private activatedProcessIdSubject = new BehaviorSubject<number>(undefined);
get activatedProcessId() {
return this.activatedProcessIdSubject.value;
}
get activatedProcessId$() {
return this.activatedProcessIdSubject.asObservable();
}
constructor(private store: Store) {}
getProcesses$(section?: 'customer' | 'branch') {
const processes$ = this.store.select(selectProcesses);
return processes$.pipe(
map((processes) =>
processes.filter((process) =>
section ? process.section === section : true,
),
),
);
}
getProcessById$(processId: number): Observable<ApplicationProcess> {
return this.getProcesses$().pipe(
map((processes) => processes.find((process) => process.id === processId)),
);
}
getSection$() {
return this.store.select(selectSection);
}
getTitle$() {
return this.getSection$().pipe(
map((section) => {
return section === 'customer' ? 'Kundenbereich' : 'Filialbereich';
}),
);
}
/** @deprecated */
getActivatedProcessId$() {
return this.store
.select(selectActivatedProcess)
.pipe(map((process) => process?.id));
}
activateProcess(activatedProcessId: number) {
this.store.dispatch(setActivatedProcess({ activatedProcessId }));
this.activatedProcessIdSubject.next(activatedProcessId);
}
removeProcess(processId: number) {
this.store.dispatch(removeProcess({ processId }));
}
patchProcess(processId: number, changes: Partial<ApplicationProcess>) {
this.store.dispatch(patchProcess({ processId, changes }));
}
patchProcessData(processId: number, data: Record<string, any>) {
this.store.dispatch(patchProcessData({ processId, data }));
}
getSelectedBranch$(processId?: number): Observable<BranchDTO> {
if (!processId) {
return this.activatedProcessId$.pipe(
switchMap((processId) =>
this.getProcessById$(processId).pipe(
map((process) => process?.data?.selectedBranch),
),
),
);
}
return this.getProcessById$(processId).pipe(
map((process) => process?.data?.selectedBranch),
);
}
readonly REGEX_PROCESS_NAME = /^Vorgang \d+$/;
async createCustomerProcess(processId?: number): Promise<ApplicationProcess> {
const processes = await this.getProcesses$('customer')
.pipe(first())
.toPromise();
const processIds = processes
.filter((x) => this.REGEX_PROCESS_NAME.test(x.name))
.map((x) => +x.name.split(' ')[1]);
const maxId = processIds.length > 0 ? Math.max(...processIds) : 0;
const process: ApplicationProcess = {
id: processId ?? Date.now(),
type: 'cart',
name: `Vorgang ${maxId + 1}`,
section: 'customer',
closeable: true,
};
await this.createProcess(process);
return process;
}
async createProcess(process: ApplicationProcess) {
const existingProcess = await this.getProcessById$(process?.id)
.pipe(first())
.toPromise();
if (existingProcess?.id === process?.id) {
throw new Error('Process Id existiert bereits');
}
if (!isNumber(process.id)) {
throw new Error('Process Id nicht gesetzt');
}
if (!isBoolean(process.closeable)) {
process.closeable = true;
}
if (!isBoolean(process.confirmClosing)) {
process.confirmClosing = true;
}
process.created = this._createTimestamp();
process.activated = 0;
this.store.dispatch(addProcess({ process }));
}
setSection(section: 'customer' | 'branch') {
this.store.dispatch(setSection({ section }));
}
getLastActivatedProcessWithSectionAndType$(
section: 'customer' | 'branch',
type: string,
): Observable<ApplicationProcess> {
return this.getProcesses$(section).pipe(
map((processes) =>
processes
?.filter((process) => process.type === type)
?.reduce((latest, current) => {
if (!latest) {
return current;
}
return latest?.activated > current?.activated ? latest : current;
}, undefined),
),
);
}
getLastActivatedProcessWithSection$(
section: 'customer' | 'branch',
): Observable<ApplicationProcess> {
return this.getProcesses$(section).pipe(
map((processes) =>
processes?.reduce((latest, current) => {
if (!latest) {
return current;
}
return latest?.activated > current?.activated ? latest : current;
}, undefined),
),
);
}
private _createTimestamp() {
return Date.now();
}
}

View File

@@ -35,7 +35,7 @@ const _domainCheckoutReducer = createReducer(
const now = Date.now();
for (let shoppingCartItem of addedShoppingCartItems) {
for (const shoppingCartItem of addedShoppingCartItems) {
if (shoppingCartItem.features?.orderType) {
entity.itemAvailabilityTimestamp[
`${shoppingCartItem.id}_${shoppingCartItem.features.orderType}`
@@ -275,7 +275,7 @@ function getOrCreateCheckoutEntity({
entities: Dictionary<CheckoutEntity>;
processId: number;
}): CheckoutEntity {
let entity = entities[processId];
const entity = entities[processId];
if (isNullOrUndefined(entity)) {
return {

View File

@@ -1,14 +1,19 @@
import { Routes } from '@angular/router';
import { AssortmentComponent } from './assortment.component';
import { PriceUpdateComponent } from './price-update/price-update.component';
export const routes: Routes = [
{
path: '',
component: AssortmentComponent,
children: [
{ path: 'price-update', component: PriceUpdateComponent },
{ path: '**', redirectTo: 'price-update' },
],
},
];
import { Routes } from '@angular/router';
import { AssortmentComponent } from './assortment.component';
import { PriceUpdateComponent } from './price-update/price-update.component';
export const routes: Routes = [
{
path: '',
component: AssortmentComponent,
title: 'Sortiment',
children: [
{
path: 'price-update',
component: PriceUpdateComponent,
title: 'Sortiment - Preisänderung',
},
{ path: '**', redirectTo: 'price-update' },
],
},
];

View File

@@ -1,125 +1,135 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { ArticleDetailsComponent } from './article-details/article-details.component';
import { ArticleSearchComponent } from './article-search/article-search.component';
import { ArticleSearchFilterComponent } from './article-search/search-filter/search-filter.component';
import { ArticleSearchMainComponent } from './article-search/search-main/search-main.component';
import { ArticleSearchResultsComponent } from './article-search/search-results/search-results.component';
import { PageCatalogComponent } from './page-catalog.component';
import { MatomoRouteData } from 'ngx-matomo-client';
const routes: Routes = [
{
path: '',
component: PageCatalogComponent,
data: {
matomo: {
title: 'Artikelsuche',
} as MatomoRouteData,
},
children: [
{
path: 'filter',
component: ArticleSearchFilterComponent,
data: {
matomo: {
title: 'Artikelsuche - Filter',
} as MatomoRouteData,
},
},
{
path: 'filter/:id',
component: ArticleSearchFilterComponent,
data: {
matomo: {
title: 'Artikelsuche - Filter',
} as MatomoRouteData,
},
},
{
path: 'search',
component: ArticleSearchComponent,
outlet: 'side',
data: {
matomo: {
title: 'Artikelsuche',
} as MatomoRouteData,
},
children: [
{
path: '',
component: ArticleSearchMainComponent,
},
],
},
{
path: 'results',
component: ArticleSearchResultsComponent,
data: {
matomo: {
title: 'Artikelsuche - Trefferliste',
} as MatomoRouteData,
},
},
{
path: 'results',
component: ArticleSearchResultsComponent,
outlet: 'side',
data: {
matomo: {
title: 'Artikelsuche - Trefferliste',
} as MatomoRouteData,
},
},
{
path: 'results/:id',
component: ArticleSearchResultsComponent,
outlet: 'side',
data: {
matomo: {
title: 'Artikelsuche - Artikeldetails',
} as MatomoRouteData,
},
},
{
path: 'results/:ean/ean',
component: ArticleSearchResultsComponent,
outlet: 'side',
data: {
matomo: {
title: 'Artikelsuche - Artikeldetails (EAN)',
} as MatomoRouteData,
},
},
{
path: 'details/:id',
component: ArticleDetailsComponent,
data: {
matomo: {
title: 'Artikelsuche - Artikeldetails (ID)',
} as MatomoRouteData,
},
},
{
path: 'details/:ean/ean',
component: ArticleDetailsComponent,
data: {
matomo: {
title: 'Artikelsuche - Artikeldetails (EAN)',
} as MatomoRouteData,
},
},
{
path: '',
pathMatch: 'full',
redirectTo: 'filter',
},
],
},
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule],
})
export class PageCatalogRoutingModule {}
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { ArticleDetailsComponent } from './article-details/article-details.component';
import { ArticleSearchComponent } from './article-search/article-search.component';
import { ArticleSearchFilterComponent } from './article-search/search-filter/search-filter.component';
import { ArticleSearchMainComponent } from './article-search/search-main/search-main.component';
import { ArticleSearchResultsComponent } from './article-search/search-results/search-results.component';
import { PageCatalogComponent } from './page-catalog.component';
import { MatomoRouteData } from 'ngx-matomo-client';
const routes: Routes = [
{
path: '',
component: PageCatalogComponent,
title: 'Artikelsuche',
data: {
matomo: {
title: 'Artikelsuche',
} as MatomoRouteData,
},
children: [
{
path: 'filter',
component: ArticleSearchFilterComponent,
title: 'Artikelsuche - Filter',
data: {
matomo: {
title: 'Artikelsuche - Filter',
} as MatomoRouteData,
},
},
{
path: 'filter/:id',
component: ArticleSearchFilterComponent,
title: 'Artikelsuche - Filter',
data: {
matomo: {
title: 'Artikelsuche - Filter',
} as MatomoRouteData,
},
},
{
path: 'search',
component: ArticleSearchComponent,
outlet: 'side',
title: 'Artikelsuche',
data: {
matomo: {
title: 'Artikelsuche',
} as MatomoRouteData,
},
children: [
{
path: '',
component: ArticleSearchMainComponent,
},
],
},
{
path: 'results',
component: ArticleSearchResultsComponent,
title: 'Artikelsuche - Trefferliste',
data: {
matomo: {
title: 'Artikelsuche - Trefferliste',
} as MatomoRouteData,
},
},
{
path: 'results',
component: ArticleSearchResultsComponent,
outlet: 'side',
title: 'Artikelsuche - Trefferliste',
data: {
matomo: {
title: 'Artikelsuche - Trefferliste',
} as MatomoRouteData,
},
},
{
path: 'results/:id',
component: ArticleSearchResultsComponent,
outlet: 'side',
title: 'Artikelsuche - Artikeldetails',
data: {
matomo: {
title: 'Artikelsuche - Artikeldetails',
} as MatomoRouteData,
},
},
{
path: 'results/:ean/ean',
component: ArticleSearchResultsComponent,
outlet: 'side',
title: 'Artikelsuche - Artikeldetails (EAN)',
data: {
matomo: {
title: 'Artikelsuche - Artikeldetails (EAN)',
} as MatomoRouteData,
},
},
{
path: 'details/:id',
component: ArticleDetailsComponent,
title: 'Artikelsuche - Artikeldetails (ID)',
data: {
matomo: {
title: 'Artikelsuche - Artikeldetails (ID)',
} as MatomoRouteData,
},
},
{
path: 'details/:ean/ean',
component: ArticleDetailsComponent,
title: 'Artikelsuche - Artikeldetails (EAN)',
data: {
matomo: {
title: 'Artikelsuche - Artikeldetails (EAN)',
} as MatomoRouteData,
},
},
{
path: '',
pathMatch: 'full',
redirectTo: 'filter',
},
],
},
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule],
})
export class PageCatalogRoutingModule {}

View File

@@ -14,20 +14,24 @@ const routes: Routes = [
{
path: 'details',
component: CheckoutReviewDetailsComponent,
title: 'Bestelldetails',
outlet: 'side',
},
{
path: 'review',
component: CheckoutReviewComponent,
title: 'Bestellung überprüfen',
},
{
path: 'summary',
component: CheckoutSummaryComponent,
title: 'Bestellübersicht',
canDeactivate: [canDeactivateTabCleanup],
},
{
path: 'summary/:orderIds',
component: CheckoutSummaryComponent,
title: 'Bestellübersicht',
canDeactivate: [canDeactivateTabCleanup],
},
{ path: '', pathMatch: 'full', redirectTo: 'review' },

View File

@@ -1,170 +1,187 @@
import { Routes } from '@angular/router';
import { CustomerComponent } from './customer-page.component';
import { CustomerSearchComponent } from './customer-search/customer-search.component';
import { CustomerResultsMainViewComponent } from './customer-search/results-main-view/results-main-view.component';
import { CustomerDetailsViewMainComponent } from './customer-search/details-main-view/details-main-view.component';
import { CustomerHistoryMainViewComponent } from './customer-search/history-main-view/history-main-view.component';
import { CustomerFilterMainViewComponent } from './customer-search/filter-main-view/filter-main-view.component';
import { CustomerCreateGuard } from './guards/customer-create.guard';
import {
CreateB2BCustomerComponent,
CreateGuestCustomerComponent,
// CreateP4MCustomerComponent,
CreateStoreCustomerComponent,
CreateWebshopCustomerComponent,
} from './create-customer';
// import { UpdateP4MWebshopCustomerComponent } from './create-customer/update-p4m-webshop-customer';
import { CreateCustomerComponent } from './create-customer/create-customer.component';
import { CustomerDataEditB2BComponent } from './customer-search/edit-main-view/customer-data-edit-b2b.component';
import { CustomerDataEditB2CComponent } from './customer-search/edit-main-view/customer-data-edit-b2c.component';
import { AddBillingAddressMainViewComponent } from './customer-search/add-billing-address-main-view/add-billing-address-main-view.component';
import { AddShippingAddressMainViewComponent } from './customer-search/add-shipping-address-main-view/add-shipping-address-main-view.component';
import { EditBillingAddressMainViewComponent } from './customer-search/edit-billing-address-main-view/edit-billing-address-main-view.component';
import { EditShippingAddressMainViewComponent } from './customer-search/edit-shipping-address-main-view/edit-shipping-address-main-view.component';
import { CustomerOrdersMainViewComponent } from './customer-search/orders-main-view/orders-main-view.component';
import { OrderDetailsMainViewComponent } from './customer-search/order-details-main-view/order-details-main-view.component';
import { KundenkarteMainViewComponent } from './customer-search/kundenkarte-main-view/kundenkarte-main-view.component';
import { CustomerOrderDetailsHistoryMainViewComponent } from './customer-search/order-details-history-main-view/order-details-history-main-view.component';
import { CustomerMainViewComponent } from './customer-search/main-view/main-view.component';
import { MainSideViewComponent } from './customer-search/main-side-view/main-side-view.component';
import { CustomerResultsSideViewComponent } from './customer-search/results-side-view/results-side-view.component';
import { OrderDetailsSideViewComponent } from './customer-search/order-details-side-view/order-details-side-view.component';
import { CustomerCreateSideViewComponent } from './create-customer/customer-create-side-view';
export const routes: Routes = [
{
path: '',
component: CustomerComponent,
children: [
{
path: '',
component: CustomerSearchComponent,
children: [
{
path: 'search',
component: CustomerMainViewComponent,
data: { side: 'main', breadcrumb: 'main' },
},
{
path: 'search/list',
component: CustomerResultsMainViewComponent,
data: { breadcrumb: 'search' },
},
{
path: 'search/filter',
component: CustomerFilterMainViewComponent,
data: { side: 'results', breadcrumb: 'filter' },
},
{
path: 'search/:customerId',
component: CustomerDetailsViewMainComponent,
data: { side: 'results', breadcrumb: 'details' },
},
{
path: 'search/:customerId/history',
component: CustomerHistoryMainViewComponent,
data: { side: 'results', breadcrumb: 'history' },
},
{
path: 'search/:customerId/kundenkarte',
component: KundenkarteMainViewComponent,
data: { side: 'results', breadcrumb: 'kundenkarte' },
},
{
path: 'search/:customerId/orders',
component: CustomerOrdersMainViewComponent,
data: { side: 'results', breadcrumb: 'orders' },
},
{
path: 'search/:customerId/orders/:orderId',
component: OrderDetailsMainViewComponent,
data: { side: 'order-details', breadcrumb: 'order-details' },
},
{
path: 'search/:customerId/orders/:orderId/:orderItemId',
component: OrderDetailsMainViewComponent,
data: { side: 'order-details', breadcrumb: 'order-details' },
},
{
path: 'search/:customerId/orders/:orderId/:orderItemId/history',
component: CustomerOrderDetailsHistoryMainViewComponent,
data: {
side: 'order-details',
breadcrumb: 'order-details-history',
},
},
{
path: 'search/:customerId/edit/b2b',
component: CustomerDataEditB2BComponent,
data: { side: 'results', breadcrumb: 'edit' },
},
{
path: 'search/:customerId/edit',
component: CustomerDataEditB2CComponent,
data: { side: 'results', breadcrumb: 'edit' },
},
{
path: 'search/:customerId/billingaddress/add',
component: AddBillingAddressMainViewComponent,
data: { side: 'results', breadcrumb: 'add-billing-address' },
},
{
path: 'search/:customerId/billingaddress/:payerId/edit',
component: EditBillingAddressMainViewComponent,
data: { side: 'results', breadcrumb: 'edit-billing-address' },
},
{
path: 'search/:customerId/shippingaddress/add',
component: AddShippingAddressMainViewComponent,
data: { side: 'results', breadcrumb: 'add-shipping-address' },
},
{
path: 'search/:customerId/shippingaddress/:shippingAddressId/edit',
component: EditShippingAddressMainViewComponent,
data: { side: 'results', breadcrumb: 'edit-shipping-address' },
},
{
path: 'search-customer-main',
outlet: 'side',
component: MainSideViewComponent,
},
{
path: 'results',
outlet: 'side',
component: CustomerResultsSideViewComponent,
},
{
path: 'order-details',
outlet: 'side',
component: OrderDetailsSideViewComponent,
},
],
},
{
path: '',
component: CreateCustomerComponent,
canActivate: [CustomerCreateGuard],
canActivateChild: [CustomerCreateGuard],
children: [
{ path: 'create', component: CreateStoreCustomerComponent },
{ path: 'create/store', component: CreateStoreCustomerComponent },
{ path: 'create/webshop', component: CreateWebshopCustomerComponent },
{ path: 'create/b2b', component: CreateB2BCustomerComponent },
{ path: 'create/guest', component: CreateGuestCustomerComponent },
// { path: 'create/webshop-p4m', component: CreateP4MCustomerComponent, data: { customerType: 'webshop' } },
// { path: 'create/store-p4m', component: CreateP4MCustomerComponent, data: { customerType: 'store' } },
// {
// path: 'create/webshop-p4m/update',
// component: UpdateP4MWebshopCustomerComponent,
// data: { customerType: 'webshop' },
// },
{
path: 'create-customer-main',
outlet: 'side',
component: CustomerCreateSideViewComponent,
},
],
},
],
},
];
import { Routes } from '@angular/router';
import { CustomerComponent } from './customer-page.component';
import { CustomerSearchComponent } from './customer-search/customer-search.component';
import { CustomerResultsMainViewComponent } from './customer-search/results-main-view/results-main-view.component';
import { CustomerDetailsViewMainComponent } from './customer-search/details-main-view/details-main-view.component';
import { CustomerHistoryMainViewComponent } from './customer-search/history-main-view/history-main-view.component';
import { CustomerFilterMainViewComponent } from './customer-search/filter-main-view/filter-main-view.component';
import { CustomerCreateGuard } from './guards/customer-create.guard';
import {
CreateB2BCustomerComponent,
CreateGuestCustomerComponent,
// CreateP4MCustomerComponent,
CreateStoreCustomerComponent,
CreateWebshopCustomerComponent,
} from './create-customer';
// import { UpdateP4MWebshopCustomerComponent } from './create-customer/update-p4m-webshop-customer';
import { CreateCustomerComponent } from './create-customer/create-customer.component';
import { CustomerDataEditB2BComponent } from './customer-search/edit-main-view/customer-data-edit-b2b.component';
import { CustomerDataEditB2CComponent } from './customer-search/edit-main-view/customer-data-edit-b2c.component';
import { AddBillingAddressMainViewComponent } from './customer-search/add-billing-address-main-view/add-billing-address-main-view.component';
import { AddShippingAddressMainViewComponent } from './customer-search/add-shipping-address-main-view/add-shipping-address-main-view.component';
import { EditBillingAddressMainViewComponent } from './customer-search/edit-billing-address-main-view/edit-billing-address-main-view.component';
import { EditShippingAddressMainViewComponent } from './customer-search/edit-shipping-address-main-view/edit-shipping-address-main-view.component';
import { CustomerOrdersMainViewComponent } from './customer-search/orders-main-view/orders-main-view.component';
import { OrderDetailsMainViewComponent } from './customer-search/order-details-main-view/order-details-main-view.component';
import { KundenkarteMainViewComponent } from './customer-search/kundenkarte-main-view/kundenkarte-main-view.component';
import { CustomerOrderDetailsHistoryMainViewComponent } from './customer-search/order-details-history-main-view/order-details-history-main-view.component';
import { CustomerMainViewComponent } from './customer-search/main-view/main-view.component';
import { MainSideViewComponent } from './customer-search/main-side-view/main-side-view.component';
import { CustomerResultsSideViewComponent } from './customer-search/results-side-view/results-side-view.component';
import { OrderDetailsSideViewComponent } from './customer-search/order-details-side-view/order-details-side-view.component';
import { CustomerCreateSideViewComponent } from './create-customer/customer-create-side-view';
export const routes: Routes = [
{
path: '',
component: CustomerComponent,
children: [
{
path: '',
component: CustomerSearchComponent,
children: [
{
path: 'search',
component: CustomerMainViewComponent,
title: 'Kundensuche',
data: { side: 'main', breadcrumb: 'main' },
},
{
path: 'search/list',
component: CustomerResultsMainViewComponent,
title: 'Kundensuche - Trefferliste',
data: { breadcrumb: 'search' },
},
{
path: 'search/filter',
component: CustomerFilterMainViewComponent,
title: 'Kundensuche - Filter',
data: { side: 'results', breadcrumb: 'filter' },
},
{
path: 'search/:customerId',
component: CustomerDetailsViewMainComponent,
title: 'Kundendetails',
data: { side: 'results', breadcrumb: 'details' },
},
{
path: 'search/:customerId/history',
component: CustomerHistoryMainViewComponent,
title: 'Kundendetails - Verlauf',
data: { side: 'results', breadcrumb: 'history' },
},
{
path: 'search/:customerId/kundenkarte',
component: KundenkarteMainViewComponent,
title: 'Kundendetails - Kundenkarte',
data: { side: 'results', breadcrumb: 'kundenkarte' },
},
{
path: 'search/:customerId/orders',
component: CustomerOrdersMainViewComponent,
title: 'Kundendetails - Bestellungen',
data: { side: 'results', breadcrumb: 'orders' },
},
{
path: 'search/:customerId/orders/:orderId',
component: OrderDetailsMainViewComponent,
title: 'Kundendetails - Bestelldetails',
data: { side: 'order-details', breadcrumb: 'order-details' },
},
{
path: 'search/:customerId/orders/:orderId/:orderItemId',
component: OrderDetailsMainViewComponent,
title: 'Kundendetails - Bestelldetails',
data: { side: 'order-details', breadcrumb: 'order-details' },
},
{
path: 'search/:customerId/orders/:orderId/:orderItemId/history',
component: CustomerOrderDetailsHistoryMainViewComponent,
title: 'Kundendetails - Bestelldetails Verlauf',
data: {
side: 'order-details',
breadcrumb: 'order-details-history',
},
},
{
path: 'search/:customerId/edit/b2b',
component: CustomerDataEditB2BComponent,
title: 'Kundendetails - Bearbeiten (B2B)',
data: { side: 'results', breadcrumb: 'edit' },
},
{
path: 'search/:customerId/edit',
component: CustomerDataEditB2CComponent,
title: 'Kundendetails - Bearbeiten',
data: { side: 'results', breadcrumb: 'edit' },
},
{
path: 'search/:customerId/billingaddress/add',
component: AddBillingAddressMainViewComponent,
title: 'Kundendetails - Neue Rechnungsadresse',
data: { side: 'results', breadcrumb: 'add-billing-address' },
},
{
path: 'search/:customerId/billingaddress/:payerId/edit',
component: EditBillingAddressMainViewComponent,
title: 'Kundendetails - Rechnungsadresse bearbeiten',
data: { side: 'results', breadcrumb: 'edit-billing-address' },
},
{
path: 'search/:customerId/shippingaddress/add',
component: AddShippingAddressMainViewComponent,
title: 'Kundendetails - Neue Lieferadresse',
data: { side: 'results', breadcrumb: 'add-shipping-address' },
},
{
path: 'search/:customerId/shippingaddress/:shippingAddressId/edit',
component: EditShippingAddressMainViewComponent,
title: 'Kundendetails - Lieferadresse bearbeiten',
data: { side: 'results', breadcrumb: 'edit-shipping-address' },
},
{
path: 'search-customer-main',
outlet: 'side',
component: MainSideViewComponent,
},
{
path: 'results',
outlet: 'side',
component: CustomerResultsSideViewComponent,
},
{
path: 'order-details',
outlet: 'side',
component: OrderDetailsSideViewComponent,
},
],
},
{
path: '',
component: CreateCustomerComponent,
canActivate: [CustomerCreateGuard],
canActivateChild: [CustomerCreateGuard],
title: 'Kundendaten erfassen',
children: [
{ path: 'create', component: CreateStoreCustomerComponent },
{ path: 'create/store', component: CreateStoreCustomerComponent },
{ path: 'create/webshop', component: CreateWebshopCustomerComponent },
{ path: 'create/b2b', component: CreateB2BCustomerComponent },
{ path: 'create/guest', component: CreateGuestCustomerComponent },
// { path: 'create/webshop-p4m', component: CreateP4MCustomerComponent, data: { customerType: 'webshop' } },
// { path: 'create/store-p4m', component: CreateP4MCustomerComponent, data: { customerType: 'store' } },
// {
// path: 'create/webshop-p4m/update',
// component: UpdateP4MWebshopCustomerComponent,
// data: { customerType: 'webshop' },
// },
{
path: 'create-customer-main',
outlet: 'side',
component: CustomerCreateSideViewComponent,
},
],
},
],
},
];

View File

@@ -1,16 +1,17 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { DashboardComponent } from './dashboard.component';
const routes: Routes = [
{
path: '',
component: DashboardComponent,
},
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule],
})
export class DashboardRoutingModule {}
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { DashboardComponent } from './dashboard.component';
const routes: Routes = [
{
path: '',
component: DashboardComponent,
title: 'Dashboard',
},
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule],
})
export class DashboardRoutingModule {}

View File

@@ -1,35 +1,51 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { GoodsInCleanupListComponent } from './goods-in-cleanup-list/goods-in-cleanup-list.component';
import { GoodsInCleanupListModule } from './goods-in-cleanup-list/goods-in-cleanup-list.module';
import { GoodsInListComponent } from './goods-in-list/goods-in-list.component';
import { GoodsInListModule } from './goods-in-list/goods-in-list.module';
import { GoodsInRemissionPreviewComponent } from './goods-in-remission-preview/goods-in-remission-preview.component';
import { GoodsInRemissionPreviewModule } from './goods-in-remission-preview/goods-in-remission-preview.module';
import { GoodsInReservationComponent } from './goods-in-reservation/goods-in-reservation.component';
import { GoodsInReservationModule } from './goods-in-reservation/goods-in-reservation.module';
import { GoodsInComponent } from './goods-in.component';
const routes: Routes = [
{
path: '',
component: GoodsInComponent,
children: [
{ path: 'list', component: GoodsInListComponent },
{ path: 'reservation', component: GoodsInReservationComponent },
{ path: 'cleanup', component: GoodsInCleanupListComponent },
{ path: 'preview', component: GoodsInRemissionPreviewComponent },
],
},
];
@NgModule({
imports: [
RouterModule.forChild(routes),
GoodsInListModule,
GoodsInCleanupListModule,
GoodsInReservationModule,
GoodsInRemissionPreviewModule,
],
exports: [RouterModule],
})
export class GoodsInRoutingModule {}
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { GoodsInCleanupListComponent } from './goods-in-cleanup-list/goods-in-cleanup-list.component';
import { GoodsInCleanupListModule } from './goods-in-cleanup-list/goods-in-cleanup-list.module';
import { GoodsInListComponent } from './goods-in-list/goods-in-list.component';
import { GoodsInListModule } from './goods-in-list/goods-in-list.module';
import { GoodsInRemissionPreviewComponent } from './goods-in-remission-preview/goods-in-remission-preview.component';
import { GoodsInRemissionPreviewModule } from './goods-in-remission-preview/goods-in-remission-preview.module';
import { GoodsInReservationComponent } from './goods-in-reservation/goods-in-reservation.component';
import { GoodsInReservationModule } from './goods-in-reservation/goods-in-reservation.module';
import { GoodsInComponent } from './goods-in.component';
const routes: Routes = [
{
path: '',
component: GoodsInComponent,
children: [
{
path: 'list',
component: GoodsInListComponent,
title: 'Abholfach - Fehlende',
},
{
path: 'reservation',
component: GoodsInReservationComponent,
title: 'Abholfach - Reservierung',
},
{
path: 'cleanup',
component: GoodsInCleanupListComponent,
title: 'Abholfach - Ausräumen',
},
{
path: 'preview',
component: GoodsInRemissionPreviewComponent,
title: 'Abholfach - Vorschau',
},
],
},
];
@NgModule({
imports: [
RouterModule.forChild(routes),
GoodsInListModule,
GoodsInCleanupListModule,
GoodsInReservationModule,
GoodsInRemissionPreviewModule,
],
exports: [RouterModule],
})
export class GoodsInRoutingModule {}

View File

@@ -1,22 +1,24 @@
import { Routes } from '@angular/router';
import { PackageDetailsComponent } from './package-details';
import { PackageInspectionComponent } from './package-inspection.component';
import { PackageResultComponent } from './package-result';
export const packageInspectionRoutes: Routes = [
{
path: '',
component: PackageInspectionComponent,
children: [
{
path: 'packages',
component: PackageResultComponent,
},
{
path: 'packages/:id',
component: PackageDetailsComponent,
},
{ path: '**', redirectTo: 'packages' },
],
},
];
import { Routes } from '@angular/router';
import { PackageDetailsComponent } from './package-details';
import { PackageInspectionComponent } from './package-inspection.component';
import { PackageResultComponent } from './package-result';
export const packageInspectionRoutes: Routes = [
{
path: '',
component: PackageInspectionComponent,
title: 'Packstück-Prüfung',
children: [
{
path: 'packages',
component: PackageResultComponent,
},
{
path: 'packages/:id',
component: PackageDetailsComponent,
title: 'Packstück-Prüfung - Details',
},
{ path: '**', redirectTo: 'packages' },
],
},
];

View File

@@ -1,146 +1,168 @@
import { Routes } from '@angular/router';
import { PickupShelfInComponent } from './pickup-shelf-in.component';
import { PickupShelfFilterComponent } from '../shared/pickup-shelf-filter/pickup-shelf-filter.component';
import { PickupShelfInDetailsComponent } from './pickup-shelf-in-details/pickup-shelf-in-details.component';
import { viewResolver } from '../resolvers/view.resolver';
import { PickUpShelfHistoryComponent } from '../shared/pickup-shelf-history/pickup-shelf-history.component';
import { PickUpShelfInMainSideViewComponent } from './pickup-shelf-in-main-side-view/pickup-shelf-in-main-side-view.component';
import { PickUpShelfInMainComponent } from './pickup-shelf-in-main/pickup-shelf-in-main.component';
import { PickUpShelfInListComponent } from './pickup-shelf-in-list/pickup-shelf-in-list.component';
import { PickupShelfInEditComponent } from './pickup-shelf-in-edit/pickup-shelf-in-edit.component';
import { MatomoRouteData } from 'ngx-matomo-client';
export const routes: Routes = [
{
path: '',
component: PickupShelfInComponent,
resolve: {
view: viewResolver,
},
runGuardsAndResolvers: 'always',
children: [
{
path: 'main',
component: PickUpShelfInMainComponent,
data: {
view: 'main',
matomo: {
title: 'Abholfach',
} as MatomoRouteData,
},
},
{
path: 'list',
component: PickUpShelfInListComponent,
data: {
view: 'list',
matomo: {
title: 'Abholfach - Trefferliste',
} as MatomoRouteData,
},
},
{
path: 'list/filter',
component: PickupShelfFilterComponent,
data: {
view: 'filter',
matomo: {
title: 'Abholfach - Filter',
} as MatomoRouteData,
},
},
{
path: 'order/:orderId/:orderNumber/item/status/:orderItemProcessingStatus/:orderItemSubsetId/edit',
component: PickupShelfInEditComponent,
data: {
view: 'edit',
matomo: {
title: 'Abholfach - Bearbeiten',
} as MatomoRouteData,
},
},
{
path: 'order/:orderId/compartment/:compartmentCode/:orderItemSubsetId/edit',
component: PickupShelfInEditComponent,
data: {
view: 'edit',
matomo: {
title: 'Abholfach - Bearbeiten',
} as MatomoRouteData,
},
},
{
path: 'order/:orderId/compartment/:compartmentCode/:compartmentInfo/:orderItemSubsetId/edit',
component: PickupShelfInEditComponent,
data: {
view: 'edit',
matomo: {
title: 'Abholfach - Bearbeiten',
} as MatomoRouteData,
},
},
{
path: 'order/:orderId/:orderNumber/item/status/:orderItemProcessingStatus/:orderItemSubsetId/history',
component: PickUpShelfHistoryComponent,
data: {
view: 'history',
matomo: {
title: 'Abholfach - Historie',
} as MatomoRouteData,
},
},
{
path: 'order/:orderId/compartment/:compartmentCode/:orderItemSubsetId/history',
component: PickUpShelfHistoryComponent,
data: {
view: 'history',
matomo: {
title: 'Abholfach - Historie',
} as MatomoRouteData,
},
},
{
path: 'order/:orderId/compartment/:compartmentCode/:compartmentInfo/:orderItemSubsetId/history',
component: PickUpShelfHistoryComponent,
data: {
view: 'history',
matomo: {
title: 'Abholfach - Historie',
} as MatomoRouteData,
},
},
{
path: 'order/:orderId/:orderNumber/item/status/:orderItemProcessingStatus/:orderItemSubsetId',
component: PickupShelfInDetailsComponent,
data: {
view: 'details',
matomo: {
title: 'Abholfach - Details',
} as MatomoRouteData,
},
},
{
path: 'order/:orderId/compartment/:compartmentCode/:orderItemSubsetId',
component: PickupShelfInDetailsComponent,
data: {
view: 'details',
matomo: {
title: 'Abholfach - Details',
} as MatomoRouteData,
},
},
{
path: 'order/:orderId/compartment/:compartmentCode/:compartmentInfo/:orderItemSubsetId',
component: PickupShelfInDetailsComponent,
data: {
view: 'details',
matomo: {
title: 'Abholfach - Details',
} as MatomoRouteData,
},
},
{ path: 'search', component: PickUpShelfInMainSideViewComponent, outlet: 'side' },
{ path: 'list', component: PickUpShelfInListComponent, data: { view: 'list' }, outlet: 'side' },
],
},
];
import { Routes } from '@angular/router';
import { PickupShelfInComponent } from './pickup-shelf-in.component';
import { PickupShelfFilterComponent } from '../shared/pickup-shelf-filter/pickup-shelf-filter.component';
import { PickupShelfInDetailsComponent } from './pickup-shelf-in-details/pickup-shelf-in-details.component';
import { viewResolver } from '../resolvers/view.resolver';
import { PickUpShelfHistoryComponent } from '../shared/pickup-shelf-history/pickup-shelf-history.component';
import { PickUpShelfInMainSideViewComponent } from './pickup-shelf-in-main-side-view/pickup-shelf-in-main-side-view.component';
import { PickUpShelfInMainComponent } from './pickup-shelf-in-main/pickup-shelf-in-main.component';
import { PickUpShelfInListComponent } from './pickup-shelf-in-list/pickup-shelf-in-list.component';
import { PickupShelfInEditComponent } from './pickup-shelf-in-edit/pickup-shelf-in-edit.component';
import { MatomoRouteData } from 'ngx-matomo-client';
export const routes: Routes = [
{
path: '',
component: PickupShelfInComponent,
resolve: {
view: viewResolver,
},
runGuardsAndResolvers: 'always',
title: 'Abholfach - Einbuchen',
children: [
{
path: 'main',
component: PickUpShelfInMainComponent,
data: {
view: 'main',
matomo: {
title: 'Abholfach',
} as MatomoRouteData,
},
},
{
path: 'list',
component: PickUpShelfInListComponent,
title: 'Abholfach - Trefferliste',
data: {
view: 'list',
matomo: {
title: 'Abholfach - Trefferliste',
} as MatomoRouteData,
},
},
{
path: 'list/filter',
component: PickupShelfFilterComponent,
title: 'Abholfach - Filter',
data: {
view: 'filter',
matomo: {
title: 'Abholfach - Filter',
} as MatomoRouteData,
},
},
{
path: 'order/:orderId/:orderNumber/item/status/:orderItemProcessingStatus/:orderItemSubsetId/edit',
component: PickupShelfInEditComponent,
title: 'Abholfach - Bearbeiten',
data: {
view: 'edit',
matomo: {
title: 'Abholfach - Bearbeiten',
} as MatomoRouteData,
},
},
{
path: 'order/:orderId/compartment/:compartmentCode/:orderItemSubsetId/edit',
component: PickupShelfInEditComponent,
title: 'Abholfach - Bearbeiten',
data: {
view: 'edit',
matomo: {
title: 'Abholfach - Bearbeiten',
} as MatomoRouteData,
},
},
{
path: 'order/:orderId/compartment/:compartmentCode/:compartmentInfo/:orderItemSubsetId/edit',
component: PickupShelfInEditComponent,
title: 'Abholfach - Bearbeiten',
data: {
view: 'edit',
matomo: {
title: 'Abholfach - Bearbeiten',
} as MatomoRouteData,
},
},
{
path: 'order/:orderId/:orderNumber/item/status/:orderItemProcessingStatus/:orderItemSubsetId/history',
component: PickUpShelfHistoryComponent,
title: 'Abholfach - Historie',
data: {
view: 'history',
matomo: {
title: 'Abholfach - Historie',
} as MatomoRouteData,
},
},
{
path: 'order/:orderId/compartment/:compartmentCode/:orderItemSubsetId/history',
component: PickUpShelfHistoryComponent,
title: 'Abholfach - Historie',
data: {
view: 'history',
matomo: {
title: 'Abholfach - Historie',
} as MatomoRouteData,
},
},
{
path: 'order/:orderId/compartment/:compartmentCode/:compartmentInfo/:orderItemSubsetId/history',
component: PickUpShelfHistoryComponent,
title: 'Abholfach - Historie',
data: {
view: 'history',
matomo: {
title: 'Abholfach - Historie',
} as MatomoRouteData,
},
},
{
path: 'order/:orderId/:orderNumber/item/status/:orderItemProcessingStatus/:orderItemSubsetId',
component: PickupShelfInDetailsComponent,
title: 'Abholfach - Details',
data: {
view: 'details',
matomo: {
title: 'Abholfach - Details',
} as MatomoRouteData,
},
},
{
path: 'order/:orderId/compartment/:compartmentCode/:orderItemSubsetId',
component: PickupShelfInDetailsComponent,
title: 'Abholfach - Details',
data: {
view: 'details',
matomo: {
title: 'Abholfach - Details',
} as MatomoRouteData,
},
},
{
path: 'order/:orderId/compartment/:compartmentCode/:compartmentInfo/:orderItemSubsetId',
component: PickupShelfInDetailsComponent,
title: 'Abholfach - Details',
data: {
view: 'details',
matomo: {
title: 'Abholfach - Details',
} as MatomoRouteData,
},
},
{
path: 'search',
component: PickUpShelfInMainSideViewComponent,
outlet: 'side',
},
{
path: 'list',
component: PickUpShelfInListComponent,
data: { view: 'list' },
outlet: 'side',
},
],
},
];

View File

@@ -1,75 +1,107 @@
import { Routes } from '@angular/router';
import { PickupShelfOutComponent } from './pickup-shelf-out.component';
import { PickupShelfOutMainComponent } from './pickup-shelf-out-main/pickup-shelf-out-main.component';
import { PickupShelfOutListComponent } from './pickup-shelf-out-list/pickup-shelf-out-list.component';
import { PickupShelfFilterComponent } from '../shared/pickup-shelf-filter/pickup-shelf-filter.component';
import { PickupShelfOutDetailsComponent } from './pickup-shelf-out-details/pickup-shelf-out-details.component';
import { PickupShelfOutMainSideViewComponent } from './pickup-shelf-out-main-side-view/pickup-shelf-out-main-side-view.component';
import { viewResolver } from '../resolvers/view.resolver';
import { PickUpShelfHistoryComponent } from '../shared/pickup-shelf-history/pickup-shelf-history.component';
import { PickupShelfOutEditComponent } from './pickup-shelf-out-edit/pickup-shelf-out-edit.component';
export const routes: Routes = [
{
path: '',
component: PickupShelfOutComponent,
resolve: {
view: viewResolver,
},
runGuardsAndResolvers: 'always',
children: [
{ path: 'main', component: PickupShelfOutMainComponent, data: { view: 'main' } },
{ path: 'list', component: PickupShelfOutListComponent, data: { view: 'list' } },
{ path: 'list/filter', component: PickupShelfFilterComponent, data: { view: 'filter' } },
{
path: 'order/:orderId/:orderNumber/item/status/:orderItemProcessingStatus/edit',
component: PickupShelfOutEditComponent,
data: { view: 'edit' },
},
{
path: 'order/:orderId/compartment/:compartmentCode/status/:orderItemProcessingStatus/edit',
component: PickupShelfOutEditComponent,
data: { view: 'edit' },
},
{
path: 'order/:orderId/compartment/:compartmentCode/:compartmentInfo/status/:orderItemProcessingStatus/edit',
component: PickupShelfOutEditComponent,
data: { view: 'edit' },
},
{
path: 'order/:orderId/:orderNumber/item/status/:orderItemProcessingStatus/history',
component: PickUpShelfHistoryComponent,
data: { view: 'history' },
},
{
path: 'order/:orderId/compartment/:compartmentCode/status/:orderItemProcessingStatus/history',
component: PickUpShelfHistoryComponent,
data: { view: 'history' },
},
{
path: 'order/:orderId/compartment/:compartmentCode/:compartmentInfo/status/:orderItemProcessingStatus/history',
component: PickUpShelfHistoryComponent,
data: { view: 'history' },
},
{
path: 'order/:orderId/:orderNumber/item/status/:orderItemProcessingStatus',
component: PickupShelfOutDetailsComponent,
data: { view: 'details' },
},
{
path: 'order/:orderId/compartment/:compartmentCode/status/:orderItemProcessingStatus',
component: PickupShelfOutDetailsComponent,
data: { view: 'details' },
},
{
path: 'order/:orderId/compartment/:compartmentCode/:compartmentInfo/status/:orderItemProcessingStatus',
component: PickupShelfOutDetailsComponent,
data: { view: 'details' },
},
{ path: 'search', component: PickupShelfOutMainSideViewComponent, outlet: 'side' },
{ path: 'list', component: PickupShelfOutListComponent, data: { view: 'list' }, outlet: 'side' },
],
},
];
import { Routes } from '@angular/router';
import { PickupShelfOutComponent } from './pickup-shelf-out.component';
import { PickupShelfOutMainComponent } from './pickup-shelf-out-main/pickup-shelf-out-main.component';
import { PickupShelfOutListComponent } from './pickup-shelf-out-list/pickup-shelf-out-list.component';
import { PickupShelfFilterComponent } from '../shared/pickup-shelf-filter/pickup-shelf-filter.component';
import { PickupShelfOutDetailsComponent } from './pickup-shelf-out-details/pickup-shelf-out-details.component';
import { PickupShelfOutMainSideViewComponent } from './pickup-shelf-out-main-side-view/pickup-shelf-out-main-side-view.component';
import { viewResolver } from '../resolvers/view.resolver';
import { PickUpShelfHistoryComponent } from '../shared/pickup-shelf-history/pickup-shelf-history.component';
import { PickupShelfOutEditComponent } from './pickup-shelf-out-edit/pickup-shelf-out-edit.component';
export const routes: Routes = [
{
path: '',
component: PickupShelfOutComponent,
resolve: {
view: viewResolver,
},
runGuardsAndResolvers: 'always',
title: 'Warenausgabe',
children: [
{
path: 'main',
component: PickupShelfOutMainComponent,
data: { view: 'main' },
},
{
path: 'list',
component: PickupShelfOutListComponent,
data: { view: 'list' },
},
{
path: 'list/filter',
component: PickupShelfFilterComponent,
data: { view: 'filter' },
},
{
path: 'order/:orderId/:orderNumber/item/status/:orderItemProcessingStatus/edit',
component: PickupShelfOutEditComponent,
title: 'Warenausgabe - Details',
data: { view: 'edit' },
},
{
path: 'order/:orderId/compartment/:compartmentCode/status/:orderItemProcessingStatus/edit',
component: PickupShelfOutEditComponent,
title: 'Warenausgabe - Details',
data: { view: 'edit' },
},
{
path: 'order/:orderId/compartment/:compartmentCode/:compartmentInfo/status/:orderItemProcessingStatus/edit',
component: PickupShelfOutEditComponent,
title: 'Warenausgabe - Details',
data: { view: 'edit' },
},
{
path: 'order/:orderId/:orderNumber/item/status/:orderItemProcessingStatus/history',
component: PickUpShelfHistoryComponent,
title: 'Warenausgabe - Verlauf',
data: { view: 'history' },
},
{
path: 'order/:orderId/compartment/:compartmentCode/status/:orderItemProcessingStatus/history',
component: PickUpShelfHistoryComponent,
title: 'Warenausgabe - Verlauf',
data: { view: 'history' },
},
{
path: 'order/:orderId/compartment/:compartmentCode/:compartmentInfo/status/:orderItemProcessingStatus/history',
component: PickUpShelfHistoryComponent,
title: 'Warenausgabe - Verlauf',
data: { view: 'history' },
},
{
path: 'order/:orderId/:orderNumber/item/status/:orderItemProcessingStatus',
component: PickupShelfOutDetailsComponent,
title: 'Warenausgabe - Details',
data: { view: 'details' },
},
{
path: 'order/:orderId/compartment/:compartmentCode/status/:orderItemProcessingStatus',
component: PickupShelfOutDetailsComponent,
title: 'Warenausgabe - Details',
data: { view: 'details' },
},
{
path: 'order/:orderId/compartment/:compartmentCode/:compartmentInfo/status/:orderItemProcessingStatus',
component: PickupShelfOutDetailsComponent,
title: 'Warenausgabe - Details',
data: { view: 'details' },
},
{
path: 'search',
component: PickupShelfOutMainSideViewComponent,
outlet: 'side',
data: { view: 'search' },
},
{
path: 'list',
component: PickupShelfOutListComponent,
data: { view: 'list' },
outlet: 'side',
},
],
},
];

View File

@@ -1,27 +1,36 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { PageTaskCalendarComponent } from './page-task-calendar.component';
import { CalendarComponent } from './pages/calendar/calendar.component';
import { CalendarModule } from './pages/calendar/calendar.module';
import { TaskSearchComponent } from './pages/task-search';
import { TasksComponent } from './pages/tasks/tasks.component';
import { TasksModule } from './pages/tasks/tasks.module';
const routes: Routes = [
{
path: '',
component: PageTaskCalendarComponent,
children: [
{ path: 'calendar', component: CalendarComponent },
{ path: 'tasks', component: TasksComponent },
{ path: 'search', component: TaskSearchComponent },
{ path: '', pathMatch: 'full', redirectTo: 'tasks' },
],
},
];
@NgModule({
imports: [RouterModule.forChild(routes), CalendarModule, TasksModule],
exports: [RouterModule],
})
export class PageTaskCalendarRoutingModule {}
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { PageTaskCalendarComponent } from './page-task-calendar.component';
import { CalendarComponent } from './pages/calendar/calendar.component';
import { CalendarModule } from './pages/calendar/calendar.module';
import { TaskSearchComponent } from './pages/task-search';
import { TasksComponent } from './pages/tasks/tasks.component';
import { TasksModule } from './pages/tasks/tasks.module';
const routes: Routes = [
{
path: '',
component: PageTaskCalendarComponent,
title: 'Tätigkeitskalender',
children: [
{ path: 'calendar', component: CalendarComponent },
{
path: 'tasks',
title: 'Tätigkeitskalender - Aufgaben',
component: TasksComponent,
},
{
path: 'search',
title: 'Tätigkeitskalender - Suche',
component: TaskSearchComponent,
},
{ path: '', pathMatch: 'full', redirectTo: 'tasks' },
],
},
];
@NgModule({
imports: [RouterModule.forChild(routes), CalendarModule, TasksModule],
exports: [RouterModule],
})
export class PageTaskCalendarRoutingModule {}

View File

@@ -6,6 +6,7 @@ export const routes: Routes = [
{
path: '',
component: RewardCatalogComponent,
title: 'Prämienshop',
resolve: { querySettings: querySettingsResolverFn },
data: {
scrollPositionRestoration: true,

View File

@@ -6,6 +6,7 @@ import { canDeactivateTabCleanup } from '@isa/core/tabs';
export const routes: Routes = [
{
path: ':displayOrderIds',
title: 'Prämienshop - Bestellbestätigung',
providers: [
CoreCommandModule.forChild(OMS_ACTION_HANDLERS).providers ?? [],
],

View File

@@ -4,6 +4,7 @@ import { RewardShoppingCartComponent } from './reward-shopping-cart.component';
export const routes: Routes = [
{
path: '',
title: 'Prämienshop - Warenkorb',
component: RewardShoppingCartComponent,
},
];

View File

@@ -0,0 +1,600 @@
# @isa/common/title-management
> Reusable title management patterns for Angular applications with reactive updates and tab integration.
## Overview
This library provides two complementary approaches for managing page titles in the ISA application:
1. **`IsaTitleStrategy`** - A custom TitleStrategy for route-based static titles
2. **`usePageTitle()`** - A reactive helper function for component-based dynamic titles
Both approaches automatically:
- Add the ISA prefix from config to all titles
- Update the TabService for multi-tab navigation
- Set the browser document title
## When to Use What
| Scenario | Recommended Approach |
|----------|---------------------|
| Static page title (never changes) | Route configuration with `IsaTitleStrategy` |
| Dynamic title based on user input (search, filters) | `usePageTitle()` in component |
| Title depends on loaded data (item name, ID) | `usePageTitle()` in component |
| Wizard/multi-step flows with changing steps | `usePageTitle()` in component |
| Combination of static base + dynamic suffix | Both (route + `usePageTitle()`) |
## Installation
This library is already installed and configured in your workspace. Import from:
```typescript
import { IsaTitleStrategy, usePageTitle } from '@isa/common/title-management';
```
## Setup
### 1. Configure IsaTitleStrategy in AppModule
To enable automatic title management for all routes, add the `IsaTitleStrategy` to your app providers:
```typescript
// apps/isa-app/src/app/app.module.ts
import { TitleStrategy } from '@angular/router';
import { IsaTitleStrategy } from '@isa/common/title-management';
@NgModule({
providers: [
{ provide: TitleStrategy, useClass: IsaTitleStrategy }
]
})
export class AppModule {}
```
**Note:** This replaces Angular's default `TitleStrategy` with our custom implementation that adds the ISA prefix and updates tabs.
## Usage
### Static Titles (Route Configuration)
For pages with fixed titles, simply add a `title` property to your route:
```typescript
// In your routing module
const routes: Routes = [
{
path: 'dashboard',
component: DashboardComponent,
title: 'Dashboard' // Will become "ISA - Dashboard"
},
{
path: 'artikelsuche',
component: ArticleSearchComponent,
title: 'Artikelsuche' // Will become "ISA - Artikelsuche"
},
{
path: 'returns',
component: ReturnsComponent,
title: 'Rückgaben' // Will become "ISA - Rückgaben"
}
];
```
The `IsaTitleStrategy` will automatically:
- Add the configured prefix (default: "ISA")
- Update the active tab name
- Set the document title
### Dynamic Titles (Component with Signals)
For pages where the title depends on component state, use the `usePageTitle()` helper:
#### Example 1: Search Page with Query Term
```typescript
import { Component, signal, computed } from '@angular/core';
import { usePageTitle } from '@isa/common/title-management';
@Component({
selector: 'app-article-search',
standalone: true,
template: `
<input [(ngModel)]="searchTerm" placeholder="Search..." />
<h1>{{ pageTitle().title }}</h1>
`
})
export class ArticleSearchComponent {
searchTerm = signal('');
// Computed signal that updates when searchTerm changes
pageTitle = computed(() => {
const term = this.searchTerm();
return {
title: term ? `Artikelsuche - "${term}"` : 'Artikelsuche'
};
});
constructor() {
// Title updates automatically when searchTerm changes!
usePageTitle(this.pageTitle);
}
}
```
**Result:**
- Initial load: `ISA - Artikelsuche`
- After searching "Laptop": `ISA - Artikelsuche - "Laptop"`
- Tab name also updates automatically
#### Example 2: Detail Page with Item Name
```typescript
import { Component, signal, computed, inject, OnInit } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { usePageTitle } from '@isa/common/title-management';
@Component({
selector: 'app-product-details',
standalone: true,
template: `<h1>{{ productName() || 'Loading...' }}</h1>`
})
export class ProductDetailsComponent implements OnInit {
private route = inject(ActivatedRoute);
productName = signal<string | null>(null);
pageTitle = computed(() => {
const name = this.productName();
return {
title: name ? `Produkt - ${name}` : 'Produkt Details'
};
});
constructor() {
usePageTitle(this.pageTitle);
}
ngOnInit() {
// Load product data...
this.productName.set('Samsung Galaxy S24');
}
}
```
#### Example 3: Combining Route Title with Dynamic Content
```typescript
import { Component, signal, computed, inject } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { usePageTitle } from '@isa/common/title-management';
@Component({
selector: 'app-order-wizard',
standalone: true,
template: `<div>Step {{ currentStep() }} of 3</div>`
})
export class OrderWizardComponent {
private route = inject(ActivatedRoute);
currentStep = signal(1);
pageTitle = computed(() => {
// Get base title from route config
const baseTitle = this.route.snapshot.title || 'Bestellung';
const step = this.currentStep();
return {
title: `${baseTitle} - Schritt ${step}/3`
};
});
constructor() {
usePageTitle(this.pageTitle);
}
}
```
### Nested Components (Parent/Child Routes)
When using `usePageTitle()` in nested component hierarchies (parent/child routes), the **deepest component automatically wins**. When the child component is destroyed (e.g., navigating away), the parent's title is automatically restored.
**This happens automatically** - no configuration or depth tracking needed!
#### Example: Dashboard → Settings Flow
```typescript
// Parent route: /dashboard
@Component({
selector: 'app-dashboard',
standalone: true,
template: `<router-outlet />`
})
export class DashboardComponent {
pageTitle = signal({ title: 'Dashboard' });
constructor() {
usePageTitle(this.pageTitle);
// Sets: "ISA - Dashboard"
}
}
// Child route: /dashboard/settings
@Component({
selector: 'app-settings',
standalone: true,
template: `<h1>Settings</h1>`
})
export class SettingsComponent {
pageTitle = signal({ title: 'Settings' });
constructor() {
usePageTitle(this.pageTitle);
// Sets: "ISA - Settings" (child wins!)
}
}
// Navigation flow:
// 1. Navigate to /dashboard → Title: "ISA - Dashboard"
// 2. Navigate to /dashboard/settings → Title: "ISA - Settings" (child takes over)
// 3. Navigate back to /dashboard → Title: "ISA - Dashboard" (parent restored automatically)
```
#### How It Works
The library uses an internal registry that tracks component creation order:
- **Last-registered (deepest) component controls the title**
- **Parent components' title updates are ignored** while child is active
- **Automatic cleanup** via Angular's `DestroyRef` - when child is destroyed, parent becomes active again
#### Real-World Scenario
```typescript
// Main page with search
@Component({...})
export class ArticleSearchComponent {
searchTerm = signal('');
pageTitle = computed(() => {
const term = this.searchTerm();
return {
title: term ? `Artikelsuche - "${term}"` : 'Artikelsuche'
};
});
constructor() {
usePageTitle(this.pageTitle); // "ISA - Artikelsuche"
}
}
// Detail view (child route)
@Component({...})
export class ArticleDetailsComponent {
articleName = signal('Samsung Galaxy S24');
pageTitle = computed(() => ({
title: `Artikel - ${this.articleName()}`
}));
constructor() {
usePageTitle(this.pageTitle); // "ISA - Artikel - Samsung Galaxy S24" (wins!)
}
}
// When user closes detail view → "ISA - Artikelsuche" is restored automatically
```
#### Important Notes
**Works with any nesting depth** - Grandparent → Parent → Child → Grandchild
**No manual depth tracking** - Registration order determines precedence
**Automatic restoration** - Parent title restored when child is destroyed
⚠️ **Parent signal updates are ignored** while child is active (by design!)
### Tab Subtitles
You can include a subtitle in the signal to display additional context in the tab:
```typescript
constructor() {
this.pageTitle = signal({
title: 'Dashboard',
subtitle: 'Active Orders'
});
usePageTitle(this.pageTitle);
}
```
**Use Cases for Subtitles:**
- **Status indicators**: `"Pending"`, `"Active"`, `"Completed"`
- **Context information**: `"3 items"`, `"Last updated: 2min ago"`
- **Category labels**: `"Customer"`, `"Order"`, `"Product"`
- **Step indicators**: `"Step 2 of 5"`, `"Review"`
#### Example: Order Processing with Status
```typescript
@Component({
selector: 'app-order-details',
standalone: true,
template: `
<h1>Order {{ orderId() }}</h1>
<p>Status: {{ orderStatus() }}</p>
`
})
export class OrderDetailsComponent {
orderId = signal('12345');
orderStatus = signal<'pending' | 'processing' | 'complete'>('pending');
// Status labels for subtitle
statusLabels = {
pending: 'Awaiting Payment',
processing: 'In Progress',
complete: 'Completed'
};
pageTitle = computed(() => ({
title: `Order ${this.orderId()}`,
subtitle: this.statusLabels[this.orderStatus()]
}));
constructor() {
// Title and subtitle both update dynamically
usePageTitle(this.pageTitle);
}
}
```
#### Example: Search Results with Count
```typescript
@Component({
selector: 'app-article-search',
standalone: true,
template: `...`
})
export class ArticleSearchComponent {
searchTerm = signal('');
resultCount = signal(0);
pageTitle = computed(() => {
const term = this.searchTerm();
const count = this.resultCount();
return {
title: term ? `Artikelsuche - "${term}"` : 'Artikelsuche',
subtitle: `${count} Ergebnis${count === 1 ? '' : 'se'}`
};
});
constructor() {
usePageTitle(this.pageTitle);
}
}
```
### Optional Title and Subtitle
Both `title` and `subtitle` are optional. When a property is `undefined`, it will not be updated:
#### Skip Title Update (Subtitle Only)
```typescript
// Only update the subtitle, keep existing document title
pageTitle = signal({ subtitle: '3 items' });
usePageTitle(this.pageTitle);
```
#### Skip Subtitle Update (Title Only)
```typescript
// Only update the title, no subtitle
pageTitle = signal({ title: 'Dashboard' });
usePageTitle(this.pageTitle);
```
#### Skip All Updates
```typescript
// Empty object - skips all updates
pageTitle = signal({});
usePageTitle(this.pageTitle);
```
#### Conditional Updates
```typescript
// Skip title update when data is not loaded
pageTitle = computed(() => {
const name = this.productName();
return {
title: name ? `Product - ${name}` : undefined,
subtitle: 'Loading...'
};
});
usePageTitle(this.pageTitle);
```
## Migration Guide
### From Resolver-Based Titles
If you're currently using a resolver for titles (e.g., `resolveTitle`), here's how to migrate:
**Before (with resolver):**
```typescript
// In resolver file
export const resolveTitle: (keyOrTitle: string) => ResolveFn<string> =
(keyOrTitle) => (route, state) => {
const config = inject(Config);
const title = inject(Title);
const tabService = inject(TabService);
const titleFromConfig = config.get(`process.titles.${keyOrTitle}`, z.string().default(keyOrTitle));
// ... manual title setting logic
return titleFromConfig;
};
// In routing module
{
path: 'dashboard',
component: DashboardComponent,
resolve: { title: resolveTitle('Dashboard') }
}
```
**After (with IsaTitleStrategy):**
```typescript
// No resolver needed - just use route config
{
path: 'dashboard',
component: DashboardComponent,
title: 'Dashboard' // Much simpler!
}
```
**For dynamic titles, use usePageTitle() instead:**
```typescript
// In component
pageTitle = computed(() => ({ title: this.dynamicTitle() }));
constructor() {
usePageTitle(this.pageTitle);
}
```
## API Reference
### `IsaTitleStrategy`
Custom TitleStrategy implementation that extends Angular's TitleStrategy.
**Methods:**
- `updateTitle(snapshot: RouterStateSnapshot): void` - Called automatically by Angular Router
**Dependencies:**
- `@isa/core/config` - For title prefix configuration
- `@isa/core/tabs` - For tab name updates
- `@angular/platform-browser` - For document title updates
**Configuration:**
```typescript
// In app providers
{ provide: TitleStrategy, useClass: IsaTitleStrategy }
```
### `usePageTitle(titleSubtitleSignal)`
Reactive helper function for managing dynamic component titles and subtitles.
**Parameters:**
- `titleSubtitleSignal: Signal<PageTitleInput>` - A signal containing optional title and subtitle
**Returns:** `void`
**Dependencies:**
- `@isa/core/config` - For title prefix configuration
- `@isa/core/tabs` - For tab name updates
- `@angular/platform-browser` - For document title updates
**Example:**
```typescript
const pageTitle = computed(() => ({
title: `Search - ${query()}`,
subtitle: `${count()} results`
}));
usePageTitle(pageTitle);
```
### `PageTitleInput`
Input interface for `usePageTitle()`.
**Properties:**
- `title?: string` - Optional page title (without ISA prefix). When undefined, document title is not updated.
- `subtitle?: string` - Optional subtitle to display in the tab. When undefined, tab subtitle is not updated.
## Best Practices
### ✅ Do
- Use route-based titles for static pages
- Use `usePageTitle()` for dynamic content-dependent titles
- Keep title signals computed from other signals for reactivity
- Use descriptive, user-friendly titles
- Combine route titles with component-level refinements for clarity
- Use subtitles for status indicators, context info, or step numbers
- Return `undefined` for title/subtitle when you want to skip updates
### ❌ Don't
- Add the "ISA" prefix manually (it's added automatically)
- Call `Title.setTitle()` directly (use these utilities instead)
- Create multiple effects updating the same title (use one computed signal)
- Put long, complex logic in title computations (keep them simple)
## Examples from ISA Codebase
### Artikelsuche (Search with Term)
```typescript
@Component({...})
export class ArticleSearchComponent {
searchTerm = signal('');
pageTitle = computed(() => {
const term = this.searchTerm();
return {
title: term ? `Artikelsuche - "${term}"` : 'Artikelsuche'
};
});
constructor() {
usePageTitle(this.pageTitle);
}
}
```
### Rückgabe Details (Return with ID)
```typescript
@Component({...})
export class ReturnDetailsComponent {
returnId = signal<string | null>(null);
pageTitle = computed(() => {
const id = this.returnId();
return {
title: id ? `Rückgabe - ${id}` : 'Rückgabe Details'
};
});
constructor() {
usePageTitle(this.pageTitle);
}
}
```
## Testing
Run tests for this library:
```bash
npx nx test common-title-management --skip-nx-cache
```
## Architecture Notes
This library is placed in the **common** domain (not core) because:
- It's a reusable utility pattern that features opt into
- Components can function without it (unlike core infrastructure)
- Provides patterns for solving a recurring problem (page titles)
- Similar to other common libraries like decorators and data-access utilities
## Related Libraries
- `@isa/core/config` - Configuration management
- `@isa/core/tabs` - Multi-tab navigation
- `@isa/core/navigation` - Navigation context preservation
## Support
For issues or questions, refer to the main ISA documentation or contact the development team.

View File

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

View File

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

View File

@@ -0,0 +1,3 @@
export * from './lib/isa-title.strategy';
export * from './lib/use-page-title.function';
export * from './lib/title-management.types';

View File

@@ -0,0 +1,157 @@
import { TestBed } from '@angular/core/testing';
import { Title } from '@angular/platform-browser';
import { RouterStateSnapshot } from '@angular/router';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { signal } from '@angular/core';
import { IsaTitleStrategy } from './isa-title.strategy';
import { TabService } from '@isa/core/tabs';
import { TITLE_PREFIX } from './title-prefix';
describe('IsaTitleStrategy', () => {
let strategy: IsaTitleStrategy;
let titleServiceMock: { setTitle: ReturnType<typeof vi.fn>; getTitle: ReturnType<typeof vi.fn> };
let tabServiceMock: {
activatedTabId: ReturnType<typeof signal<number | null>>;
patchTab: ReturnType<typeof vi.fn>;
};
beforeEach(() => {
// Arrange - Create mocks
titleServiceMock = {
setTitle: vi.fn(),
getTitle: vi.fn().mockReturnValue(''),
};
tabServiceMock = {
activatedTabId: signal<number | null>(123),
patchTab: vi.fn(),
};
TestBed.configureTestingModule({
providers: [
IsaTitleStrategy,
{ provide: Title, useValue: titleServiceMock },
{ provide: TITLE_PREFIX, useValue: 'ISA' },
{ provide: TabService, useValue: tabServiceMock },
],
});
strategy = TestBed.inject(IsaTitleStrategy);
});
it('should be created', () => {
expect(strategy).toBeTruthy();
});
describe('updateTitle', () => {
it('should set document title with ISA prefix', () => {
// Arrange
const mockSnapshot = {
url: '/dashboard',
} as RouterStateSnapshot;
// Mock buildTitle to return a specific title
vi.spyOn(strategy, 'buildTitle').mockReturnValue('Dashboard');
// Act
strategy.updateTitle(mockSnapshot);
// Assert
expect(titleServiceMock.setTitle).toHaveBeenCalledWith('ISA - Dashboard');
});
it('should update tab name via TabService', () => {
// Arrange
const mockSnapshot = {
url: '/search',
} as RouterStateSnapshot;
vi.spyOn(strategy, 'buildTitle').mockReturnValue('Artikelsuche');
// Act
strategy.updateTitle(mockSnapshot);
// Assert
expect(tabServiceMock.patchTab).toHaveBeenCalledWith(123, {
name: 'Artikelsuche',
});
});
it('should use custom prefix from TITLE_PREFIX', () => {
// Arrange - Reset TestBed and configure with custom TITLE_PREFIX
TestBed.resetTestingModule();
TestBed.configureTestingModule({
providers: [
IsaTitleStrategy,
{ provide: Title, useValue: titleServiceMock },
{ provide: TITLE_PREFIX, useValue: 'MyApp' }, // Custom prefix
{ provide: TabService, useValue: tabServiceMock },
],
});
const customStrategy = TestBed.inject(IsaTitleStrategy);
const mockSnapshot = {
url: '/settings',
} as RouterStateSnapshot;
vi.spyOn(customStrategy, 'buildTitle').mockReturnValue('Settings');
// Act
customStrategy.updateTitle(mockSnapshot);
// Assert
expect(titleServiceMock.setTitle).toHaveBeenCalledWith('MyApp - Settings');
});
it('should not update tab when activatedTabId is null', () => {
// Arrange
tabServiceMock.activatedTabId.set(null);
const mockSnapshot = {
url: '/dashboard',
} as RouterStateSnapshot;
vi.spyOn(strategy, 'buildTitle').mockReturnValue('Dashboard');
// Act
strategy.updateTitle(mockSnapshot);
// Assert
expect(titleServiceMock.setTitle).toHaveBeenCalledWith('ISA - Dashboard');
expect(tabServiceMock.patchTab).not.toHaveBeenCalled();
});
it('should not update anything when buildTitle returns empty string', () => {
// Arrange
const mockSnapshot = {
url: '/no-title',
} as RouterStateSnapshot;
vi.spyOn(strategy, 'buildTitle').mockReturnValue('');
// Act
strategy.updateTitle(mockSnapshot);
// Assert
expect(titleServiceMock.setTitle).not.toHaveBeenCalled();
expect(tabServiceMock.patchTab).not.toHaveBeenCalled();
});
it('should not update anything when buildTitle returns undefined', () => {
// Arrange
const mockSnapshot = {
url: '/no-title',
} as RouterStateSnapshot;
vi.spyOn(strategy, 'buildTitle').mockReturnValue(undefined as any);
// Act
strategy.updateTitle(mockSnapshot);
// Assert
expect(titleServiceMock.setTitle).not.toHaveBeenCalled();
expect(tabServiceMock.patchTab).not.toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,89 @@
import { Injectable, inject, Injector } from '@angular/core';
import { Title } from '@angular/platform-browser';
import { RouterStateSnapshot, TitleStrategy } from '@angular/router';
import { TabService } from '@isa/core/tabs';
import { TITLE_PREFIX } from './title-prefix';
/**
* Custom TitleStrategy for the ISA application that:
* 1. Automatically adds the ISA prefix from config to all page titles
* 2. Updates the TabService with the page title for tab management
* 3. Sets the browser document title
*
* @example
* ```typescript
* // In app.module.ts or app.config.ts
* import { TitleStrategy } from '@angular/router';
* import { IsaTitleStrategy } from '@isa/common/title-management';
*
* @NgModule({
* providers: [
* { provide: TitleStrategy, useClass: IsaTitleStrategy }
* ]
* })
* export class AppModule {}
* ```
*
* @example
* ```typescript
* // Usage in routes
* const routes: Routes = [
* {
* path: 'dashboard',
* component: DashboardComponent,
* title: 'Dashboard' // Will become "ISA - Dashboard"
* },
* {
* path: 'search',
* component: SearchComponent,
* title: 'Artikelsuche' // Will become "ISA - Artikelsuche"
* }
* ];
* ```
*/
@Injectable({ providedIn: 'root' })
export class IsaTitleStrategy extends TitleStrategy {
readonly #title = inject(Title);
readonly #injector = inject(Injector);
readonly #titlePrefix = inject(TITLE_PREFIX);
// Lazy injection to avoid circular dependency:
// TitleStrategy → TabService → UserStorageProvider → AuthService → Router → TitleStrategy
#getTabService() {
return this.#injector.get(TabService);
}
/**
* Updates the page title when navigation occurs.
* This method is called automatically by Angular's Router.
*
* @param snapshot - The router state snapshot containing route data
*/
override updateTitle(snapshot: RouterStateSnapshot): void {
const pageTitle = this.buildTitle(snapshot);
if (pageTitle) {
this.#updateTitleAndTab(pageTitle);
}
}
/**
* Updates both the document title and the tab name with the given page title.
*
* @param pageTitle - The page title to set (without prefix)
* @private
*/
#updateTitleAndTab(pageTitle: string): void {
const fullTitle = `${this.#titlePrefix} - ${pageTitle}`;
this.#title.setTitle(fullTitle);
const tabService = this.#getTabService();
const activeTabId = tabService.activatedTabId();
if (activeTabId !== null) {
tabService.patchTab(activeTabId, {
name: pageTitle,
});
}
}
}

View File

@@ -0,0 +1,29 @@
/**
* Input type for the `usePageTitle` helper function.
* Contains optional title and subtitle properties that can be set independently.
*/
export interface PageTitleInput {
/**
* Optional page title (without ISA prefix).
* When undefined, the document title will not be updated.
* @example
* ```typescript
* { title: 'Dashboard' }
* { title: searchTerm() ? `Search - "${searchTerm()}"` : undefined }
* ```
*/
title?: string;
/**
* Optional subtitle to display in the tab.
* When undefined, the tab subtitle will not be updated.
* Useful for status indicators, context information, or step numbers.
* @example
* ```typescript
* { subtitle: 'Active Orders' }
* { subtitle: 'Step 2 of 5' }
* { title: 'Order Details', subtitle: 'Pending' }
* ```
*/
subtitle?: string;
}

View File

@@ -0,0 +1,14 @@
import { inject, InjectionToken } from '@angular/core';
import { Config } from '@core/config';
import { z } from 'zod';
export const TITLE_PREFIX = new InjectionToken(
'isa.common.title-management.title-prefix',
{
providedIn: 'root',
factory: () => {
const config = inject(Config);
return config.get('title', z.string().default('ISA'));
},
},
);

View File

@@ -0,0 +1,255 @@
import { TestBed } from '@angular/core/testing';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { TitleRegistryService } from './title-registry.service';
describe('TitleRegistryService', () => {
let service: TitleRegistryService;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [TitleRegistryService],
});
service = TestBed.inject(TitleRegistryService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
describe('register', () => {
it('should register and immediately execute the updater', () => {
// Arrange
const updater = vi.fn();
// Act
service.register(updater);
// Assert
expect(updater).toHaveBeenCalledOnce();
});
it('should return a unique symbol for each registration', () => {
// Arrange
const updater1 = vi.fn();
const updater2 = vi.fn();
// Act
const id1 = service.register(updater1);
const id2 = service.register(updater2);
// Assert
expect(id1).not.toBe(id2);
expect(typeof id1).toBe('symbol');
expect(typeof id2).toBe('symbol');
});
it('should make the last registered updater the active one', () => {
// Arrange
const updater1 = vi.fn();
const updater2 = vi.fn();
const updater3 = vi.fn();
const id1 = service.register(updater1);
const id2 = service.register(updater2);
const id3 = service.register(updater3);
vi.clearAllMocks();
// Act - Only the last registered should execute
service.updateIfActive(id1, updater1);
service.updateIfActive(id2, updater2);
service.updateIfActive(id3, updater3);
// Assert
expect(updater1).not.toHaveBeenCalled();
expect(updater2).not.toHaveBeenCalled();
expect(updater3).toHaveBeenCalledOnce();
});
});
describe('unregister', () => {
it('should remove the registration', () => {
// Arrange
const updater = vi.fn();
const id = service.register(updater);
vi.clearAllMocks();
// Act
service.unregister(id);
service.updateIfActive(id, updater);
// Assert - Updater should not be called after unregistration
expect(updater).not.toHaveBeenCalled();
});
it('should restore the previous registration when active one is unregistered', () => {
// Arrange
const updater1 = vi.fn();
const updater2 = vi.fn();
const id1 = service.register(updater1);
const id2 = service.register(updater2);
vi.clearAllMocks();
// Act - Unregister the active one (id2)
service.unregister(id2);
// Assert - updater1 should have been called to restore title
expect(updater1).toHaveBeenCalledOnce();
expect(updater2).not.toHaveBeenCalled();
});
it('should make the previous registration active after unregistering current active', () => {
// Arrange
const updater1 = vi.fn();
const updater2 = vi.fn();
const id1 = service.register(updater1);
const id2 = service.register(updater2);
service.unregister(id2);
vi.clearAllMocks();
// Act - id1 should now be active
service.updateIfActive(id1, updater1);
service.updateIfActive(id2, updater2);
// Assert
expect(updater1).toHaveBeenCalledOnce();
expect(updater2).not.toHaveBeenCalled();
});
it('should handle unregistering a non-active registration', () => {
// Arrange
const updater1 = vi.fn();
const updater2 = vi.fn();
const updater3 = vi.fn();
const id1 = service.register(updater1);
const id2 = service.register(updater2);
const id3 = service.register(updater3);
vi.clearAllMocks();
// Act - Unregister the middle one (not active)
service.unregister(id2);
// Assert - id3 should still be active, updater2 should not be called
service.updateIfActive(id3, updater3);
expect(updater2).not.toHaveBeenCalled();
expect(updater3).toHaveBeenCalledOnce();
});
it('should handle unregistering when no registrations remain', () => {
// Arrange
const updater = vi.fn();
const id = service.register(updater);
vi.clearAllMocks();
// Act
service.unregister(id);
// Assert - No errors should occur
service.updateIfActive(id, updater);
expect(updater).not.toHaveBeenCalled();
});
});
describe('updateIfActive', () => {
it('should execute updater only if it is the active registration', () => {
// Arrange
const updater1 = vi.fn();
const updater2 = vi.fn();
const id1 = service.register(updater1);
const id2 = service.register(updater2); // This becomes active
vi.clearAllMocks();
// Act
service.updateIfActive(id1, updater1);
service.updateIfActive(id2, updater2);
// Assert
expect(updater1).not.toHaveBeenCalled(); // Not active
expect(updater2).toHaveBeenCalledOnce(); // Active
});
it('should not execute updater for unregistered id', () => {
// Arrange
const updater = vi.fn();
const id = service.register(updater);
service.unregister(id);
vi.clearAllMocks();
// Act
service.updateIfActive(id, updater);
// Assert
expect(updater).not.toHaveBeenCalled();
});
});
describe('nested component scenario', () => {
it('should handle parent → child → back to parent flow', () => {
// Arrange - Simulate parent component
const parentUpdater = vi.fn(() => 'Parent Title');
const parentId = service.register(parentUpdater);
vi.clearAllMocks();
// Act 1 - Child component registers
const childUpdater = vi.fn(() => 'Child Title');
const childId = service.register(childUpdater);
// Assert 1 - Child is active
expect(childUpdater).toHaveBeenCalledOnce();
vi.clearAllMocks();
// Act 2 - Child component is destroyed
service.unregister(childId);
// Assert 2 - Parent title is restored
expect(parentUpdater).toHaveBeenCalledOnce();
vi.clearAllMocks();
// Act 3 - Parent updates should work
service.updateIfActive(parentId, parentUpdater);
// Assert 3 - Parent is active again
expect(parentUpdater).toHaveBeenCalledOnce();
});
it('should handle three-level nesting (grandparent → parent → child)', () => {
// Arrange
const grandparentUpdater = vi.fn();
const parentUpdater = vi.fn();
const childUpdater = vi.fn();
const grandparentId = service.register(grandparentUpdater);
const parentId = service.register(parentUpdater);
const childId = service.register(childUpdater);
vi.clearAllMocks();
// Act 1 - Verify child is active
service.updateIfActive(childId, childUpdater);
expect(childUpdater).toHaveBeenCalledOnce();
// Act 2 - Remove child, parent should become active
service.unregister(childId);
expect(parentUpdater).toHaveBeenCalledOnce();
vi.clearAllMocks();
// Act 3 - Remove parent, grandparent should become active
service.unregister(parentId);
expect(grandparentUpdater).toHaveBeenCalledOnce();
});
});
});

View File

@@ -0,0 +1,79 @@
import { Injectable } from '@angular/core';
/**
* Internal service that tracks which component is currently managing the page title.
* This ensures that in nested component hierarchies (parent/child routes),
* the deepest (most recently registered) component's title takes precedence.
*
* When a child component is destroyed, the parent component's title is automatically restored.
*
* @internal
*/
@Injectable({ providedIn: 'root' })
export class TitleRegistryService {
#registrations = new Map<symbol, () => void>();
#activeRegistration: symbol | null = null;
/**
* Register a new title updater. The most recently registered updater
* becomes the active one and will control the title.
*
* This implements a stack-like behavior where the last component to register
* (deepest in the component hierarchy) takes precedence.
*
* @param updater - Function that updates the title
* @returns A symbol that uniquely identifies this registration
*/
register(updater: () => void): symbol {
const id = Symbol('title-registration');
this.#registrations.set(id, updater);
this.#activeRegistration = id;
// Execute the updater immediately since it's now active
updater();
return id;
}
/**
* Unregister a title updater. If this was the active updater,
* the previous one (if any) becomes active and its title is restored.
*
* This ensures that when a child component is destroyed, the parent
* component's title is automatically restored.
*
* @param id - The symbol identifying the registration to remove
*/
unregister(id: symbol): void {
this.#registrations.delete(id);
// If we just unregistered the active one, activate the most recent remaining one
if (this.#activeRegistration === id) {
// Get the last registration (most recent)
const entries = Array.from(this.#registrations.entries());
if (entries.length > 0) {
const [lastId, lastUpdater] = entries[entries.length - 1];
this.#activeRegistration = lastId;
// Restore the previous component's title
lastUpdater();
} else {
this.#activeRegistration = null;
}
}
}
/**
* Execute the updater only if it's the currently active registration.
*
* This prevents inactive (parent) components from overwriting the title
* set by active (child) components.
*
* @param id - The symbol identifying the registration
* @param updater - Function to execute if this registration is active
*/
updateIfActive(id: symbol, updater: () => void): void {
if (this.#activeRegistration === id) {
updater();
}
}
}

View File

@@ -0,0 +1,746 @@
import { TestBed } from '@angular/core/testing';
import { Component, signal, computed } from '@angular/core';
import { Title } from '@angular/platform-browser';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { usePageTitle } from './use-page-title.function';
import { TabService } from '@isa/core/tabs';
import { TITLE_PREFIX } from './title-prefix';
describe('usePageTitle', () => {
let titleServiceMock: { setTitle: ReturnType<typeof vi.fn>; getTitle: ReturnType<typeof vi.fn> };
let tabServiceMock: {
activatedTabId: ReturnType<typeof signal<number | null>>;
patchTab: ReturnType<typeof vi.fn>;
};
beforeEach(() => {
// Arrange - Create mocks
titleServiceMock = {
setTitle: vi.fn(),
getTitle: vi.fn().mockReturnValue(''),
};
tabServiceMock = {
activatedTabId: signal<number | null>(456),
patchTab: vi.fn(),
};
TestBed.configureTestingModule({
providers: [
{ provide: Title, useValue: titleServiceMock },
{ provide: TITLE_PREFIX, useValue: 'ISA' },
{ provide: TabService, useValue: tabServiceMock },
],
});
});
it('should set initial title on component creation', () => {
// Arrange
@Component({
standalone: true,
template: '<div>Test</div>',
})
class TestComponent {
pageTitle = signal({ title: 'Dashboard' });
constructor() {
usePageTitle(this.pageTitle);
}
}
// Act
TestBed.createComponent(TestComponent);
TestBed.flushEffects();
// Assert
expect(titleServiceMock.setTitle).toHaveBeenCalledWith('ISA - Dashboard');
expect(tabServiceMock.patchTab).toHaveBeenCalledWith(456, {
name: 'Dashboard',
});
});
it('should update title when signal changes', () => {
// Arrange
@Component({
standalone: true,
template: '<div>Test</div>',
})
class TestComponent {
searchTerm = signal('');
pageTitle = computed(() => {
const term = this.searchTerm();
return {
title: term ? `Artikelsuche - "${term}"` : 'Artikelsuche',
};
});
constructor() {
usePageTitle(this.pageTitle);
}
}
const fixture = TestBed.createComponent(TestComponent);
const component = fixture.componentInstance;
TestBed.flushEffects();
// Clear previous calls
vi.clearAllMocks();
// Act
component.searchTerm.set('Laptop');
TestBed.flushEffects();
// Assert
expect(titleServiceMock.setTitle).toHaveBeenCalledWith(
'ISA - Artikelsuche - "Laptop"'
);
expect(tabServiceMock.patchTab).toHaveBeenCalledWith(456, {
name: 'Artikelsuche - "Laptop"',
});
});
it('should not update tab when activatedTabId is null', () => {
// Arrange
tabServiceMock.activatedTabId.set(null);
@Component({
standalone: true,
template: '<div>Test</div>',
})
class TestComponent {
pageTitle = signal({ title: 'Profile' });
constructor() {
usePageTitle(this.pageTitle);
}
}
// Act
TestBed.createComponent(TestComponent);
TestBed.flushEffects();
// Assert
expect(titleServiceMock.setTitle).toHaveBeenCalledWith('ISA - Profile');
expect(tabServiceMock.patchTab).not.toHaveBeenCalled();
});
it('should use custom prefix from TITLE_PREFIX', () => {
// Arrange
TestBed.overrideProvider(TITLE_PREFIX, { useValue: 'MyApp' });
@Component({
standalone: true,
template: '<div>Test</div>',
})
class TestComponent {
pageTitle = signal({ title: 'About' });
constructor() {
usePageTitle(this.pageTitle);
}
}
// Act
TestBed.createComponent(TestComponent);
TestBed.flushEffects();
// Assert
expect(titleServiceMock.setTitle).toHaveBeenCalledWith('MyApp - About');
});
it('should handle multiple signal updates correctly', () => {
// Arrange
@Component({
standalone: true,
template: '<div>Test</div>',
})
class TestComponent {
counter = signal(0);
pageTitle = computed(() => ({ title: `Page ${this.counter()}` }));
constructor() {
usePageTitle(this.pageTitle);
}
}
const fixture = TestBed.createComponent(TestComponent);
const component = fixture.componentInstance;
TestBed.flushEffects();
// Clear initial call
vi.clearAllMocks();
// Act
component.counter.set(1);
TestBed.flushEffects();
component.counter.set(2);
TestBed.flushEffects();
component.counter.set(3);
TestBed.flushEffects();
// Assert
expect(titleServiceMock.setTitle).toHaveBeenCalledTimes(3);
expect(titleServiceMock.setTitle).toHaveBeenNthCalledWith(1, 'ISA - Page 1');
expect(titleServiceMock.setTitle).toHaveBeenNthCalledWith(2, 'ISA - Page 2');
expect(titleServiceMock.setTitle).toHaveBeenNthCalledWith(3, 'ISA - Page 3');
});
describe('nested components', () => {
it('should prioritize child component title over parent', () => {
// Arrange - Parent component
@Component({
standalone: true,
template: '<div>Parent</div>',
})
class ParentComponent {
pageTitle = signal({ title: 'Dashboard' });
constructor() {
usePageTitle(this.pageTitle);
}
}
// Child component
@Component({
standalone: true,
template: '<div>Child</div>',
})
class ChildComponent {
pageTitle = signal({ title: 'Settings' });
constructor() {
usePageTitle(this.pageTitle);
}
}
// Act - Create parent first
TestBed.createComponent(ParentComponent);
TestBed.flushEffects();
expect(titleServiceMock.setTitle).toHaveBeenCalledWith('ISA - Dashboard');
vi.clearAllMocks();
// Act - Create child (should win)
TestBed.createComponent(ChildComponent);
TestBed.flushEffects();
// Assert - Child title should be set
expect(titleServiceMock.setTitle).toHaveBeenCalledWith('ISA - Settings');
});
it('should restore parent title when child component is destroyed', () => {
// Arrange - Parent component
@Component({
standalone: true,
template: '<div>Parent</div>',
})
class ParentComponent {
pageTitle = signal({ title: 'Dashboard' });
constructor() {
usePageTitle(this.pageTitle);
}
}
// Child component
@Component({
standalone: true,
template: '<div>Child</div>',
})
class ChildComponent {
pageTitle = signal({ title: 'Settings' });
constructor() {
usePageTitle(this.pageTitle);
}
}
// Act - Create both components
TestBed.createComponent(ParentComponent);
TestBed.flushEffects();
const childFixture = TestBed.createComponent(ChildComponent);
TestBed.flushEffects();
vi.clearAllMocks();
// Act - Destroy child
childFixture.destroy();
// Assert - Parent title should be restored
expect(titleServiceMock.setTitle).toHaveBeenCalledWith('ISA - Dashboard');
});
it('should handle parent title updates when child is active', () => {
// Arrange - Parent component with mutable signal
@Component({
standalone: true,
template: '<div>Parent</div>',
})
class ParentComponent {
pageTitle = signal({ title: 'Dashboard' });
constructor() {
usePageTitle(this.pageTitle);
}
}
// Child component
@Component({
standalone: true,
template: '<div>Child</div>',
})
class ChildComponent {
pageTitle = signal({ title: 'Settings' });
constructor() {
usePageTitle(this.pageTitle);
}
}
// Act - Create both
const parentFixture = TestBed.createComponent(ParentComponent);
const parent = parentFixture.componentInstance;
TestBed.flushEffects();
TestBed.createComponent(ChildComponent);
TestBed.flushEffects();
vi.clearAllMocks();
// Act - Parent tries to update (should be ignored while child is active)
parent.pageTitle.set({ title: 'Dashboard Updated' });
TestBed.flushEffects();
// Assert - Title should still be child's (parent update ignored)
expect(titleServiceMock.setTitle).not.toHaveBeenCalled();
});
it('should allow parent title updates after child is destroyed', () => {
// Arrange - Parent component with mutable signal
@Component({
standalone: true,
template: '<div>Parent</div>',
})
class ParentComponent {
pageTitle = signal({ title: 'Dashboard' });
constructor() {
usePageTitle(this.pageTitle);
}
}
// Child component
@Component({
standalone: true,
template: '<div>Child</div>',
})
class ChildComponent {
pageTitle = signal({ title: 'Settings' });
constructor() {
usePageTitle(this.pageTitle);
}
}
// Act - Create both
const parentFixture = TestBed.createComponent(ParentComponent);
const parent = parentFixture.componentInstance;
TestBed.flushEffects();
const childFixture = TestBed.createComponent(ChildComponent);
TestBed.flushEffects();
// Destroy child
childFixture.destroy();
vi.clearAllMocks();
// Act - Parent updates now (should work)
parent.pageTitle.set({ title: 'Dashboard Updated' });
TestBed.flushEffects();
// Assert - Parent update should be reflected
expect(titleServiceMock.setTitle).toHaveBeenCalledWith('ISA - Dashboard Updated');
});
it('should handle three-level nesting (grandparent → parent → child)', () => {
// Arrange - Three levels of components
@Component({
standalone: true,
template: '<div>Grandparent</div>',
})
class GrandparentComponent {
pageTitle = signal({ title: 'Main' });
constructor() {
usePageTitle(this.pageTitle);
}
}
@Component({
standalone: true,
template: '<div>Parent</div>',
})
class ParentComponent {
pageTitle = signal({ title: 'Dashboard' });
constructor() {
usePageTitle(this.pageTitle);
}
}
@Component({
standalone: true,
template: '<div>Child</div>',
})
class ChildComponent {
pageTitle = signal({ title: 'Settings' });
constructor() {
usePageTitle(this.pageTitle);
}
}
// Act - Create all three
TestBed.createComponent(GrandparentComponent);
TestBed.flushEffects();
TestBed.createComponent(ParentComponent);
TestBed.flushEffects();
const childFixture = TestBed.createComponent(ChildComponent);
TestBed.flushEffects();
// Verify child is active
expect(titleServiceMock.setTitle).toHaveBeenLastCalledWith('ISA - Settings');
vi.clearAllMocks();
// Act - Destroy child
childFixture.destroy();
// Assert - Parent title should be restored
expect(titleServiceMock.setTitle).toHaveBeenCalledWith('ISA - Dashboard');
});
});
describe('subtitle', () => {
it('should set tab subtitle when provided', () => {
// Arrange
@Component({
standalone: true,
template: '<div>Test</div>',
})
class TestComponent {
pageTitle = signal({ title: 'Dashboard', subtitle: 'Overview' });
constructor() {
usePageTitle(this.pageTitle);
}
}
// Act
TestBed.createComponent(TestComponent);
TestBed.flushEffects();
// Assert
expect(tabServiceMock.patchTab).toHaveBeenCalledWith(456, {
name: 'Dashboard',
subtitle: 'Overview',
});
});
it('should not include subtitle in patchTab when not provided', () => {
// Arrange
@Component({
standalone: true,
template: '<div>Test</div>',
})
class TestComponent {
pageTitle = signal({ title: 'Dashboard' });
constructor() {
usePageTitle(this.pageTitle);
}
}
// Act
TestBed.createComponent(TestComponent);
TestBed.flushEffects();
// Assert
const callArgs = tabServiceMock.patchTab.mock.calls[0];
expect(callArgs[0]).toBe(456);
expect(callArgs[1]).toEqual({ name: 'Dashboard' });
expect(callArgs[1]).not.toHaveProperty('subtitle');
});
it('should maintain subtitle across title signal changes', () => {
// Arrange
@Component({
standalone: true,
template: '<div>Test</div>',
})
class TestComponent {
searchTerm = signal('');
pageTitle = computed(() => {
const term = this.searchTerm();
return {
title: term ? `Search - "${term}"` : 'Search',
subtitle: 'Active',
};
});
constructor() {
usePageTitle(this.pageTitle);
}
}
const fixture = TestBed.createComponent(TestComponent);
const component = fixture.componentInstance;
TestBed.flushEffects();
vi.clearAllMocks();
// Act
component.searchTerm.set('Laptop');
TestBed.flushEffects();
// Assert
expect(tabServiceMock.patchTab).toHaveBeenCalledWith(456, {
name: 'Search - "Laptop"',
subtitle: 'Active',
});
});
it('should handle nested components each with different subtitles', () => {
// Arrange - Parent component
@Component({
standalone: true,
template: '<div>Parent</div>',
})
class ParentComponent {
pageTitle = signal({ title: 'Dashboard', subtitle: 'Main' });
constructor() {
usePageTitle(this.pageTitle);
}
}
// Child component
@Component({
standalone: true,
template: '<div>Child</div>',
})
class ChildComponent {
pageTitle = signal({ title: 'Settings', subtitle: 'Preferences' });
constructor() {
usePageTitle(this.pageTitle);
}
}
// Act - Create parent first
TestBed.createComponent(ParentComponent);
TestBed.flushEffects();
expect(tabServiceMock.patchTab).toHaveBeenCalledWith(456, {
name: 'Dashboard',
subtitle: 'Main',
});
vi.clearAllMocks();
// Act - Create child (should win)
TestBed.createComponent(ChildComponent);
TestBed.flushEffects();
// Assert - Child subtitle should be set
expect(tabServiceMock.patchTab).toHaveBeenCalledWith(456, {
name: 'Settings',
subtitle: 'Preferences',
});
});
});
describe('optional title/subtitle', () => {
it('should skip document title update when title is undefined', () => {
// Arrange
@Component({
standalone: true,
template: '<div>Test</div>',
})
class TestComponent {
pageTitle = signal({ subtitle: 'Loading' });
constructor() {
usePageTitle(this.pageTitle);
}
}
// Act
TestBed.createComponent(TestComponent);
TestBed.flushEffects();
// Assert
expect(titleServiceMock.setTitle).not.toHaveBeenCalled();
expect(tabServiceMock.patchTab).toHaveBeenCalledWith(456, {
subtitle: 'Loading',
});
});
it('should skip tab subtitle update when subtitle is undefined', () => {
// Arrange
@Component({
standalone: true,
template: '<div>Test</div>',
})
class TestComponent {
pageTitle = signal({ title: 'Dashboard' });
constructor() {
usePageTitle(this.pageTitle);
}
}
// Act
TestBed.createComponent(TestComponent);
TestBed.flushEffects();
// Assert
expect(titleServiceMock.setTitle).toHaveBeenCalledWith('ISA - Dashboard');
const callArgs = tabServiceMock.patchTab.mock.calls[0];
expect(callArgs[1]).toEqual({ name: 'Dashboard' });
expect(callArgs[1]).not.toHaveProperty('subtitle');
});
it('should handle empty object (skip all updates)', () => {
// Arrange
@Component({
standalone: true,
template: '<div>Test</div>',
})
class TestComponent {
pageTitle = signal({});
constructor() {
usePageTitle(this.pageTitle);
}
}
// Act
TestBed.createComponent(TestComponent);
TestBed.flushEffects();
// Assert
expect(titleServiceMock.setTitle).not.toHaveBeenCalled();
expect(tabServiceMock.patchTab).not.toHaveBeenCalled();
});
it('should handle dynamic changes from defined to undefined', () => {
// Arrange
@Component({
standalone: true,
template: '<div>Test</div>',
})
class TestComponent {
showTitle = signal(true);
pageTitle = computed(() => ({
title: this.showTitle() ? 'Dashboard' : undefined,
subtitle: 'Active',
}));
constructor() {
usePageTitle(this.pageTitle);
}
}
const fixture = TestBed.createComponent(TestComponent);
const component = fixture.componentInstance;
TestBed.flushEffects();
// Verify initial state
expect(titleServiceMock.setTitle).toHaveBeenCalledWith('ISA - Dashboard');
vi.clearAllMocks();
// Act - Hide title
component.showTitle.set(false);
TestBed.flushEffects();
// Assert - Title should not be updated, but subtitle should
expect(titleServiceMock.setTitle).not.toHaveBeenCalled();
expect(tabServiceMock.patchTab).toHaveBeenCalledWith(456, {
subtitle: 'Active',
});
});
it('should handle both title and subtitle', () => {
// Arrange
@Component({
standalone: true,
template: '<div>Test</div>',
})
class TestComponent {
pageTitle = signal({ title: 'Orders', subtitle: '3 items' });
constructor() {
usePageTitle(this.pageTitle);
}
}
// Act
TestBed.createComponent(TestComponent);
TestBed.flushEffects();
// Assert
expect(titleServiceMock.setTitle).toHaveBeenCalledWith('ISA - Orders');
expect(tabServiceMock.patchTab).toHaveBeenCalledWith(456, {
name: 'Orders',
subtitle: '3 items',
});
});
it('should handle subtitle only (no title)', () => {
// Arrange
@Component({
standalone: true,
template: '<div>Test</div>',
})
class TestComponent {
count = signal(5);
pageTitle = computed(() => ({
subtitle: `${this.count()} items`,
}));
constructor() {
usePageTitle(this.pageTitle);
}
}
const fixture = TestBed.createComponent(TestComponent);
const component = fixture.componentInstance;
TestBed.flushEffects();
// Assert initial
expect(titleServiceMock.setTitle).not.toHaveBeenCalled();
expect(tabServiceMock.patchTab).toHaveBeenCalledWith(456, {
subtitle: '5 items',
});
vi.clearAllMocks();
// Act - Update count
component.count.set(10);
TestBed.flushEffects();
// Assert - Only subtitle updates
expect(titleServiceMock.setTitle).not.toHaveBeenCalled();
expect(tabServiceMock.patchTab).toHaveBeenCalledWith(456, {
subtitle: '10 items',
});
});
});
});

View File

@@ -0,0 +1,203 @@
import { inject, effect, Signal, DestroyRef } from '@angular/core';
import { Title } from '@angular/platform-browser';
import { TabService } from '@isa/core/tabs';
import { PageTitleInput } from './title-management.types';
import { TitleRegistryService } from './title-registry.service';
import { TITLE_PREFIX } from './title-prefix';
/**
* Reactive helper function for managing dynamic page titles and subtitles in components.
* Uses Angular signals and effects to automatically update the document title
* and tab name/subtitle whenever the provided signal changes.
*
* This is ideal for pages where the title depends on component state, such as:
* - Search pages with query terms
* - Detail pages with item names
* - Wizard flows with step names
* - Filter pages with applied filters
* - Status indicators with changing subtitles
*
* @param titleSubtitleSignal - A signal containing optional title and subtitle
*
* @example
* ```typescript
* // Basic usage with static title
* import { Component, signal } from '@angular/core';
* import { usePageTitle } from '@isa/common/title-management';
*
* @Component({
* selector: 'app-dashboard',
* template: `<h1>Dashboard</h1>`
* })
* export class DashboardComponent {
* pageTitle = signal({ title: 'Dashboard' });
*
* constructor() {
* usePageTitle(this.pageTitle);
* }
* }
* ```
*
* @example
* ```typescript
* // Dynamic title with search term
* import { Component, signal, computed } from '@angular/core';
* import { usePageTitle } from '@isa/common/title-management';
*
* @Component({
* selector: 'app-article-search',
* template: `<input [(ngModel)]="searchTerm" />`
* })
* export class ArticleSearchComponent {
* searchTerm = signal('');
*
* pageTitle = computed(() => {
* const term = this.searchTerm();
* return {
* title: term ? `Artikelsuche - "${term}"` : 'Artikelsuche'
* };
* });
*
* constructor() {
* usePageTitle(this.pageTitle);
* // Title updates automatically when searchTerm changes!
* }
* }
* ```
*
* @example
* ```typescript
* // Title with subtitle
* import { Component, signal, computed } from '@angular/core';
* import { usePageTitle } from '@isa/common/title-management';
*
* @Component({
* selector: 'app-order-details',
* template: `<h1>Order Details</h1>`
* })
* export class OrderDetailsComponent {
* orderId = signal('12345');
* orderStatus = signal<'pending' | 'processing' | 'complete'>('pending');
*
* statusLabels = {
* pending: 'Awaiting Payment',
* processing: 'In Progress',
* complete: 'Completed'
* };
*
* pageTitle = computed(() => ({
* title: `Order ${this.orderId()}`,
* subtitle: this.statusLabels[this.orderStatus()]
* }));
*
* constructor() {
* usePageTitle(this.pageTitle);
* }
* }
* ```
*
* @example
* ```typescript
* // Skip title when undefined (e.g., data not loaded yet)
* import { Component, signal, computed } from '@angular/core';
* import { usePageTitle } from '@isa/common/title-management';
*
* @Component({
* selector: 'app-product-details',
* template: `<h1>{{ productName() || 'Loading...' }}</h1>`
* })
* export class ProductDetailsComponent {
* productName = signal<string | null>(null);
*
* pageTitle = computed(() => {
* const name = this.productName();
* return {
* title: name ? `Product - ${name}` : undefined
* };
* });
*
* constructor() {
* usePageTitle(this.pageTitle);
* // Title only updates when productName is not null
* }
* }
* ```
*
* @example
* ```typescript
* // Nested components - child component's title automatically takes precedence
* // Parent component (route: /dashboard)
* export class DashboardComponent {
* pageTitle = signal({ title: 'Dashboard' });
* constructor() {
* usePageTitle(this.pageTitle); // "ISA - Dashboard"
* }
* }
*
* // Child component (route: /dashboard/settings)
* export class SettingsComponent {
* pageTitle = signal({ title: 'Settings' });
* constructor() {
* usePageTitle(this.pageTitle); // "ISA - Settings" (wins!)
* }
* }
* // When SettingsComponent is destroyed → "ISA - Dashboard" (automatically restored)
* ```
*/
export function usePageTitle(
titleSubtitleSignal: Signal<PageTitleInput>
): void {
const title = inject(Title);
const titlePrefix = inject(TITLE_PREFIX);
const tabService = inject(TabService);
const registry = inject(TitleRegistryService);
const destroyRef = inject(DestroyRef);
// Create the updater function that will be called by the registry
const updateTitle = () => {
const { title: pageTitle, subtitle } = titleSubtitleSignal();
// Update document title if title is defined
if (pageTitle !== undefined) {
const fullTitle = `${titlePrefix} - ${pageTitle}`;
title.setTitle(fullTitle);
}
// Update tab if activeTabId exists
const activeTabId = tabService.activatedTabId();
if (activeTabId !== null) {
// Build patch object conditionally based on what's defined
const patch: { name?: string; subtitle?: string } = {};
if (pageTitle !== undefined) {
patch.name = pageTitle;
}
if (subtitle !== undefined) {
patch.subtitle = subtitle;
}
// Only patch if we have something to update
if (Object.keys(patch).length > 0) {
tabService.patchTab(activeTabId, patch);
}
}
};
// Register with the registry (this will execute updateTitle immediately)
const registrationId = registry.register(updateTitle);
// Automatically unregister when component is destroyed
destroyRef.onDestroy(() => {
registry.unregister(registrationId);
});
// React to signal changes, but only update if this is the active registration
effect(() => {
// Access the signal to track it as a dependency
titleSubtitleSignal();
// Only update if this component is the active one
registry.updateIfActive(registrationId, updateTitle);
});
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,33 @@
/// <reference types='vitest' />
import { defineConfig } from 'vite';
import angular from '@analogjs/vite-plugin-angular';
import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin';
import { nxCopyAssetsPlugin } from '@nx/vite/plugins/nx-copy-assets.plugin';
export default
// @ts-expect-error - Vitest reporter tuple types have complex inference issues, but config works correctly at runtime
defineConfig(() => ({
root: __dirname,
cacheDir: '../../../node_modules/.vite/libs/common/title-management',
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-common-title-management.xml' }],
],
coverage: {
reportsDirectory: '../../../coverage/libs/common/title-management',
provider: 'v8' as const,
reporter: ['text', 'cobertura'],
},
},
}));

View File

@@ -1,4 +1,4 @@
import { inject, Injectable } from '@angular/core';
import { inject, Injectable, Injector } from '@angular/core';
import { StorageProvider } from './storage-provider';
import {
ResponseArgsOfUserState,
@@ -32,9 +32,13 @@ const DEFAULT_USER_STATE_RESPONSE: ResponseArgsOfUserState = {
export class UserStorageProvider implements StorageProvider {
#logger = logger(() => ({ service: 'UserStorageProvider' }));
#userStateService = inject(UserStateService);
#authService = inject(AuthService);
#injector = inject(Injector);
#loadUserState = this.#authService.authenticated$.pipe(
#getAuthService() {
return this.#injector.get(AuthService);
}
#loadUserState = this.#getAuthService().authenticated$.pipe(
filter((authenticated) => authenticated),
switchMap(() =>
this.#userStateService.UserStateGetUserState().pipe(

View File

@@ -142,6 +142,7 @@ Each tab is stored as an NgRx entity with the following structure:
interface Tab {
id: number; // Unique identifier
name: string; // Display name
subtitle: string; // Subtitle (defaults to empty string)
createdAt: number; // Creation timestamp (ms)
activatedAt?: number; // Last activation timestamp
metadata: Record<string, unknown>; // Flexible metadata storage
@@ -295,6 +296,7 @@ Creates and adds a new tab to the store.
**Parameters:**
- `params: AddTabInput` - Tab creation parameters (validated with Zod)
- `name?: string` - Display name (default: 'Neuer Vorgang')
- `subtitle?: string` - Subtitle (default: '')
- `tags?: string[]` - Initial tags (default: [])
- `metadata?: Record<string, unknown>` - Initial metadata (default: {})
- `id?: number` - Optional ID (auto-generated if omitted)
@@ -306,6 +308,7 @@ Creates and adds a new tab to the store.
```typescript
const tab = this.#tabService.addTab({
name: 'Customer Order #1234',
subtitle: 'Pending approval',
tags: ['order', 'customer'],
metadata: { orderId: 1234, status: 'pending' }
});
@@ -337,6 +340,7 @@ Partially updates a tab's properties.
- `id: number` - Tab ID to update
- `changes: PatchTabInput` - Partial tab updates
- `name?: string` - Updated display name
- `subtitle?: string` - Updated subtitle
- `tags?: string[]` - Updated tags array
- `metadata?: Record<string, unknown>` - Metadata to merge
- `location?: TabLocationHistory` - Updated location history
@@ -345,6 +349,7 @@ Partially updates a tab's properties.
```typescript
this.#tabService.patchTab(42, {
name: 'Updated Name',
subtitle: 'New subtitle',
tags: ['new-tag'],
metadata: { additionalField: 'value' }
});

View File

@@ -100,6 +100,8 @@ export const TabSchema = z.object({
id: z.number(),
/** Display name for the tab (minimum 1 character) */
name: z.string().default('Neuer Vorgang'),
/** Subtitle for the tab */
subtitle: z.string().default(''),
/** Creation timestamp (milliseconds since epoch) */
createdAt: z.number(),
/** Last activation timestamp (optional) */
@@ -123,6 +125,8 @@ export interface Tab {
id: number;
/** Display name for the tab */
name: string;
/** Subtitle for the tab */
subtitle: string;
/** Creation timestamp */
createdAt: number;
/** Last activation timestamp */
@@ -146,6 +150,8 @@ export interface TabCreate {
id?: number;
/** Display name for the tab */
name: string;
/** Subtitle for the tab */
subtitle?: string;
/** Creation timestamp */
createdAt: number;
/** Last activation timestamp */
@@ -170,6 +176,8 @@ export const PersistedTabSchema = z
id: z.number(),
/** Tab display name */
name: z.string().default('Neuer Vorgang'),
/** Tab subtitle */
subtitle: z.string(),
/** Creation timestamp */
createdAt: z.number(),
/** Last activation timestamp */
@@ -195,6 +203,8 @@ export type TabInput = z.input<typeof TabSchema>;
export const AddTabSchema = z.object({
/** Display name for the new tab */
name: z.string().default('Neuer Vorgang'),
/** Subtitle for the new tab */
subtitle: z.string().default(''),
/** Initial tags for the tab */
tags: TabTagsSchema,
/** Initial metadata for the tab */
@@ -221,6 +231,8 @@ export const TabUpdateSchema = z
.object({
/** Updated display name */
name: z.string().min(1).optional(),
/** Updated subtitle */
subtitle: z.string().optional(),
/** Updated activation timestamp */
activatedAt: z.number().optional(),
/** Updated metadata object */

View File

@@ -63,6 +63,7 @@ export const TabService = signalStore(
const tab: Tab = {
id: parsed.id ?? store._generateId(),
name: parsed.name,
subtitle: parsed.subtitle,
createdAt: Date.now(),
activatedAt: parsed.activatedAt,
tags: parsed.tags,

View File

@@ -1,31 +1,34 @@
import { Routes } from '@angular/router';
import { ReturnSearchMainComponent } from './return-search-main/return-search-main.component';
import { ReturnSearchComponent } from './return-search.component';
import { querySettingsResolverFn } from './resolvers/query-settings.resolver-fn';
import { ReturnSearchResultComponent } from './return-search-result/return-search-result.component';
export const routes: Routes = [
{
path: '',
component: ReturnSearchComponent,
resolve: { querySettings: querySettingsResolverFn },
children: [
{ path: '', component: ReturnSearchMainComponent },
{
path: 'receipts',
component: ReturnSearchResultComponent,
data: { scrollPositionRestoration: true },
},
],
},
{
path: 'receipt',
loadChildren: () =>
import('@isa/oms/feature/return-details').then((feat) => feat.routes),
},
{
path: 'process',
loadChildren: () =>
import('@isa/oms/feature/return-process').then((feat) => feat.routes),
},
];
import { Routes } from '@angular/router';
import { ReturnSearchMainComponent } from './return-search-main/return-search-main.component';
import { ReturnSearchComponent } from './return-search.component';
import { querySettingsResolverFn } from './resolvers/query-settings.resolver-fn';
import { ReturnSearchResultComponent } from './return-search-result/return-search-result.component';
export const routes: Routes = [
{
path: '',
component: ReturnSearchComponent,
resolve: { querySettings: querySettingsResolverFn },
title: 'Retoure',
children: [
{ path: '', component: ReturnSearchMainComponent },
{
path: 'receipts',
component: ReturnSearchResultComponent,
data: { scrollPositionRestoration: true },
},
],
},
{
path: 'receipt',
title: 'Retoure - Beleg',
loadChildren: () =>
import('@isa/oms/feature/return-details').then((feat) => feat.routes),
},
{
path: 'process',
title: 'Retoure - Prozess',
loadChildren: () =>
import('@isa/oms/feature/return-process').then((feat) => feat.routes),
},
];

View File

@@ -1,31 +1,33 @@
import { Routes } from '@angular/router';
import { RemissionListComponent } from './remission-list.component';
import { querySettingsResolverFn } from './resolvers/query-settings.resolver-fn';
import { querySettingsDepartmentResolverFn } from './resolvers/query-settings-department.resolver-fn';
import { RemissionListType } from '@isa/remission/data-access';
export const routes: Routes = [
{
path: 'mandatory',
component: RemissionListComponent,
resolve: { querySettings: querySettingsResolverFn },
data: {
scrollPositionRestoration: true,
remiType: RemissionListType.Pflicht,
},
},
{
path: 'department',
component: RemissionListComponent,
resolve: { querySettings: querySettingsDepartmentResolverFn },
data: {
scrollPositionRestoration: true,
remiType: RemissionListType.Abteilung,
},
},
{
path: '',
redirectTo: 'mandatory',
pathMatch: 'full',
},
];
import { Routes } from '@angular/router';
import { RemissionListComponent } from './remission-list.component';
import { querySettingsResolverFn } from './resolvers/query-settings.resolver-fn';
import { querySettingsDepartmentResolverFn } from './resolvers/query-settings-department.resolver-fn';
import { RemissionListType } from '@isa/remission/data-access';
export const routes: Routes = [
{
path: 'mandatory',
component: RemissionListComponent,
resolve: { querySettings: querySettingsResolverFn },
title: 'Remission - Pflicht',
data: {
scrollPositionRestoration: true,
remiType: RemissionListType.Pflicht,
},
},
{
path: 'department',
component: RemissionListComponent,
resolve: { querySettings: querySettingsDepartmentResolverFn },
title: 'Remission - Abteilung',
data: {
scrollPositionRestoration: true,
remiType: RemissionListType.Abteilung,
},
},
{
path: '',
redirectTo: 'mandatory',
pathMatch: 'full',
},
];

View File

@@ -4,12 +4,14 @@ export const routes: Routes = [
{
path: '',
component: RemissionReturnReceiptListComponent,
title: 'Remission',
data: {
scrollPositionRestoration: true,
},
},
{
path: ':returnId/:receiptId',
title: 'Remission - Beleg',
loadComponent: () =>
import('@isa/remission/feature/remission-return-receipt-details').then(
(m) => m.RemissionReturnReceiptDetailsComponent,

6
package-lock.json generated
View File

@@ -13674,9 +13674,9 @@
"license": "MIT"
},
"node_modules/@types/react": {
"version": "19.2.6",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.6.tgz",
"integrity": "sha512-p/jUvulfgU7oKtj6Xpk8cA2Y1xKTtICGpJYeJXz2YVO2UcvjQgeRMLDGfDeqeRW2Ta+0QNFwcc8X3GH8SxZz6w==",
"version": "19.2.7",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz",
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
"dev": true,
"license": "MIT",
"peer": true,

View File

@@ -65,6 +65,9 @@
"@isa/common/data-access": ["libs/common/data-access/src/index.ts"],
"@isa/common/decorators": ["libs/common/decorators/src/index.ts"],
"@isa/common/print": ["libs/common/print/src/index.ts"],
"@isa/common/title-management": [
"libs/common/title-management/src/index.ts"
],
"@isa/core/auth": ["libs/core/auth/src/index.ts"],
"@isa/core/config": ["libs/core/config/src/index.ts"],
"@isa/core/logging": ["libs/core/logging/src/index.ts"],