Compare commits

...

25 Commits

Author SHA1 Message Date
Lorenz Hilpert
0a1f25a1ee 📝 docs(shell): update READMEs for shell and connectivity libraries
- Expand core-connectivity README with usage examples and API docs
- Add TabsCollapsedService documentation to shell-common README
- Update shell-layout README with responsive behavior documentation
- Update library-reference.md with current library descriptions
2025-12-10 20:40:12 +01:00
Lorenz Hilpert
609a7ed6dd ️ feat(shell): add E2E testing and ARIA accessibility attributes
- Add data-what/data-which attributes for E2E test targeting
- Add ARIA roles (tablist, tab, group) for screen readers
- Add aria-label attributes for interactive elements
- Add aria-current for active tab indication
2025-12-10 20:37:23 +01:00
Lorenz Hilpert
5bebd3de4d feat(shell): add logging and JSDoc documentation across shell components
- Add @isa/core/logging to services and components with actions
- Add comprehensive JSDoc documentation with @example blocks
- Fix typo: TabsCollabsedService → TabsCollapsedService
- Add error handling with try/catch for async operations
- Add tap operator for logging in NetworkStatusService
2025-12-10 20:36:58 +01:00
Lorenz Hilpert
b7e69dacf7 Merge branch 'develop' into feature/5087-application-shell
# Conflicts:
#	libs/ui/layout/src/index.ts
2025-12-10 20:10:03 +01:00
Lorenz Hilpert
a8cca9143e Merge branch 'develop' into feature/5087-application-shell 2025-12-10 20:08:43 +01:00
Lorenz Hilpert
16b9761573 💡 docs(shell): add JSDoc documentation for shell components and services
Add comprehensive JSDoc comments to shell layout, navigation, and tabs
components to improve code documentation and IDE support.

- Document TabsCollabsedService with usage examples
- Document ShellLayoutComponent with feature descriptions
- Document ShellNavigationItemComponent with signal and method descriptions
- Document ShellNavigationSubItemComponent with route resolution details
- Document navigations configuration with route type explanations
2025-12-10 20:07:54 +01:00
Lorenz Hilpert
7a86fcf507 feat(shell): add tabs collapsed state service and navigation indicators
Add TabsCollabsedService to manage tabs bar collapsed/expanded state with
proximity-based switching. Implement activity indicators for navigation items
that bubble up from child routes when menu is collapsed.

- Add TabsCollabsedService for centralized tabs state management
- Add indicator support to navigation types and components
- Implement indicator bubbling from children to parent when collapsed
- Update shell-layout with click-outside-to-close for mobile navigation
- Add dynamic route resolution with factory functions and signals
- Update shell-tabs with compact mode and proximity detection
2025-12-10 20:07:35 +01:00
Lorenz Hilpert
1cc13eebe1 feat(shell): enhance tab routing and responsive navigation
- Add tab route injection utilities (injectTabRoute, injectLabeledTabRoute,
  injectLegacyTabRoute, injectLabeledLegacyTabRoute) for dynamic tab-aware navigation
- Improve TabService with tabsByActivationOrder computed, removeTab returning
  previous tab, and removeAllTabs method
- Implement slide-in/slide-out animations for navigation on tablet viewports
- Update navigation config to use new tab route injection utilities
- Add dynamic date calculation for package inspection filter
- Enhance shell-tabs with close functionality, keyboard navigation, and
  proximity-based compact mode
- Update shell-layout to handle navigation visibility and margin responsively
- Add comprehensive JSDoc documentation to navigation types
- Update README documentation for navigation and tabs libraries
2025-12-08 17:51:10 +01:00
Lorenz Hilpert
44426109bd feat(shell-layout): integrate tabs and responsive element positioning
- Add ShellTabsComponent to the header section
- Use UiElementSizeObserverDirective for dynamic margin calculations
- Apply fixed positioning for header and navigation with proper z-index
- Calculate main content margins based on header/navigation dimensions
2025-12-05 21:05:45 +01:00
Lorenz Hilpert
bb9e9ff90e feat(shell-tabs): scaffold shell-tabs library with tab components
Adds new shell-tabs library with ShellTabsComponent and ShellTabItemComponent
for managing application tabs in the shell header area.
2025-12-05 21:05:31 +01:00
Lorenz Hilpert
e5c7c18c40 feat(ui-layout): add UiElementSizeObserverDirective for reactive element sizing
Adds a directive that uses ResizeObserver to track element dimensions
and exposes them as signals for reactive positioning calculations.
2025-12-05 21:05:20 +01:00
Lorenz Hilpert
e3c60f14f7 feat(shell-navigation): implement navigation components with collapsible menu
Add modular navigation system with three reusable components:
- NavigationGroupComponent for grouped navigation sections
- NavigationItemComponent for main navigation entries with expand/collapse
- NavigationSubItemComponent for nested child routes

Include navigation types, route configuration with dynamic tabId support,
and integrate navigation into shell-layout sidebar.
2025-12-04 22:15:35 +01:00
Lorenz Hilpert
5fe85282e7 📝 docs(shell): update READMEs and library reference
Update documentation for shell-header, shell-layout, shell-notifications,
and shell-common libraries with API references, E2E attributes, and
accessibility documentation.
2025-12-03 21:18:34 +01:00
Lorenz Hilpert
9a8eac3f9a 🎨 style(icons): reformat icons file and add new icons
Reformat SVG exports for consistent code style.
Add new icons: isaFiliale, isaFilialeLocation, isaArtikel variants.
2025-12-03 21:18:16 +01:00
Lorenz Hilpert
93752efb9d feat(shell-layout): integrate shell-header component
Add ShellHeaderComponent to layout and configure path aliases for new
shell-common and shell-notifications libraries.
2025-12-03 21:17:36 +01:00
Lorenz Hilpert
0c546802fa feat(core-auth): add AuthService for logout functionality
Add AuthService wrapping OAuthService logout with proper logging.
Refactor RoleService to use private class fields (#) convention.
2025-12-03 21:17:15 +01:00
Lorenz Hilpert
3ed3d0b466 ♻️ refactor(shell-header): restructure with modular sub-components
Reorganize header into focused sub-components:
- ShellNavigationToggleComponent: drawer toggle with dynamic icon
- ShellFontSizeSelectorComponent: accessibility font size control
- ShellLogoutButtonComponent: logout with AuthService integration
- ShellNotificationsToggleComponent: notification panel overlay

Add E2E testing attributes and ARIA accessibility support
2025-12-03 21:16:57 +01:00
Lorenz Hilpert
daf79d55a5 feat(shell-notifications): add notification display component
Add feature library for rendering grouped notifications with:
- Grouped notification display with collapsible sections
- Unread/read status indication
- Relative timestamps via date-fns
- Action buttons supporting navigation and callback types
2025-12-03 21:16:33 +01:00
Lorenz Hilpert
062a8044f2 feat(shell-common): add shared shell services library
Add new util library providing state management services for shell components:
- NavigationService: navigation drawer open/closed state
- FontSizeService: application-wide font size with document sync
- NotificationsService: notification state with read status tracking
2025-12-03 21:16:14 +01:00
Lorenz Hilpert
86b0493591 feat(shell-layout): enhance network status banner with animations and tests
- Add slide-in/slide-out CSS animations for banner enter/leave
- Add content fade animation for smooth offline→online transitions
- Refactor to declarative RxJS pattern (pairwise + switchMap + timer)
- Use computed() for derived showBanner state
- Add wifi/wifi-off SVG icons inline
- Add comprehensive unit tests (14 test cases)
- Integrate shell-layout into isa-app root component
- Add proper JSDoc documentation
- Add ARIA accessibility attributes (role, aria-live)
- Export ONLINE_BANNER_DISPLAY_DURATION_MS constant for tests
2025-12-03 15:13:45 +01:00
Lorenz Hilpert
85f1184648 📝 docs: add Claude Code guide documentation 2025-12-03 14:20:21 +01:00
Lorenz Hilpert
803a53253c feat(isa-app): migrate to standalone component architecture
- Replace NgModule bootstrap with bootstrapApplication and ApplicationConfig
- Convert app.module.ts to app.config.ts with provider functions
- Convert routing module to standalone routes array
- Remove domain NgModules (services now providedIn: 'root')
- Remove NgRx application store in favor of signals
- Update icon components and registries for modern patterns
- Update page components for standalone compatibility
2025-12-03 14:20:21 +01:00
Lorenz Hilpert
abcb8e2cb4 ♻️ refactor(shell-layout): restructure component and add network status banner
- Move shell-layout from nested folder to lib root
- Add network-status-banner component for connectivity display
- Remove old component structure and specs
2025-12-03 14:15:50 +01:00
Lorenz Hilpert
598a77b288 feat(core-connectivity): add network status service library
- Scaffold new core-connectivity library with Nx generator
- Migrate NetworkStatusService from isa-app to shared library
- Refactor service to use fromEvent/merge with shareReplay
- Add NetworkStatus type and injectNetworkStatus signal helper
- Update all consumers to use @isa/core/connectivity imports
- Add comprehensive unit tests (9 tests)
- Configure Vitest with JUnit/Cobertura reporters
- Update library-reference.md (74 → 75 libraries)
2025-12-02 17:26:25 +01:00
Lorenz Hilpert
e5dd1e312d feat(shell): scaffold shell-layout, shell-header, and shell-navigation libraries
Add new shell domain with three feature libraries:
- shell-layout: Main application layout container
- shell-header: Application header component
- shell-navigation: Navigation component

All libraries configured with:
- Standalone Angular components
- Vitest with JUnit and Cobertura reporters
- Architectural tags (scope:shell, type:feature)
2025-12-02 16:14:35 +01:00
203 changed files with 10277 additions and 4466 deletions

View File

@@ -3,6 +3,9 @@ import { DevScanAdapter } from './dev.scan-adapter';
import { NativeScanAdapter } from './native.scan-adapter';
import { SCAN_ADAPTER } from './tokens';
/**
* @deprecated Use '@isa/shared/scanner' instead.
*/
@NgModule({})
export class ScanAdapterModule {
static forRoot() {

View File

@@ -5,6 +5,9 @@ import { ScanditOverlayComponent } from './scandit-overlay.component';
import { ScanditScanAdapter } from './scandit.scan-adapter';
import { SCAN_ADAPTER } from '../tokens';
/**
* @deprecated Use @isa/shared/scanner instead.
*/
@NgModule({
imports: [CommonModule],
exports: [ScanditOverlayComponent],
@@ -14,7 +17,9 @@ export class ScanditScanAdapterModule {
static forRoot() {
return {
ngModule: ScanditScanAdapterModule,
providers: [{ provide: SCAN_ADAPTER, useClass: ScanditScanAdapter, multi: true }],
providers: [
{ provide: SCAN_ADAPTER, useClass: ScanditScanAdapter, multi: true },
],
};
}
}

View File

@@ -11,7 +11,7 @@ import { Config } from '@core/config';
import { ComponentPortal } from '@angular/cdk/portal';
import { ScanditOverlayComponent } from './scandit-overlay.component';
import { EnvironmentService } from '@core/environment';
import { injectNetworkStatus$ } from 'apps/isa-app/src/app/services/network-status.service';
import { injectNetworkStatus$ } from '@isa/core/connectivity';
import { toSignal } from '@angular/core/rxjs-interop';
@Injectable()

View File

@@ -1,19 +0,0 @@
import { NgModule } from '@angular/core';
import { DomainAvailabilityModule } from '@domain/availability';
import { DomainCatalogModule } from '@domain/catalog';
import { DomainIsaModule } from '@domain/isa';
import { DomainCheckoutModule } from '@domain/checkout';
import { DomainOmsModule } from '@domain/oms';
import { DomainRemissionModule } from '@domain/remission';
@NgModule({
imports: [
DomainIsaModule.forRoot(),
DomainCatalogModule.forRoot(),
DomainAvailabilityModule.forRoot(),
DomainCheckoutModule.forRoot(),
DomainOmsModule.forRoot(),
DomainRemissionModule.forRoot(),
],
})
export class AppDomainModule {}

View File

@@ -1,34 +0,0 @@
import { NgModule } from '@angular/core';
import { EffectsModule } from '@ngrx/effects';
import { ActionReducer, MetaReducer, StoreModule } from '@ngrx/store';
import { StoreDevtoolsModule } from '@ngrx/store-devtools';
import { environment } from '../environments/environment';
import { rootReducer } from './store/root.reducer';
import { RootState } from './store/root.state';
export function storeInLocalStorage(
reducer: ActionReducer<any>,
): ActionReducer<any> {
return function (state, action) {
if (action.type === 'HYDRATE') {
return reducer(action['payload'], action);
}
return reducer(state, action);
};
}
export const metaReducers: MetaReducer<RootState>[] = !environment.production
? [storeInLocalStorage]
: [storeInLocalStorage];
@NgModule({
imports: [
StoreModule.forRoot(rootReducer, { metaReducers }),
EffectsModule.forRoot([]),
StoreDevtoolsModule.instrument({
name: 'ISA Ngrx Application Store',
connectInZone: true,
}),
],
})
export class AppStoreModule {}

View File

@@ -1,40 +0,0 @@
import { HttpInterceptorFn, provideHttpClient, withInterceptors } from '@angular/common/http';
import { NgModule } from '@angular/core';
import { Config } from '@core/config';
import { AvConfiguration } from '@generated/swagger/availability-api';
import { CatConfiguration } from '@generated/swagger/cat-search-api';
import { CheckoutConfiguration } from '@generated/swagger/checkout-api';
import { CrmConfiguration } from '@generated/swagger/crm-api';
import { EisConfiguration } from '@generated/swagger/eis-api';
import { IsaConfiguration } from '@generated/swagger/isa-api';
import { OmsConfiguration } from '@generated/swagger/oms-api';
import { PrintConfiguration } from '@generated/swagger/print-api';
import { RemiConfiguration } from '@generated/swagger/inventory-api';
import { WwsConfiguration } from '@generated/swagger/wws-api';
export function createConfigurationFactory(name: string) {
return function (config: Config): { rootUrl: string } {
return config.get(`@swagger/${name}`);
};
}
const serviceWorkerInterceptor: HttpInterceptorFn = (req, next) => {
return next(req.clone({ setHeaders: { 'ngsw-bypass': 'true' } }));
};
@NgModule({
providers: [
provideHttpClient(withInterceptors([serviceWorkerInterceptor])),
{ provide: AvConfiguration, useFactory: createConfigurationFactory('av'), deps: [Config] },
{ provide: CatConfiguration, useFactory: createConfigurationFactory('cat'), deps: [Config] },
{ provide: CheckoutConfiguration, useFactory: createConfigurationFactory('checkout'), deps: [Config] },
{ provide: CrmConfiguration, useFactory: createConfigurationFactory('crm'), deps: [Config] },
{ provide: EisConfiguration, useFactory: createConfigurationFactory('eis'), deps: [Config] },
{ provide: IsaConfiguration, useFactory: createConfigurationFactory('isa'), deps: [Config] },
{ provide: OmsConfiguration, useFactory: createConfigurationFactory('oms'), deps: [Config] },
{ provide: PrintConfiguration, useFactory: createConfigurationFactory('print'), deps: [Config] },
{ provide: RemiConfiguration, useFactory: createConfigurationFactory('remi'), deps: [Config] },
{ provide: WwsConfiguration, useFactory: createConfigurationFactory('wws'), deps: [Config] },
],
})
export class AppSwaggerModule {}

View File

@@ -1,4 +1,4 @@
@if ($offlineBannerVisible()) {
<!-- @if ($offlineBannerVisible()) {
<div [@fadeInOut] class="bg-brand text-white text-center fixed inset-x-0 top-0 z-tooltip p-4">
<h3 class="font-bold grid grid-flow-col items-center justify-center text-xl gap-4">
<div>
@@ -25,4 +25,4 @@
</div>
}
<router-outlet></router-outlet>
<router-outlet></router-outlet> -->

View File

@@ -1,3 +0,0 @@
:host {
@apply block;
}

View File

@@ -1,137 +0,0 @@
import { Spectator, createComponentFactory, SpyObject, createSpyObject } from '@ngneat/spectator';
import { RouterTestingModule } from '@angular/router/testing';
import { AppComponent } from './app.component';
import { Config } from '@core/config';
import { ApplicationService } from '@core/application';
import { of } from 'rxjs';
import { Renderer2 } from '@angular/core';
import { CommonModule } from '@angular/common';
import { SwUpdate } from '@angular/service-worker';
import { NotificationsHub } from '@hub/notifications';
import { UserStateService } from '@generated/swagger/isa-api';
import { UiModalService } from '@ui/modal';
import { AuthService } from '@core/auth';
describe('AppComponent', () => {
let spectator: Spectator<AppComponent>;
let config: SpyObject<Config>;
let renderer: SpyObject<Renderer2>;
let applicationServiceMock: SpyObject<ApplicationService>;
let notificationsHubMock: SpyObject<NotificationsHub>;
let swUpdateMock: SpyObject<SwUpdate>;
const createComponent = createComponentFactory({
component: AppComponent,
imports: [CommonModule, RouterTestingModule],
providers: [],
mocks: [Config, SwUpdate, UserStateService, UiModalService, AuthService],
});
beforeEach(() => {
applicationServiceMock = createSpyObject(ApplicationService);
applicationServiceMock.getSection$.and.returnValue(of('customer'));
applicationServiceMock.getActivatedProcessId$.and.returnValue(of(undefined));
renderer = jasmine.createSpyObj('Renderer2', ['addClass', 'removeClass']);
notificationsHubMock = createSpyObject(NotificationsHub);
notificationsHubMock.notifications$ = of({});
swUpdateMock = createSpyObject(SwUpdate);
spectator = createComponent({
providers: [
{ provide: ApplicationService, useValue: applicationServiceMock },
{
provide: Renderer2,
useValue: renderer,
},
{ provide: NotificationsHub, useValue: notificationsHubMock },
{ provide: SwUpdate, useValue: swUpdateMock },
],
});
config = spectator.inject(Config);
});
it('should create the app', () => {
expect(spectator.component).toBeTruthy();
});
it('should have a router outlet', () => {
expect(spectator.query('router-outlet')).toExist();
});
describe('ngOnInit', () => {
it('should call setTitle', () => {
const spy = spyOn(spectator.component, 'setTitle');
spectator.component.ngOnInit();
expect(spy).toHaveBeenCalled();
});
it('should call logVersion', () => {
const spy = spyOn(spectator.component, 'logVersion');
spectator.component.ngOnInit();
expect(spy).toHaveBeenCalled();
});
});
describe('setTitle', () => {
it('should call Title.setTitle()', () => {
const spyTitleSetTitle = spyOn(spectator.component['_title'], 'setTitle');
config.get.and.returnValue('test');
spectator.component.setTitle();
expect(spyTitleSetTitle).toHaveBeenCalledWith('test');
});
});
describe('logVersion', () => {
it('should call console.log()', () => {
const spyConsoleLog = spyOn(console, 'log');
config.get.and.returnValue('test');
spectator.component.logVersion();
expect(spyConsoleLog).toHaveBeenCalled();
});
});
// --------------------------------------------------------
// Unit Tests Implementation for Angular Version 13.x.x
// describe('updateClient()', () => {
// it('should call checkForUpdate() if SwUpdate.isEnabled is True', () => {
// spyOn(spectator.component, 'checkForUpdate');
// spyOn(spectator.component, 'initialCheckForUpdate');
// (swUpdateMock as any).isEnabled = true;
// spectator.component.updateClient();
// expect(spectator.component.initialCheckForUpdate).toHaveBeenCalled();
// expect(spectator.component.checkForUpdate).toHaveBeenCalled();
// });
// it('should not call checkForUpdate() if SwUpdate.isEnabled is False', () => {
// spyOn(spectator.component, 'checkForUpdate');
// spyOn(spectator.component, 'initialCheckForUpdate');
// (swUpdateMock as any).isEnabled = false;
// spectator.component.updateClient();
// expect(spectator.component.initialCheckForUpdate).not.toHaveBeenCalled();
// expect(spectator.component.checkForUpdate).not.toHaveBeenCalled();
// });
// });
// describe('checkForUpdate', () => {
// it('should call swUpdate.checkForUpdate() and notifications.updateNotification() every second', fakeAsync(() => {
// swUpdateMock.checkForUpdate.and.returnValue(Promise.resolve());
// spectator.component.checkForUpdates = 1000;
// spectator.component.checkForUpdate();
// spectator.detectChanges();
// tick(1100);
// expect(notificationsHubMock.updateNotification).toHaveBeenCalled();
// discardPeriodicTasks();
// }));
// });
// describe('initialCheckForUpdate', () => {
// it('should call swUpdate.checkForUpdate()', () => {
// swUpdateMock.checkForUpdate.and.returnValue(new Promise(undefined));
// spectator.component.initialCheckForUpdate();
// expect(swUpdateMock.checkForUpdate).toHaveBeenCalled();
// });
// });
});

View File

@@ -1,206 +1,205 @@
// import {
// Component,
// effect,
// HostListener,
// inject,
// Inject,
// Injector,
// OnInit,
// Renderer2,
// signal,
// untracked,
// DOCUMENT
// } from '@angular/core';
// import { Title } from '@angular/platform-browser';
// import { SwUpdate } from '@angular/service-worker';
// import { ApplicationService } from '@core/application';
// import { Config } from '@core/config';
// import { NotificationsHub } from '@hub/notifications';
// import packageInfo from 'packageJson';
// import { asapScheduler, interval, Subscription } from 'rxjs';
// import { UserStateService } from '@generated/swagger/isa-api';
// import { IsaLogProvider } from './providers';
// import { EnvironmentService } from '@core/environment';
// import { AuthService, LoginStrategy } from '@core/auth';
// import { UiMessageModalComponent, UiModalService } from '@ui/modal';
// import { injectNetworkStatus } from '@isa/core/connectivity';
// import { animate, style, transition, trigger } from '@angular/animations';
import {
Component,
effect,
HostListener,
inject,
Inject,
Injector,
OnInit,
Renderer2,
signal,
untracked,
DOCUMENT
} from '@angular/core';
import { Title } from '@angular/platform-browser';
import { SwUpdate } from '@angular/service-worker';
import { ApplicationService } from '@core/application';
import { Config } from '@core/config';
import { NotificationsHub } from '@hub/notifications';
import packageInfo from 'packageJson';
import { asapScheduler, interval, Subscription } from 'rxjs';
import { UserStateService } from '@generated/swagger/isa-api';
import { IsaLogProvider } from './providers';
import { EnvironmentService } from '@core/environment';
import { AuthService, LoginStrategy } from '@core/auth';
import { UiMessageModalComponent, UiModalService } from '@ui/modal';
import { injectOnline$ } from './services/network-status.service';
import { toSignal } from '@angular/core/rxjs-interop';
import { animate, style, transition, trigger } from '@angular/animations';
// @Component({
// selector: 'app-root',
// templateUrl: './app.component.html',
// styleUrls: ['./app.component.scss'],
// animations: [
// trigger('fadeInOut', [
// transition(':enter', [
// // :enter wird ausgelöst, wenn das Element zum DOM hinzugefügt wird
// style({ opacity: 0, transform: 'translateY(-100%)' }),
// animate('300ms', style({ opacity: 1, transform: 'translateY(0)' })),
// ]),
// transition(':leave', [
// // :leave wird ausgelöst, wenn das Element aus dem DOM entfernt wird
// animate('300ms', style({ opacity: 0, transform: 'translateY(-100%)' })),
// ]),
// ]),
// ],
// standalone: false,
// })
// export class AppComponent implements OnInit {
// readonly injector = inject(Injector);
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.scss'],
animations: [
trigger('fadeInOut', [
transition(':enter', [
// :enter wird ausgelöst, wenn das Element zum DOM hinzugefügt wird
style({ opacity: 0, transform: 'translateY(-100%)' }),
animate('300ms', style({ opacity: 1, transform: 'translateY(0)' })),
]),
transition(':leave', [
// :leave wird ausgelöst, wenn das Element aus dem DOM entfernt wird
animate('300ms', style({ opacity: 0, transform: 'translateY(-100%)' })),
]),
]),
],
standalone: false,
})
export class AppComponent implements OnInit {
readonly injector = inject(Injector);
// $networkStatus = injectNetworkStatus();
$online = toSignal(injectOnline$());
// $offlineBannerVisible = signal(false);
$offlineBannerVisible = signal(false);
// $onlineBannerVisible = signal(false);
$onlineBannerVisible = signal(false);
// private onlineBannerDismissTimeout: any;
private onlineBannerDismissTimeout: any;
// onlineEffects = effect(() => {
// const status = this.$networkStatus();
// const online = status === 'online';
// const offlineBannerVisible = this.$offlineBannerVisible();
onlineEffects = effect(() => {
const online = this.$online();
const offlineBannerVisible = this.$offlineBannerVisible();
// untracked(() => {
// this.$offlineBannerVisible.set(!online);
untracked(() => {
this.$offlineBannerVisible.set(!online);
// if (!online) {
// this.$onlineBannerVisible.set(false);
// clearTimeout(this.onlineBannerDismissTimeout);
// }
if (!online) {
this.$onlineBannerVisible.set(false);
clearTimeout(this.onlineBannerDismissTimeout);
}
// if (offlineBannerVisible && online) {
// this.$onlineBannerVisible.set(true);
// this.onlineBannerDismissTimeout = setTimeout(() => this.$onlineBannerVisible.set(false), 5000);
// }
// });
// });
if (offlineBannerVisible && online) {
this.$onlineBannerVisible.set(true);
this.onlineBannerDismissTimeout = setTimeout(() => this.$onlineBannerVisible.set(false), 5000);
}
});
});
// private _checkForUpdates: number = this._config.get('checkForUpdates');
private _checkForUpdates: number = this._config.get('checkForUpdates');
// get checkForUpdates(): number {
// return this._checkForUpdates ?? 60 * 60 * 1000; // default 1 hour
// }
get checkForUpdates(): number {
return this._checkForUpdates ?? 60 * 60 * 1000; // default 1 hour
}
// // For Unit Testing
// set checkForUpdates(time: number) {
// this._checkForUpdates = time;
// }
// For Unit Testing
set checkForUpdates(time: number) {
this._checkForUpdates = time;
}
// subscriptions = new Subscription();
subscriptions = new Subscription();
// constructor(
// private readonly _config: Config,
// private readonly _title: Title,
// private readonly _appService: ApplicationService,
// @Inject(DOCUMENT) private readonly _document: Document,
// private readonly _renderer: Renderer2,
// private readonly _swUpdate: SwUpdate,
// private readonly _notifications: NotificationsHub,
// private infoService: UserStateService,
// private readonly _environment: EnvironmentService,
// private readonly _authService: AuthService,
// private readonly _modal: UiModalService,
// ) {
// this.updateClient();
// IsaLogProvider.InfoService = this.infoService;
// }
constructor(
private readonly _config: Config,
private readonly _title: Title,
private readonly _appService: ApplicationService,
@Inject(DOCUMENT) private readonly _document: Document,
private readonly _renderer: Renderer2,
private readonly _swUpdate: SwUpdate,
private readonly _notifications: NotificationsHub,
private infoService: UserStateService,
private readonly _environment: EnvironmentService,
private readonly _authService: AuthService,
private readonly _modal: UiModalService,
) {
this.updateClient();
IsaLogProvider.InfoService = this.infoService;
}
// ngOnInit() {
// this.setTitle();
// this.logVersion();
// asapScheduler.schedule(() => this.determinePlatform(), 250);
// this._appService.getSection$().subscribe(this.sectionChangeHandler.bind(this));
ngOnInit() {
this.setTitle();
this.logVersion();
asapScheduler.schedule(() => this.determinePlatform(), 250);
this._appService.getSection$().subscribe(this.sectionChangeHandler.bind(this));
// this.setupSilentRefresh();
// }
this.setupSilentRefresh();
}
// // Setup interval for silent refresh
// setupSilentRefresh() {
// const silentRefreshInterval = this._config.get('silentRefresh.interval');
// if (silentRefreshInterval > 0) {
// interval(silentRefreshInterval).subscribe(() => {
// if (this._authService.isAuthenticated()) {
// this._authService.refresh();
// }
// });
// }
// }
// Setup interval for silent refresh
setupSilentRefresh() {
const silentRefreshInterval = this._config.get('silentRefresh.interval');
if (silentRefreshInterval > 0) {
interval(silentRefreshInterval).subscribe(() => {
if (this._authService.isAuthenticated()) {
this._authService.refresh();
}
});
}
}
// setTitle() {
// this._title.setTitle(this._config.get('title'));
// }
setTitle() {
this._title.setTitle(this._config.get('title'));
}
// logVersion() {
// console.log(
// `%c${this._config.get('title')}\r\nVersion: ${packageInfo.version}`,
// 'font-weight: bold; font-size: 20px;',
// );
// }
logVersion() {
console.log(
`%c${this._config.get('title')}\r\nVersion: ${packageInfo.version}`,
'font-weight: bold; font-size: 20px;',
);
}
// determinePlatform() {
// if (this._environment.isNative()) {
// this._renderer.addClass(this._document.body, 'tablet-native');
// } else if (this._environment.isTablet()) {
// this._renderer.addClass(this._document.body, 'tablet-browser');
// }
// if (this._environment.isTablet()) {
// this._renderer.addClass(this._document.body, 'tablet');
// }
// if (this._environment.isDesktop()) {
// this._renderer.addClass(this._document.body, 'desktop');
// }
// }
determinePlatform() {
if (this._environment.isNative()) {
this._renderer.addClass(this._document.body, 'tablet-native');
} else if (this._environment.isTablet()) {
this._renderer.addClass(this._document.body, 'tablet-browser');
}
if (this._environment.isTablet()) {
this._renderer.addClass(this._document.body, 'tablet');
}
if (this._environment.isDesktop()) {
this._renderer.addClass(this._document.body, 'desktop');
}
}
// sectionChangeHandler(section: string) {
// if (section === 'customer') {
// this._renderer.removeClass(this._document.body, 'branch');
// this._renderer.addClass(this._document.body, 'customer');
// } else if (section === 'branch') {
// this._renderer.removeClass(this._document.body, 'customer');
// this._renderer.addClass(this._document.body, 'branch');
// }
// }
sectionChangeHandler(section: string) {
if (section === 'customer') {
this._renderer.removeClass(this._document.body, 'branch');
this._renderer.addClass(this._document.body, 'customer');
} else if (section === 'branch') {
this._renderer.removeClass(this._document.body, 'customer');
this._renderer.addClass(this._document.body, 'branch');
}
}
// updateClient() {
// if (!this._swUpdate.isEnabled) {
// return;
// }
updateClient() {
if (!this._swUpdate.isEnabled) {
return;
}
// this.initialCheckForUpdate();
// this.checkForUpdate();
// }
this.initialCheckForUpdate();
this.checkForUpdate();
}
// checkForUpdate() {
// interval(this._checkForUpdates).subscribe(() => {
// this._swUpdate.checkForUpdate().then((value) => {
// console.log('check for update', value);
// if (value) {
// this._notifications.updateNotification();
// }
// });
// });
// }
checkForUpdate() {
interval(this._checkForUpdates).subscribe(() => {
this._swUpdate.checkForUpdate().then((value) => {
console.log('check for update', value);
if (value) {
this._notifications.updateNotification();
}
});
});
}
// initialCheckForUpdate() {
// this._swUpdate.checkForUpdate().then((value) => {
// console.log('initial check for update', value);
// if (value) {
// location.reload();
// }
// });
// }
initialCheckForUpdate() {
this._swUpdate.checkForUpdate().then((value) => {
console.log('initial check for update', value);
if (value) {
location.reload();
}
});
}
// @HostListener('window:visibilitychange', ['$event'])
// onVisibilityChange(event: Event) {
// // refresh token when app is in background
// if (this._document.hidden && this._authService.isAuthenticated()) {
// this._authService.refresh();
// } else if (!this._authService.isAuthenticated()) {
// const strategy = this.injector.get(LoginStrategy);
@HostListener('window:visibilitychange', ['$event'])
onVisibilityChange(event: Event) {
// refresh token when app is in background
if (this._document.hidden && this._authService.isAuthenticated()) {
this._authService.refresh();
} else if (!this._authService.isAuthenticated()) {
const strategy = this.injector.get(LoginStrategy);
return strategy.login('Sie sind nicht mehr angemeldet');
}
}
}
// return strategy.login('Sie sind nicht mehr angemeldet');
// }
// }
// }

View File

@@ -2,51 +2,51 @@ import { version } from '../../../../package.json';
import { IsaTitleStrategy } from '@isa/common/title-management';
import {
HTTP_INTERCEPTORS,
HttpInterceptorFn,
provideHttpClient,
withInterceptors,
withInterceptorsFromDi,
} from '@angular/common/http';
import {
ApplicationConfig,
DEFAULT_CURRENCY_CODE,
ErrorHandler,
importProvidersFrom,
Injector,
LOCALE_ID,
NgModule,
inject,
provideAppInitializer,
provideZoneChangeDetection,
signal,
isDevMode,
} from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { PlatformModule } from '@angular/cdk/platform';
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
import {
provideRouter,
TitleStrategy,
withComponentInputBinding,
} from '@angular/router';
import { ActionReducer, MetaReducer, provideStore } from '@ngrx/store';
import { provideStoreDevtools } from '@ngrx/store-devtools';
import { Config } from '@core/config';
import { AuthModule, AuthService, LoginStrategy } from '@core/auth';
import { CoreCommandModule } from '@core/command';
import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component';
import {
ApplicationService,
ApplicationServiceAdapter,
CoreApplicationModule,
} from '@core/application';
import { AppStoreModule } from './app-store.module';
import { routes } from './app.routes';
import { rootReducer } from './store/root.reducer';
import { RootState } from './store/root.state';
import { ServiceWorkerModule } from '@angular/service-worker';
import { environment } from '../environments/environment';
import { AppSwaggerModule } from './app-swagger.module';
import { AppDomainModule } from './app-domain.module';
import { UiModalModule } from '@ui/modal';
import {
NotificationsHubModule,
NOTIFICATIONS_HUB_OPTIONS,
} from '@hub/notifications';
import { SignalRHubOptions } from '@core/signalr';
import { CoreBreadcrumbModule } from '@core/breadcrumb';
import { provideCoreBreadcrumb } from '@core/breadcrumb';
import { UiCommonModule } from '@ui/common';
import { registerLocaleData } from '@angular/common';
import localeDe from '@angular/common/locales/de';
import localeDeExtra from '@angular/common/locales/extra/de';
import { HttpErrorInterceptor } from './interceptors';
import { CoreLoggerModule, LOG_PROVIDER } from '@core/logger';
import { IsaLogProvider } from './providers';
@@ -59,7 +59,6 @@ import {
import * as Commands from './commands';
import { NativeContainerService } from '@external/native-container';
import { ShellModule } from '@shared/shell';
import { MainComponent } from './main.component';
import { IconModule } from '@shared/components/icon';
import { NgIconsModule } from '@ng-icons/core';
import {
@@ -67,10 +66,9 @@ import {
matWifi,
matWifiOff,
} from '@ng-icons/material-icons/baseline';
import { NetworkStatusService } from './services/network-status.service';
import { NetworkStatusService } from '@isa/core/connectivity';
import { debounceTime, filter, firstValueFrom, switchMap } from 'rxjs';
import { provideMatomo } from 'ngx-matomo-client';
import { withRouter, withRouteData } from 'ngx-matomo-client';
import { provideMatomo, withRouter, withRouteData } from 'ngx-matomo-client';
import {
provideLogging,
withLogLevel,
@@ -87,15 +85,58 @@ import {
import { Store } from '@ngrx/store';
import { OAuthService } from 'angular-oauth2-oidc';
import z from 'zod';
import { TitleStrategy } from '@angular/router';
import { provideScrollPositionRestoration } from '@isa/utils/scroll-position';
import { TabNavigationService } from '@isa/core/tabs';
registerLocaleData(localeDe, localeDeExtra);
registerLocaleData(localeDe, 'de', localeDeExtra);
// Domain modules
import { provideDomainCheckout } from '@domain/checkout';
export function _appInitializerFactory(config: Config, injector: Injector) {
// Swagger API configurations
import { AvConfiguration } from '@generated/swagger/availability-api';
import { CatConfiguration } from '@generated/swagger/cat-search-api';
import { CheckoutConfiguration } from '@generated/swagger/checkout-api';
import { CrmConfiguration } from '@generated/swagger/crm-api';
import { EisConfiguration } from '@generated/swagger/eis-api';
import { IsaConfiguration } from '@generated/swagger/isa-api';
import { OmsConfiguration } from '@generated/swagger/oms-api';
import { PrintConfiguration } from '@generated/swagger/print-api';
import { RemiConfiguration } from '@generated/swagger/inventory-api';
import { WwsConfiguration } from '@generated/swagger/wws-api';
import { UiIconModule } from '@ui/icon';
// --- Store Configuration ---
function storeHydrateMetaReducer(
reducer: ActionReducer<RootState>,
): ActionReducer<RootState> {
return function (state, action) {
if (action.type === 'HYDRATE') {
return reducer(action['payload'], action);
}
return reducer(state, action);
};
}
const metaReducers: MetaReducer<RootState>[] = [storeHydrateMetaReducer];
// --- Swagger Configuration ---
const swaggerConfigSchema = z.object({ rootUrl: z.string() });
function createSwaggerConfigFactory(name: string) {
return function () {
return inject(Config).get(`@swagger/${name}`, swaggerConfigSchema);
};
}
const serviceWorkerBypassInterceptor: HttpInterceptorFn = (req, next) => {
return next(req.clone({ setHeaders: { 'ngsw-bypass': 'true' } }));
};
// --- App Initializer ---
function appInitializerFactory(_config: Config, injector: Injector) {
return async () => {
// Get logging service for initialization logging
const logger = loggerFactory(() => ({ service: 'AppInitializer' }));
const statusElement = document.querySelector('#init-status');
const laoderElement = document.querySelector('#init-loader');
@@ -106,7 +147,8 @@ export function _appInitializerFactory(config: Config, injector: Injector) {
let online = false;
const networkStatus = injector.get(NetworkStatusService);
while (!online) {
online = await firstValueFrom(networkStatus.online$);
const status = await firstValueFrom(networkStatus.status$);
online = status === 'online';
if (!online) {
logger.warn('Waiting for network connection');
@@ -161,7 +203,6 @@ export function _appInitializerFactory(config: Config, injector: Injector) {
await userStorage.init();
const store = injector.get(Store);
// Hydrate Ngrx Store
const state = userStorage.get('store');
if (state && state['version'] === version) {
store.dispatch({ type: 'HYDRATE', payload: userStorage.get('store') });
@@ -171,7 +212,7 @@ export function _appInitializerFactory(config: Config, injector: Injector) {
reason: state ? 'version mismatch' : 'no stored state',
}));
}
// Subscribe on Store changes and save to user storage
auth.initialized$
.pipe(
filter((initialized) => initialized),
@@ -182,7 +223,6 @@ export function _appInitializerFactory(config: Config, injector: Injector) {
});
logger.info('Application initialization completed');
// Inject tab navigation service to initialize it
injector.get(TabNavigationService).init();
} catch (error) {
logger.error('Application initialization failed', error as Error, () => ({
@@ -223,7 +263,7 @@ export function _appInitializerFactory(config: Config, injector: Injector) {
};
}
export function _notificationsHubOptionsFactory(
function notificationsHubOptionsFactory(
config: Config,
auth: AuthService,
): SignalRHubOptions {
@@ -257,80 +297,151 @@ const USER_SUB_FACTORY = () => {
return signal(validation.data);
};
@NgModule({
declarations: [AppComponent, MainComponent],
bootstrap: [AppComponent],
imports: [
BrowserModule,
BrowserAnimationsModule,
ShellModule.forRoot(),
AppRoutingModule,
AppSwaggerModule,
AppDomainModule,
CoreBreadcrumbModule.forRoot(),
CoreCommandModule.forRoot(Object.values(Commands)),
CoreLoggerModule.forRoot(),
AppStoreModule,
AuthModule.forRoot(),
CoreApplicationModule.forRoot(),
UiModalModule.forRoot(),
UiCommonModule.forRoot(),
NotificationsHubModule.forRoot(),
ServiceWorkerModule.register('ngsw-worker.js', {
enabled: environment.production,
registrationStrategy: 'registerWhenStable:30000',
}),
ScanAdapterModule.forRoot(),
ScanditScanAdapterModule.forRoot(),
PlatformModule,
IconModule.forRoot(),
NgIconsModule.withIcons({ matWifiOff, matClose, matWifi }),
],
export const appConfig: ApplicationConfig = {
providers: [
provideZoneChangeDetection({ eventCoalescing: true }),
provideAnimationsAsync('animations'),
provideRouter(routes, withComponentInputBinding()),
provideHttpClient(
withInterceptorsFromDi(),
withInterceptors([serviceWorkerBypassInterceptor]),
),
provideScrollPositionRestoration(),
// NgRx Store
provideStore(rootReducer, { metaReducers }),
provideCoreBreadcrumb(),
provideDomainCheckout(),
provideStoreDevtools({
name: 'ISA Ngrx Application Store',
connectInZone: true,
}),
// Swagger API configurations
{
provide: AvConfiguration,
useFactory: createSwaggerConfigFactory('av'),
},
{
provide: CatConfiguration,
useFactory: createSwaggerConfigFactory('cat'),
},
{
provide: CheckoutConfiguration,
useFactory: createSwaggerConfigFactory('checkout'),
},
{
provide: CrmConfiguration,
useFactory: createSwaggerConfigFactory('crm'),
},
{
provide: EisConfiguration,
useFactory: createSwaggerConfigFactory('eis'),
},
{
provide: IsaConfiguration,
useFactory: createSwaggerConfigFactory('isa'),
},
{
provide: OmsConfiguration,
useFactory: createSwaggerConfigFactory('oms'),
},
{
provide: PrintConfiguration,
useFactory: createSwaggerConfigFactory('print'),
},
{
provide: RemiConfiguration,
useFactory: createSwaggerConfigFactory('remi'),
},
{
provide: WwsConfiguration,
useFactory: createSwaggerConfigFactory('wws'),
},
// App initializer
provideAppInitializer(() => {
const initializerFn = _appInitializerFactory(
const initializerFn = appInitializerFactory(
inject(Config),
inject(Injector),
);
return initializerFn();
}),
// Notifications hub
{
provide: NOTIFICATIONS_HUB_OPTIONS,
useFactory: _notificationsHubOptionsFactory,
useFactory: notificationsHubOptionsFactory,
deps: [Config, AuthService],
},
// HTTP interceptors
{
provide: HTTP_INTERCEPTORS,
useClass: HttpErrorInterceptor,
multi: true,
},
// Logging
{
provide: LOG_PROVIDER,
useClass: IsaLogProvider,
multi: true,
},
provideLogging(
withLogLevel(isDevMode() ? LogLevel.Debug : LogLevel.Info),
withSink(ConsoleLogSink),
),
// Error handling
{
provide: ErrorHandler,
useClass: IsaErrorHandler,
},
{
provide: ApplicationService,
useClass: ApplicationServiceAdapter,
},
// Locale settings
{ provide: LOCALE_ID, useValue: 'de-DE' },
provideHttpClient(withInterceptorsFromDi()),
{ provide: DEFAULT_CURRENCY_CODE, useValue: 'EUR' },
// Analytics
provideMatomo(
{ trackerUrl: 'https://matomo.paragon-data.net', siteId: '1' },
withRouter(),
withRouteData(),
),
provideLogging(withLogLevel(LogLevel.Debug), withSink(ConsoleLogSink)),
{
provide: DEFAULT_CURRENCY_CODE,
useValue: 'EUR',
},
// User storage
provideUserSubFactory(USER_SUB_FACTORY),
// Title strategy
{ provide: TitleStrategy, useClass: IsaTitleStrategy },
// Import providers from NgModules
importProvidersFrom(
// Core modules
CoreCommandModule.forRoot(Object.values(Commands)),
CoreLoggerModule.forRoot(),
AuthModule.forRoot(),
// UI modules
UiModalModule.forRoot(),
UiCommonModule.forRoot(),
// Hub modules
NotificationsHubModule.forRoot(),
// Service Worker
ServiceWorkerModule.register('ngsw-worker.js', {
enabled: environment.production,
registrationStrategy: 'registerWhenStable:30000',
}),
// Scan adapter
ScanAdapterModule.forRoot(),
ScanditScanAdapterModule.forRoot(),
UiIconModule.forRoot(),
IconModule.forRoot(),
),
],
})
export class AppModule {}
};

View File

View File

@@ -0,0 +1,3 @@
<shell-layout>
<router-outlet />
</shell-layout>

View File

@@ -1,5 +1,4 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { Routes } from '@angular/router';
import {
CanActivateCartGuard,
CanActivateCartWithProcessIdGuard,
@@ -11,13 +10,12 @@ import {
CanActivateProductWithProcessIdGuard,
IsAuthenticatedGuard,
} from './guards';
import { MainComponent } from './main.component';
import {
BranchSectionResolver,
CustomerSectionResolver,
ProcessIdResolver,
} from './resolvers';
import { TokenLoginComponent, TokenLoginModule } from './token-login';
import { TokenLoginComponent } from './token-login';
import {
ActivateProcessIdGuard,
ActivateProcessIdWithConfigKeyGuard,
@@ -28,9 +26,8 @@ import {
processResolverFn,
hasTabIdGuard,
} from '@isa/core/tabs';
import { provideScrollPositionRestoration } from '@isa/utils/scroll-position';
const routes: Routes = [
export const routes: Routes = [
{ path: '', redirectTo: 'kunde/dashboard', pathMatch: 'full' },
{
path: 'login',
@@ -45,7 +42,6 @@ const routes: Routes = [
children: [
{
path: 'kunde',
component: MainComponent,
children: [
{
path: 'dashboard',
@@ -72,8 +68,6 @@ const routes: Routes = [
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: () =>
@@ -122,7 +116,6 @@ const routes: Routes = [
{
path: 'pickup-shelf',
canActivate: [ActivateProcessIdGuard],
// NOTE: This is a workaround for the canActivate guard not being called
loadChildren: () =>
import('@page/pickup-shelf').then((m) => m.PickupShelfOutModule),
},
@@ -141,7 +134,6 @@ const routes: Routes = [
},
{
path: 'filiale',
component: MainComponent,
children: [
{
path: 'task-calendar',
@@ -154,7 +146,6 @@ const routes: Routes = [
{
path: 'pickup-shelf',
canActivate: [ActivateProcessIdWithConfigKeyGuard('pickupShelf')],
// NOTE: This is a workaround for the canActivate guard not being called
loadChildren: () =>
import('@page/pickup-shelf').then((m) => m.PickupShelfInModule),
},
@@ -188,7 +179,6 @@ const routes: Routes = [
},
{
path: ':tabId',
component: MainComponent,
resolve: { process: processResolverFn, tab: tabResolverFn },
canActivate: [IsAuthenticatedGuard, hasTabIdGuard],
children: [
@@ -218,7 +208,6 @@ const routes: Routes = [
},
],
},
{
path: 'return',
loadChildren: () =>
@@ -246,16 +235,3 @@ const routes: Routes = [
],
},
];
@NgModule({
imports: [
RouterModule.forRoot(routes, {
bindToComponentInputs: true,
enableTracing: false,
}),
TokenLoginModule,
],
exports: [RouterModule],
providers: [provideScrollPositionRestoration()],
})
export class AppRoutingModule {}

View File

@@ -0,0 +1,11 @@
import { Component } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { ShellLayoutComponent } from '@isa/shell/layout';
@Component({
selector: 'app-root',
templateUrl: './app.html',
styleUrls: ['./app.css'],
imports: [RouterOutlet, ShellLayoutComponent],
})
export class App {}

View File

@@ -5,7 +5,7 @@ import { ScanAdapterService } from '@adapter/scan';
import { AuthService as IsaAuthService } from '@generated/swagger/isa-api';
import { UiConfirmModalComponent, UiErrorModalComponent, UiModalResult, UiModalService } from '@ui/modal';
import { EnvironmentService } from '@core/environment';
import { injectNetworkStatus$ } from '../services/network-status.service';
import { injectNetworkStatus$ } from '@isa/core/connectivity';
import { toSignal } from '@angular/core/rxjs-interop';
@Injectable({ providedIn: 'root' })

View File

@@ -9,7 +9,7 @@ import {
import { from, NEVER, Observable, throwError } from 'rxjs';
import { catchError, filter, mergeMap, takeUntil } from 'rxjs/operators';
import { AuthService, LoginStrategy } from '@core/auth';
import { injectOnline$ } from '../services/network-status.service';
import { injectNetworkStatus$ } from '@isa/core/connectivity';
import { logger } from '@isa/core/logging';
@Injectable()
@@ -17,7 +17,7 @@ export class HttpErrorInterceptor implements HttpInterceptor {
#logger = logger(() => ({
'http-interceptor': 'HttpErrorInterceptor',
}));
#offline$ = injectOnline$().pipe(filter((online) => !online));
#offline$ = injectNetworkStatus$().pipe(filter((status) => status === 'offline'));
#injector = inject(Injector);
#auth = inject(AuthService);

View File

@@ -1,3 +0,0 @@
<shell-root>
<router-outlet></router-outlet>
</shell-root>

View File

@@ -1,11 +0,0 @@
import { Component, ChangeDetectionStrategy } from '@angular/core';
@Component({
selector: 'app-main',
templateUrl: 'main.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: false,
})
export class MainComponent {
constructor() {}
}

View File

@@ -1 +0,0 @@
export * from './network-status.service';

View File

@@ -1,25 +0,0 @@
import { inject, Injectable } from '@angular/core';
import { map, Observable } from 'rxjs';
@Injectable({ providedIn: 'root' })
export class NetworkStatusService {
online$ = new Observable<boolean>((subscriber) => {
const handler = () => subscriber.next(navigator.onLine);
window.addEventListener('online', handler);
window.addEventListener('offline', handler);
handler();
return () => {
window.removeEventListener('online', handler);
window.removeEventListener('offline', handler);
};
});
status$ = this.online$.pipe(map((online) => (online ? 'online' : 'offline')));
}
export const injectNetworkStatus$ = () => inject(NetworkStatusService).status$;
export const injectOnline$ = () => inject(NetworkStatusService).online$;

View File

@@ -1,2 +1 @@
export * from './token-login.component';
export * from './token-login.module';

View File

@@ -7,7 +7,6 @@ import { AuthService } from '@core/auth';
templateUrl: 'token-login.component.html',
styleUrls: ['token-login.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: false,
})
export class TokenLoginComponent implements OnInit {
constructor(
@@ -17,7 +16,10 @@ export class TokenLoginComponent implements OnInit {
) {}
ngOnInit() {
if (this._route.snapshot.params.token && !this._authService.isAuthenticated()) {
if (
this._route.snapshot.params.token &&
!this._authService.isAuthenticated()
) {
this._authService.setKeyCardToken(this._route.snapshot.params.token);
this._authService.login();
} else if (!this._authService.isAuthenticated()) {

View File

@@ -1,11 +0,0 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { TokenLoginComponent } from './token-login.component';
@NgModule({
imports: [CommonModule],
exports: [TokenLoginComponent],
declarations: [TokenLoginComponent],
})
export class TokenLoginModule {}

View File

@@ -1,23 +0,0 @@
import { NgModule, ModuleWithProviders } from '@angular/core';
import { StoreModule } from '@ngrx/store';
import { applicationReducer } from './store';
import { ApplicationService } from './application.service';
@NgModule({
declarations: [],
imports: [],
exports: [],
})
export class CoreApplicationModule {
static forRoot(): ModuleWithProviders<CoreApplicationModule> {
return {
ngModule: RootCoreApplicationModule,
};
}
}
@NgModule({
imports: [StoreModule.forFeature('core-application', applicationReducer)],
providers: [ApplicationService],
})
export class RootCoreApplicationModule {}

View File

@@ -1,337 +0,0 @@
import { inject, Injectable } from '@angular/core';
import { BehaviorSubject, Observable, of, firstValueFrom } from 'rxjs';
import { map, filter, withLatestFrom } from 'rxjs/operators';
import { BranchDTO } from '@generated/swagger/checkout-api';
import { isBoolean, isNumber } from '@utils/common';
import { ApplicationService } from './application.service';
import { TabService } from '@isa/core/tabs';
import { ApplicationProcess } from './defs/application-process';
import { Tab, TabMetadata } from '@isa/core/tabs';
import { toObservable } from '@angular/core/rxjs-interop';
import { Store } from '@ngrx/store';
import { removeProcess } from './store/application.actions';
/**
* Adapter service that bridges the old ApplicationService interface with the new TabService.
*
* This adapter allows existing code that depends on ApplicationService to work with the new
* TabService without requiring immediate code changes. It maps ApplicationProcess concepts
* to Tab entities, storing process-specific data in tab metadata.
*
* Key mappings:
* - ApplicationProcess.id <-> Tab.id
* - ApplicationProcess.name <-> Tab.name
* - ApplicationProcess metadata (section, type, etc.) <-> Tab.metadata with 'process_' prefix
* - ApplicationProcess.data <-> Tab.metadata with 'data_' prefix
*
* @example
* ```typescript
* // Inject the adapter instead of the original service
* constructor(private applicationService: ApplicationServiceAdapter) {}
*
* // Use the same API as before
* const process = await this.applicationService.createCustomerProcess();
* this.applicationService.activateProcess(process.id);
* ```
*/
@Injectable({ providedIn: 'root' })
export class ApplicationServiceAdapter extends ApplicationService {
#store = inject(Store);
#tabService = inject(TabService);
#activatedProcessId$ = toObservable(this.#tabService.activatedTabId);
#tabs$ = toObservable(this.#tabService.entities);
#processes$ = this.#tabs$.pipe(
map((tabs) => tabs.map((tab) => this.mapTabToProcess(tab))),
);
#section = new BehaviorSubject<'customer' | 'branch'>('customer');
readonly REGEX_PROCESS_NAME = /^Vorgang \d+$/;
get activatedProcessId() {
return this.#tabService.activatedTabId();
}
get activatedProcessId$() {
return this.#activatedProcessId$;
}
getProcesses$(
section?: 'customer' | 'branch',
): Observable<ApplicationProcess[]> {
return this.#processes$.pipe(
map((processes) =>
processes.filter((process) =>
section ? process.section === section : true,
),
),
);
}
getProcessById$(processId: number): Observable<ApplicationProcess> {
return this.#processes$.pipe(
map((processes) => processes.find((process) => process.id === processId)),
);
}
getSection$(): Observable<'customer' | 'branch'> {
return this.#section.asObservable();
}
getTitle$(): Observable<'Kundenbereich' | 'Filialbereich'> {
return this.getSection$().pipe(
map((section) =>
section === 'customer' ? 'Kundenbereich' : 'Filialbereich',
),
);
}
/** @deprecated */
getActivatedProcessId$(): Observable<number> {
return this.activatedProcessId$;
}
activateProcess(activatedProcessId: number): void {
this.#tabService.activateTab(activatedProcessId);
}
removeProcess(processId: number): void {
this.#tabService.removeTab(processId);
this.#store.dispatch(removeProcess({ processId }));
}
patchProcess(processId: number, changes: Partial<ApplicationProcess>): void {
const tabChanges: {
name?: string;
tags?: string[];
metadata?: Record<string, unknown>;
} = {};
if (changes.name) {
tabChanges.name = changes.name;
}
// Store other ApplicationProcess properties in metadata
const metadataKeys = [
'section',
'type',
'closeable',
'confirmClosing',
'created',
'activated',
'data',
];
metadataKeys.forEach((key) => {
if (tabChanges.metadata === undefined) {
tabChanges.metadata = {};
}
if (changes[key as keyof ApplicationProcess] !== undefined) {
tabChanges.metadata[`process_${key}`] =
changes[key as keyof ApplicationProcess];
}
});
// Apply the changes to the tab
this.#tabService.patchTab(processId, tabChanges);
}
patchProcessData(processId: number, data: Record<string, unknown>): void {
const currentProcess = this.#tabService.entityMap()[processId];
const currentData: TabMetadata =
(currentProcess?.metadata?.['process_data'] as TabMetadata) ?? {};
this.#tabService.patchTab(processId, {
metadata: { [`process_data`]: { ...currentData, ...data } },
});
}
getSelectedBranch$(): Observable<BranchDTO> {
return this.#processes$.pipe(
withLatestFrom(this.#activatedProcessId$),
map(([processes, activatedProcessId]) =>
processes.find((process) => process.id === activatedProcessId),
),
filter((process): process is ApplicationProcess => !!process),
map((process) => process.data?.selectedBranch as BranchDTO),
);
}
async createCustomerProcess(processId?: number): Promise<ApplicationProcess> {
const processes = await firstValueFrom(this.getProcesses$('customer'));
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;
}
/**
* Creates a new ApplicationProcess by first creating a Tab and then storing
* process-specific properties in the tab's metadata.
*
* @param process - The ApplicationProcess to create
* @throws {Error} If process ID already exists or is invalid
*/
async createProcess(process: ApplicationProcess): Promise<void> {
const existingProcess = this.#tabService.entityMap()[process.id];
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;
// Create tab with process data and preserve the process ID
this.#tabService.addTab({
id: process.id,
name: process.name,
tags: [process.section, process.type].filter(Boolean),
metadata: {
process_section: process.section,
process_type: process.type,
process_closeable: process.closeable,
process_confirmClosing: process.confirmClosing,
process_created: process.created,
process_activated: process.activated,
process_data: process.data,
},
});
}
setSection(section: 'customer' | 'branch'): void {
this.#section.next(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),
),
);
}
/**
* Maps Tab entities to ApplicationProcess objects by extracting process-specific
* metadata and combining it with tab properties.
*
* @param tab - The tab entity to convert
* @returns The corresponding ApplicationProcess object
*/
private mapTabToProcess(tab: Tab): ApplicationProcess {
return {
id: tab.id,
name: tab.name,
created:
this.getMetadataValue<number>(tab.metadata, 'process_created') ??
tab.createdAt,
activated:
this.getMetadataValue<number>(tab.metadata, 'process_activated') ??
tab.activatedAt ??
0,
section:
this.getMetadataValue<'customer' | 'branch'>(
tab.metadata,
'process_section',
) ?? 'customer',
type: this.getMetadataValue<string>(tab.metadata, 'process_type'),
closeable:
this.getMetadataValue<boolean>(tab.metadata, 'process_closeable') ??
true,
confirmClosing:
this.getMetadataValue<boolean>(
tab.metadata,
'process_confirmClosing',
) ?? true,
data: this.extractDataFromMetadata(tab.metadata),
};
}
/**
* Extracts ApplicationProcess data properties from tab metadata.
* Data properties are stored with a 'data_' prefix in tab metadata.
*
* @param metadata - The tab metadata object
* @returns The extracted data object or undefined if no data properties exist
*/
private extractDataFromMetadata(
metadata: TabMetadata,
): Record<string, unknown> | undefined {
// Return the complete data object stored under 'process_data'
const processData = metadata?.['process_data'];
if (
processData &&
typeof processData === 'object' &&
processData !== null
) {
return processData as Record<string, unknown>;
}
return undefined;
}
private getMetadataValue<T>(
metadata: TabMetadata,
key: string,
): T | undefined {
return metadata?.[key] as T | undefined;
}
private createTimestamp(): number {
return Date.now();
}
}

View File

@@ -1,233 +0,0 @@
// import { createServiceFactory, SpectatorService, SpyObject } from '@ngneat/spectator';
// import { Store } from '@ngrx/store';
// import { Observable, of } from 'rxjs';
// import { first } from 'rxjs/operators';
// import { ApplicationProcess } from './defs';
// import { ApplicationService } from './application.service';
// import * as actions from './store/application.actions';
// describe('ApplicationService', () => {
// let spectator: SpectatorService<ApplicationService>;
// let store: SpyObject<Store>;
// const createService = createServiceFactory({
// service: ApplicationService,
// mocks: [Store],
// });
// beforeEach(() => {
// spectator = createService({});
// store = spectator.inject(Store);
// });
// it('should be created', () => {
// expect(spectator.service).toBeTruthy();
// });
// describe('activatedProcessId$', () => {
// it('should return an observable', () => {
// expect(spectator.service.activatedProcessId$).toBeInstanceOf(Observable);
// });
// });
// describe('activatedProcessId', () => {
// it('should return the process id as a number', () => {
// spyOnProperty(spectator.service['activatedProcessIdSubject'] as any, 'value').and.returnValue(2);
// expect(spectator.service.activatedProcessId).toBe(2);
// });
// });
// describe('getProcesses$()', () => {
// it('should call select on store and return all selected processes', async () => {
// const processes: ApplicationProcess[] = [
// { id: 1, name: 'Vorgang', type: 'cart', section: 'customer', data: { count: 1 } },
// { id: 2, name: 'Vorgang', type: 'task-calendar', section: 'branch' },
// ];
// store.select.and.returnValue(of(processes));
// const result = await spectator.service.getProcesses$().pipe(first()).toPromise();
// expect(result).toEqual(processes);
// expect(store.select).toHaveBeenCalled();
// });
// it('should call select on store and return all section customer processes', async () => {
// const processes: ApplicationProcess[] = [
// { id: 1, name: 'Vorgang', type: 'cart', section: 'customer', data: { count: 1 } },
// { id: 2, name: 'Vorgang', type: 'task-calendar', section: 'branch' },
// ];
// store.select.and.returnValue(of(processes));
// const result = await spectator.service.getProcesses$('customer').pipe(first()).toPromise();
// expect(result).toEqual([processes[0]]);
// expect(store.select).toHaveBeenCalled();
// });
// it('should call select on store and return all section branch processes', async () => {
// const processes: ApplicationProcess[] = [
// { id: 1, name: 'Vorgang', type: 'cart', section: 'customer', data: { count: 1 } },
// { id: 2, name: 'Vorgang', type: 'task-calendar', section: 'branch' },
// ];
// store.select.and.returnValue(of(processes));
// const result = await spectator.service.getProcesses$('branch').pipe(first()).toPromise();
// expect(result).toEqual([processes[1]]);
// expect(store.select).toHaveBeenCalled();
// });
// });
// describe('getProcessById$()', () => {
// it('should return the process by id', async () => {
// const processes: ApplicationProcess[] = [
// { id: 1, name: 'Vorgang 1', section: 'customer' },
// { id: 2, name: 'Vorgang 2', section: 'customer' },
// ];
// spyOn(spectator.service, 'getProcesses$').and.returnValue(of(processes));
// const process = await spectator.service.getProcessById$(1).toPromise();
// expect(process.id).toBe(1);
// });
// });
// describe('getSection$()', () => {
// it('should return the selected section branch', async () => {
// const section = 'branch';
// store.select.and.returnValue(of(section));
// const result = await spectator.service.getSection$().pipe(first()).toPromise();
// expect(result).toEqual(section);
// expect(store.select).toHaveBeenCalled();
// });
// });
// describe('getActivatedProcessId$', () => {
// it('should return the current selected activated process id', async () => {
// const activatedProcessId = 2;
// store.select.and.returnValue(of({ id: activatedProcessId }));
// const result = await spectator.service.getActivatedProcessId$().pipe(first()).toPromise();
// expect(result).toEqual(activatedProcessId);
// expect(store.select).toHaveBeenCalled();
// });
// });
// describe('activateProcess()', () => {
// it('should dispatch action setActivatedProcess with argument activatedProcessId and action type', () => {
// const activatedProcessId = 2;
// spectator.service.activateProcess(activatedProcessId);
// expect(store.dispatch).toHaveBeenCalledWith({ activatedProcessId, type: actions.setActivatedProcess.type });
// });
// });
// describe('removeProcess()', () => {
// it('should dispatch action removeProcess with argument processId and action type', () => {
// const processId = 2;
// spectator.service.removeProcess(processId);
// expect(store.dispatch).toHaveBeenCalledWith({ processId, type: actions.removeProcess.type });
// });
// });
// describe('createProcess()', () => {
// it('should dispatch action addProcess with process', async () => {
// const process: ApplicationProcess = {
// id: 1,
// name: 'Vorgang 1',
// section: 'customer',
// type: 'cart',
// };
// const timestamp = 100;
// spyOn(spectator.service as any, '_createTimestamp').and.returnValue(timestamp);
// spyOn(spectator.service, 'getProcessById$').and.returnValue(of(undefined));
// await spectator.service.createProcess(process);
// expect(store.dispatch).toHaveBeenCalledWith({
// type: actions.addProcess.type,
// process: {
// ...process,
// activated: 0,
// created: timestamp,
// },
// });
// });
// it('should throw an error if the process id is already existing', async () => {
// const process: ApplicationProcess = {
// id: 1,
// name: 'Vorgang 1',
// section: 'customer',
// type: 'cart',
// };
// spyOn(spectator.service, 'getProcessById$').and.returnValue(of(process));
// await expectAsync(spectator.service.createProcess(process)).toBeRejectedWithError('Process Id existiert bereits');
// });
// it('should throw an error if the process id is not a number', async () => {
// const process: ApplicationProcess = {
// id: undefined,
// name: 'Vorgang 1',
// section: 'customer',
// type: 'cart',
// };
// spyOn(spectator.service, 'getProcessById$').and.returnValue(of({ id: 5, name: 'Vorgang 2', section: 'customer' }));
// await expectAsync(spectator.service.createProcess(process)).toBeRejectedWithError('Process Id nicht gesetzt');
// });
// });
// describe('patchProcess', () => {
// it('should dispatch action patchProcess with changes', async () => {
// const process: ApplicationProcess = {
// id: 1,
// name: 'Vorgang 1',
// section: 'customer',
// type: 'cart',
// };
// await spectator.service.patchProcess(process.id, process);
// expect(store.dispatch).toHaveBeenCalledWith({
// type: actions.patchProcess.type,
// processId: process.id,
// changes: {
// ...process,
// },
// });
// });
// });
// describe('setSection()', () => {
// it('should dispatch action setSection with argument section and action type', () => {
// const section = 'customer';
// spectator.service.setSection(section);
// expect(store.dispatch).toHaveBeenCalledWith({ section, type: actions.setSection.type });
// });
// });
// describe('getLastActivatedProcessWithSectionAndType()', () => {
// it('should return the last activated process by section and type', async () => {
// const processes: ApplicationProcess[] = [
// { id: 1, name: 'Vorgang 1', section: 'customer', type: 'cart', activated: 100 },
// { id: 2, name: 'Vorgang 2', section: 'customer', type: 'cart', activated: 200 },
// { id: 3, name: 'Vorgang 3', section: 'customer', type: 'goodsOut', activated: 300 },
// ];
// spyOn(spectator.service, 'getProcesses$').and.returnValue(of(processes));
// expect(await spectator.service.getLastActivatedProcessWithSectionAndType$('customer', 'cart').pipe(first()).toPromise()).toBe(
// processes[1]
// );
// });
// });
// describe('getLastActivatedProcessWithSection()', () => {
// it('should return the last activated process by section', async () => {
// const processes: ApplicationProcess[] = [
// { id: 1, name: 'Vorgang 1', section: 'customer', activated: 100 },
// { id: 2, name: 'Vorgang 2', section: 'customer', activated: 200 },
// { id: 3, name: 'Vorgang 3', section: 'customer', activated: 300 },
// ];
// spyOn(spectator.service, 'getProcesses$').and.returnValue(of(processes));
// expect(await spectator.service.getLastActivatedProcessWithSection$('customer').pipe(first()).toPromise()).toBe(processes[2]);
// });
// });
// describe('_createTimestamp', () => {
// it('should return the current timestamp in ms', () => {
// expect(spectator.service['_createTimestamp']()).toBeCloseTo(Date.now());
// });
// });
// });

View File

@@ -1,41 +1,68 @@
import { Injectable } from '@angular/core';
import { Store } from '@ngrx/store';
import { inject, Injectable } from '@angular/core';
import { BehaviorSubject, Observable, of, firstValueFrom } from 'rxjs';
import { map, filter, withLatestFrom } from 'rxjs/operators';
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';
import { TabService } from '@isa/core/tabs';
import { ApplicationProcess } from './defs/application-process';
import { Tab, TabMetadata } from '@isa/core/tabs';
import { toObservable } from '@angular/core/rxjs-interop';
import { Store } from '@ngrx/store';
import { removeProcess } from './store/application.actions';
@Injectable()
/**
* Adapter service that bridges the old ApplicationService interface with the new TabService.
*
* This adapter allows existing code that depends on ApplicationService to work with the new
* TabService without requiring immediate code changes. It maps ApplicationProcess concepts
* to Tab entities, storing process-specific data in tab metadata.
*
* Key mappings:
* - ApplicationProcess.id <-> Tab.id
* - ApplicationProcess.name <-> Tab.name
* - ApplicationProcess metadata (section, type, etc.) <-> Tab.metadata with 'process_' prefix
* - ApplicationProcess.data <-> Tab.metadata with 'data_' prefix
*
* @example
* ```typescript
* // Inject the adapter instead of the original service
* constructor(private applicationService: ApplicationServiceAdapter) {}
*
* // Use the same API as before
* const process = await this.applicationService.createCustomerProcess();
* this.applicationService.activateProcess(process.id);
* ```
*/
@Injectable({ providedIn: 'root' })
export class ApplicationService {
private activatedProcessIdSubject = new BehaviorSubject<number>(undefined);
#store = inject(Store);
#tabService = inject(TabService);
#activatedProcessId$ = toObservable(this.#tabService.activatedTabId);
#tabs$ = toObservable(this.#tabService.entities);
#processes$ = this.#tabs$.pipe(
map((tabs) => tabs.map((tab) => this.mapTabToProcess(tab))),
);
#section = new BehaviorSubject<'customer' | 'branch'>('customer');
readonly REGEX_PROCESS_NAME = /^Vorgang \d+$/;
get activatedProcessId() {
return this.activatedProcessIdSubject.value;
return this.#tabService.activatedTabId();
}
get activatedProcessId$() {
return this.activatedProcessIdSubject.asObservable();
return this.#activatedProcessId$;
}
constructor(private store: Store) {}
getProcesses$(section?: 'customer' | 'branch') {
const processes$ = this.store.select(selectProcesses);
return processes$.pipe(
getProcesses$(
section?: 'customer' | 'branch',
): Observable<ApplicationProcess[]> {
return this.#processes$.pipe(
map((processes) =>
processes.filter((process) =>
section ? process.section === section : true,
@@ -45,69 +72,96 @@ export class ApplicationService {
}
getProcessById$(processId: number): Observable<ApplicationProcess> {
return this.getProcesses$().pipe(
return this.#processes$.pipe(
map((processes) => processes.find((process) => process.id === processId)),
);
}
getSection$() {
return this.store.select(selectSection);
getSection$(): Observable<'customer' | 'branch'> {
return this.#section.asObservable();
}
getTitle$() {
getTitle$(): Observable<'Kundenbereich' | 'Filialbereich'> {
return this.getSection$().pipe(
map((section) => {
return section === 'customer' ? 'Kundenbereich' : 'Filialbereich';
}),
map((section) =>
section === 'customer' ? 'Kundenbereich' : 'Filialbereich',
),
);
}
/** @deprecated */
getActivatedProcessId$() {
return this.store
.select(selectActivatedProcess)
.pipe(map((process) => process?.id));
getActivatedProcessId$(): Observable<number> {
return this.activatedProcessId$;
}
activateProcess(activatedProcessId: number) {
this.store.dispatch(setActivatedProcess({ activatedProcessId }));
this.activatedProcessIdSubject.next(activatedProcessId);
activateProcess(activatedProcessId: number): void {
this.#tabService.activateTab(activatedProcessId);
}
removeProcess(processId: number) {
this.store.dispatch(removeProcess({ processId }));
removeProcess(processId: number): void {
this.#tabService.removeTab(processId);
this.#store.dispatch(removeProcess({ processId }));
}
patchProcess(processId: number, changes: Partial<ApplicationProcess>) {
this.store.dispatch(patchProcess({ processId, changes }));
patchProcess(processId: number, changes: Partial<ApplicationProcess>): void {
const tabChanges: {
name?: string;
tags?: string[];
metadata?: Record<string, unknown>;
} = {};
if (changes.name) {
tabChanges.name = changes.name;
}
patchProcessData(processId: number, data: Record<string, any>) {
this.store.dispatch(patchProcessData({ processId, data }));
// Store other ApplicationProcess properties in metadata
const metadataKeys = [
'section',
'type',
'closeable',
'confirmClosing',
'created',
'activated',
'data',
];
metadataKeys.forEach((key) => {
if (tabChanges.metadata === undefined) {
tabChanges.metadata = {};
}
getSelectedBranch$(processId?: number): Observable<BranchDTO> {
if (!processId) {
return this.activatedProcessId$.pipe(
switchMap((processId) =>
this.getProcessById$(processId).pipe(
map((process) => process?.data?.selectedBranch),
),
if (changes[key as keyof ApplicationProcess] !== undefined) {
tabChanges.metadata[`process_${key}`] =
changes[key as keyof ApplicationProcess];
}
});
// Apply the changes to the tab
this.#tabService.patchTab(processId, tabChanges);
}
patchProcessData(processId: number, data: Record<string, unknown>): void {
const currentProcess = this.#tabService.entityMap()[processId];
const currentData: TabMetadata =
(currentProcess?.metadata?.['process_data'] as TabMetadata) ?? {};
this.#tabService.patchTab(processId, {
metadata: { [`process_data`]: { ...currentData, ...data } },
});
}
getSelectedBranch$(): Observable<BranchDTO> {
return this.#processes$.pipe(
withLatestFrom(this.#activatedProcessId$),
map(([processes, activatedProcessId]) =>
processes.find((process) => process.id === activatedProcessId),
),
filter((process): process is ApplicationProcess => !!process),
map((process) => process.data?.selectedBranch as BranchDTO),
);
}
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 processes = await firstValueFrom(this.getProcesses$('customer'));
const processIds = processes
.filter((x) => this.REGEX_PROCESS_NAME.test(x.name))
@@ -124,14 +178,18 @@ export class ApplicationService {
};
await this.createProcess(process);
return process;
}
async createProcess(process: ApplicationProcess) {
const existingProcess = await this.getProcessById$(process?.id)
.pipe(first())
.toPromise();
/**
* Creates a new ApplicationProcess by first creating a Tab and then storing
* process-specific properties in the tab's metadata.
*
* @param process - The ApplicationProcess to create
* @throws {Error} If process ID already exists or is invalid
*/
async createProcess(process: ApplicationProcess): Promise<void> {
const existingProcess = this.#tabService.entityMap()[process.id];
if (existingProcess?.id === process?.id) {
throw new Error('Process Id existiert bereits');
}
@@ -148,13 +206,28 @@ export class ApplicationService {
process.confirmClosing = true;
}
process.created = this._createTimestamp();
process.created = this.createTimestamp();
process.activated = 0;
this.store.dispatch(addProcess({ process }));
// Create tab with process data and preserve the process ID
this.#tabService.addTab({
id: process.id,
name: process.name,
tags: [process.section, process.type].filter(Boolean),
metadata: {
process_section: process.section,
process_type: process.type,
process_closeable: process.closeable,
process_confirmClosing: process.confirmClosing,
process_created: process.created,
process_activated: process.activated,
process_data: process.data,
},
});
}
setSection(section: 'customer' | 'branch') {
this.store.dispatch(setSection({ section }));
setSection(section: 'customer' | 'branch'): void {
this.#section.next(section);
}
getLastActivatedProcessWithSectionAndType$(
@@ -190,7 +263,74 @@ export class ApplicationService {
);
}
private _createTimestamp() {
/**
* Maps Tab entities to ApplicationProcess objects by extracting process-specific
* metadata and combining it with tab properties.
*
* @param tab - The tab entity to convert
* @returns The corresponding ApplicationProcess object
*/
private mapTabToProcess(tab: Tab): ApplicationProcess {
return {
id: tab.id,
name: tab.name,
created:
this.getMetadataValue<number>(tab.metadata, 'process_created') ??
tab.createdAt,
activated:
this.getMetadataValue<number>(tab.metadata, 'process_activated') ??
tab.activatedAt ??
0,
section:
this.getMetadataValue<'customer' | 'branch'>(
tab.metadata,
'process_section',
) ?? 'customer',
type: this.getMetadataValue<string>(tab.metadata, 'process_type'),
closeable:
this.getMetadataValue<boolean>(tab.metadata, 'process_closeable') ??
true,
confirmClosing:
this.getMetadataValue<boolean>(
tab.metadata,
'process_confirmClosing',
) ?? true,
data: this.extractDataFromMetadata(tab.metadata),
};
}
/**
* Extracts ApplicationProcess data properties from tab metadata.
* Data properties are stored with a 'data_' prefix in tab metadata.
*
* @param metadata - The tab metadata object
* @returns The extracted data object or undefined if no data properties exist
*/
private extractDataFromMetadata(
metadata: TabMetadata,
): Record<string, unknown> | undefined {
// Return the complete data object stored under 'process_data'
const processData = metadata?.['process_data'];
if (
processData &&
typeof processData === 'object' &&
processData !== null
) {
return processData as Record<string, unknown>;
}
return undefined;
}
private getMetadataValue<T>(
metadata: TabMetadata,
key: string,
): T | undefined {
return metadata?.[key] as T | undefined;
}
private createTimestamp(): number {
return Date.now();
}
}

View File

@@ -1,5 +1,3 @@
export * from './application.module';
export * from './application.service';
export * from './application.service-adapter';
export * from './defs';
export * from './store';
export * from './store/application.actions';

View File

@@ -1,27 +1,8 @@
import { createAction, props } from '@ngrx/store';
import { ApplicationProcess } from '..';
const prefix = '[CORE-APPLICATION]';
export const setTitle = createAction(`${prefix} Set Title`, props<{ title: string }>());
export const setSection = createAction(`${prefix} Set Section`, props<{ section: 'customer' | 'branch' }>());
export const addProcess = createAction(`${prefix} Add Process`, props<{ process: ApplicationProcess }>());
export const removeProcess = createAction(`${prefix} Remove Process`, props<{ processId: number }>());
export const setActivatedProcess = createAction(
`${prefix} Set Activated Process`,
props<{ activatedProcessId: number }>(),
);
export const patchProcess = createAction(
`${prefix} Patch Process`,
props<{ processId: number; changes: Partial<ApplicationProcess> }>(),
);
export const patchProcessData = createAction(
`${prefix} Patch Process Data`,
props<{ processId: number; data: Record<string, any> }>(),
export const removeProcess = createAction(
`${prefix} Remove Process`,
props<{ processId: number }>(),
);

View File

@@ -1,200 +0,0 @@
import { INITIAL_APPLICATION_STATE } from './application.state';
import * as actions from './application.actions';
import { applicationReducer } from './application.reducer';
import { ApplicationProcess } from '../defs';
import { ApplicationState } from './application.state';
describe('applicationReducer', () => {
describe('setSection()', () => {
it('should return modified state with section customer', () => {
const initialState = INITIAL_APPLICATION_STATE;
const action = actions.setSection({ section: 'customer' });
const state = applicationReducer(initialState, action);
expect(state).toEqual({
...initialState,
section: 'customer',
});
});
it('should return modified state with section branch', () => {
const initialState = INITIAL_APPLICATION_STATE;
const action = actions.setSection({ section: 'branch' });
const state = applicationReducer(initialState, action);
expect(state).toEqual({
...initialState,
section: 'branch',
});
});
});
describe('addProcess()', () => {
it('should return modified state with new process if no processes are registered in the state', () => {
const initialState = INITIAL_APPLICATION_STATE;
const process: ApplicationProcess = {
id: 1,
name: 'Vorgang',
section: 'customer',
type: 'cart',
data: {},
};
const action = actions.addProcess({ process });
const state = applicationReducer(initialState, action);
expect(state.processes[0]).toEqual(process);
});
});
describe('patchProcess()', () => {
it('should return modified state with updated process when id is found', () => {
const initialState = INITIAL_APPLICATION_STATE;
const process: ApplicationProcess = {
id: 1,
name: 'Vorgang',
section: 'customer',
type: 'cart',
};
const action = actions.patchProcess({ processId: process.id, changes: { ...process, name: 'Test' } });
const state = applicationReducer(
{
...initialState,
processes: [process],
},
action,
);
expect(state.processes[0].name).toEqual('Test');
});
it('should return unmodified state when id is not existing', () => {
const initialState = INITIAL_APPLICATION_STATE;
const process: ApplicationProcess = {
id: 1,
name: 'Vorgang',
section: 'customer',
type: 'cart',
};
const action = actions.patchProcess({ processId: process.id, changes: { ...process, id: 2 } });
const state = applicationReducer(
{
...initialState,
processes: [process],
},
action,
);
expect(state.processes).toEqual([process]);
});
});
describe('removeProcess()', () => {
it('should return initial state if no processes are registered in the state', () => {
const initialState = INITIAL_APPLICATION_STATE;
const action = actions.removeProcess({ processId: 2 });
const state = applicationReducer(initialState, action);
expect(state).toEqual(initialState);
});
it('should return the unmodified state if processId not found', () => {
const initialState = INITIAL_APPLICATION_STATE;
const modifiedState: ApplicationState = {
...initialState,
section: 'customer',
processes: [
{
id: 1,
name: 'Vorgang',
section: 'customer',
type: 'cart',
},
{
id: 4,
name: 'Vorgang',
section: 'customer',
type: 'goods-out',
},
] as ApplicationProcess[],
};
const action = actions.removeProcess({ processId: 2 });
const state = applicationReducer(modifiedState, action);
expect(state).toEqual(modifiedState);
});
it('should return modified state, after process gets removed', () => {
const initialState = INITIAL_APPLICATION_STATE;
const modifiedState: ApplicationState = {
...initialState,
section: 'customer',
processes: [
{
id: 1,
name: 'Vorgang',
section: 'customer',
type: 'cart',
},
{
id: 2,
name: 'Vorgang',
section: 'customer',
type: 'goods-out',
},
] as ApplicationProcess[],
};
const action = actions.removeProcess({ processId: 2 });
const state = applicationReducer(modifiedState, action);
expect(state.processes).toEqual([
{
id: 1,
name: 'Vorgang',
section: 'customer',
type: 'cart',
},
]);
});
});
describe('setActivatedProcess()', () => {
it('should return modified state with process.activated value', () => {
const process: ApplicationProcess = {
id: 3,
name: 'Vorgang 3',
section: 'customer',
};
const initialState: ApplicationState = {
...INITIAL_APPLICATION_STATE,
processes: [process],
};
const action = actions.setActivatedProcess({ activatedProcessId: 3 });
const state = applicationReducer(initialState, action);
expect(state.processes[0].activated).toBeDefined();
});
it('should return modified state without process.activated value when activatedProcessId doesnt exist', () => {
const process: ApplicationProcess = {
id: 1,
name: 'Vorgang 3',
section: 'customer',
};
const initialState: ApplicationState = {
...INITIAL_APPLICATION_STATE,
processes: [process],
};
const action = actions.setActivatedProcess({ activatedProcessId: 3 });
const state = applicationReducer(initialState, action);
expect(state.processes[0].activated).toBeUndefined();
});
});
});

View File

@@ -1,56 +0,0 @@
import { Action, createReducer, on } from '@ngrx/store';
import {
setSection,
addProcess,
removeProcess,
setActivatedProcess,
patchProcess,
patchProcessData,
setTitle,
} from './application.actions';
import { ApplicationState, INITIAL_APPLICATION_STATE } from './application.state';
const _applicationReducer = createReducer(
INITIAL_APPLICATION_STATE,
on(setTitle, (state, { title }) => ({ ...state, title })),
on(setSection, (state, { section }) => ({ ...state, section })),
on(addProcess, (state, { process }) => ({ ...state, processes: [...state.processes, { data: {}, ...process }] })),
on(removeProcess, (state, { processId }) => {
const processes = state?.processes?.filter((process) => process.id !== processId) || [];
return { ...state, processes };
}),
on(setActivatedProcess, (state, { activatedProcessId }) => {
const processes = state.processes.map((process) => {
if (process.id === activatedProcessId) {
return { ...process, activated: Date.now() };
}
return process;
});
return { ...state, processes };
}),
on(patchProcess, (state, { processId, changes }) => {
const processes = state.processes.map((process) => {
if (process.id === processId) {
return { ...process, ...changes, id: processId };
}
return process;
});
return { ...state, processes };
}),
on(patchProcessData, (state, { processId, data }) => {
const processes = state.processes.map((process) => {
if (process.id === processId) {
return { ...process, data: { ...(process.data || {}), ...data } };
}
return process;
});
return { ...state, processes };
}),
);
export function applicationReducer(state: ApplicationState, action: Action) {
return _applicationReducer(state, action);
}

View File

@@ -1,35 +0,0 @@
// import { ApplicationState } from './application.state';
// import { ApplicationProcess } from '../defs';
// import * as selectors from './application.selectors';
// describe('applicationSelectors', () => {
// it('should select the processes', () => {
// const processes: ApplicationProcess[] = [{ id: 1, name: 'Vorgang 1', section: 'customer' }];
// const state: ApplicationState = {
// processes,
// section: 'customer',
// };
// expect(selectors.selectProcesses.projector(state)).toEqual(processes);
// });
// it('should select the section', () => {
// const state: ApplicationState = {
// processes: [],
// section: 'customer',
// };
// expect(selectors.selectSection.projector(state)).toEqual('customer');
// });
// it('should select the activatedProcess', () => {
// const processes: ApplicationProcess[] = [
// { id: 1, name: 'Vorgang 1', section: 'customer', activated: 100 },
// { id: 2, name: 'Vorgang 2', section: 'customer', activated: 300 },
// { id: 3, name: 'Vorgang 3', section: 'customer', activated: 200 },
// ];
// const state: ApplicationState = {
// processes,
// section: 'customer',
// };
// expect(selectors.selectActivatedProcess.projector(state)).toEqual(processes[1]);
// });
// });

View File

@@ -1,18 +0,0 @@
import { createFeatureSelector, createSelector } from '@ngrx/store';
import { ApplicationState } from './application.state';
export const selectApplicationState = createFeatureSelector<ApplicationState>('core-application');
export const selectTitle = createSelector(selectApplicationState, (s) => s.title);
export const selectSection = createSelector(selectApplicationState, (s) => s.section);
export const selectProcesses = createSelector(selectApplicationState, (s) => s.processes);
export const selectActivatedProcess = createSelector(selectApplicationState, (s) =>
s?.processes?.reduce((process, current) => {
if (!process) {
return current;
}
return process.activated > current.activated ? process : current;
}, undefined),
);

View File

@@ -1,13 +0,0 @@
import { ApplicationProcess } from '../defs';
export interface ApplicationState {
title: string;
processes: ApplicationProcess[];
section: 'customer' | 'branch';
}
export const INITIAL_APPLICATION_STATE: ApplicationState = {
title: '',
processes: [],
section: 'customer',
};

View File

@@ -1,6 +0,0 @@
// start:ng42.barrel
export * from './application.actions';
export * from './application.reducer';
export * from './application.selectors';
export * from './application.state';
// end:ng42.barrel

View File

@@ -3,7 +3,7 @@ import { inject, Injectable } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { EnvironmentService } from '@core/environment';
import { UiConfirmModalComponent, UiModalResult, UiModalService } from '@ui/modal';
import { injectNetworkStatus$ } from '../../app/services';
import { injectNetworkStatus$ } from '@isa/core/connectivity';
import { AuthService } from './auth.service';
import { AuthService as IsaAuthService } from '@generated/swagger/isa-api';
import { firstValueFrom, lastValueFrom } from 'rxjs';

View File

@@ -1,22 +1,15 @@
import { ModuleWithProviders, NgModule } from '@angular/core';
import { EffectsModule } from '@ngrx/effects';
import { StoreModule } from '@ngrx/store';
import { EnvironmentProviders, makeEnvironmentProviders } from '@angular/core';
import { provideEffects } from '@ngrx/effects';
import { provideState } from '@ngrx/store';
import { BreadcrumbService } from './breadcrumb.service';
import { BreadcrumbEffects } from './store/breadcrumb.effect';
import { breadcrumbReducer } from './store/breadcrumb.reducer';
import { featureName } from './store/breadcrumb.state';
@NgModule()
export class CoreBreadcrumbModule {
static forRoot(): ModuleWithProviders<CoreBreadcrumbModule> {
return {
ngModule: CoreBreadcrumbForRootModule,
};
}
export function provideCoreBreadcrumb(): EnvironmentProviders {
return makeEnvironmentProviders([
provideState({ name: featureName, reducer: breadcrumbReducer }),
provideEffects(BreadcrumbEffects),
BreadcrumbService,
]);
}
@NgModule({
imports: [StoreModule.forFeature(featureName, breadcrumbReducer), EffectsModule.forFeature([BreadcrumbEffects])],
providers: [BreadcrumbService],
})
export class CoreBreadcrumbForRootModule {}

View File

@@ -1,12 +0,0 @@
import { ModuleWithProviders, NgModule } from '@angular/core';
import { DomainAvailabilityService } from './availability.service';
@NgModule()
export class DomainAvailabilityModule {
static forRoot(): ModuleWithProviders<DomainAvailabilityModule> {
return {
ngModule: DomainAvailabilityModule,
providers: [DomainAvailabilityService],
};
}
}

View File

@@ -16,7 +16,15 @@ import {
AvailabilityType,
} from '@generated/swagger/availability-api';
import { AvailabilityDTO as CatAvailabilityDTO } from '@generated/swagger/cat-search-api';
import { map, shareReplay, switchMap, withLatestFrom, mergeMap, timeout, first } from 'rxjs/operators';
import {
map,
shareReplay,
switchMap,
withLatestFrom,
mergeMap,
timeout,
first,
} from 'rxjs/operators';
import { isArray, memorize } from '@utils/common';
import { LogisticianDTO, LogisticianService } from '@generated/swagger/oms-api';
import {
@@ -30,7 +38,7 @@ import { AvailabilityByBranchDTO, ItemData, Ssc } from './defs';
import { Availability } from './defs/availability';
import { isEmpty } from 'lodash';
@Injectable()
@Injectable({ providedIn: 'root' })
export class DomainAvailabilityService {
// Ticket #3378 Keep Result List Items and Details Page SSC in sync
sscs$ = new BehaviorSubject<Array<Ssc>>([]);
@@ -45,8 +53,12 @@ export class DomainAvailabilityService {
) {}
@memorize({ ttl: 10000 })
memorizedAvailabilityShippingAvailability(request: Array<AvailabilityRequestDTO>) {
return this._availabilityService.AvailabilityShippingAvailability(request).pipe(shareReplay(1));
memorizedAvailabilityShippingAvailability(
request: Array<AvailabilityRequestDTO>,
) {
return this._availabilityService
.AvailabilityShippingAvailability(request)
.pipe(shareReplay(1));
}
@memorize()
@@ -60,7 +72,9 @@ export class DomainAvailabilityService {
@memorize()
getTakeAwaySupplier(): Observable<SupplierDTO> {
return this._supplierService.StoreCheckoutSupplierGetSuppliers({}).pipe(
map(({ result }) => result?.find((supplier) => supplier?.supplierNumber === 'F')),
map(({ result }) =>
result?.find((supplier) => supplier?.supplierNumber === 'F'),
),
shareReplay(1),
);
}
@@ -117,7 +131,9 @@ export class DomainAvailabilityService {
@memorize({})
getLogisticians(): Observable<LogisticianDTO> {
return this._logisticanService.LogisticianGetLogisticians({}).pipe(
map((response) => response.result?.find((l) => l.logisticianNumber === '2470')),
map((response) =>
response.result?.find((l) => l.logisticianNumber === '2470'),
),
shareReplay(1),
);
}
@@ -133,22 +149,27 @@ export class DomainAvailabilityService {
price: PriceDTO;
quantity: number;
}): Observable<AvailabilityByBranchDTO[]> {
return this._stockService.StockStockRequest({ stockRequest: { branchIds, itemId } }).pipe(
return this._stockService
.StockStockRequest({ stockRequest: { branchIds, itemId } })
.pipe(
map((response) => response.result),
withLatestFrom(this.getTakeAwaySupplier()),
map(([result, supplier]) => {
const availabilities: AvailabilityByBranchDTO[] = result.map((stockInfo) => {
const availabilities: AvailabilityByBranchDTO[] = result.map(
(stockInfo) => {
return {
availableQuantity: stockInfo.availableQuantity,
availabilityType: quantity <= stockInfo.inStock ? 1024 : 1, // 1024 (=Available)
inStock: stockInfo.inStock,
supplierSSC: quantity <= stockInfo.inStock ? '999' : '',
supplierSSCText: quantity <= stockInfo.inStock ? 'Filialentnahme' : '',
supplierSSCText:
quantity <= stockInfo.inStock ? 'Filialentnahme' : '',
price,
supplier: { id: supplier?.id },
branchId: stockInfo.branchId,
};
});
},
);
return availabilities;
}),
shareReplay(1),
@@ -165,11 +186,16 @@ export class DomainAvailabilityService {
quantity: number;
branch?: BranchDTO;
}): Observable<AvailabilityDTO> {
const request = branch ? this.getStockByBranch(branch.id) : this.getDefaultStock();
const request = branch
? this.getStockByBranch(branch.id)
: this.getDefaultStock();
return request.pipe(
switchMap((s) =>
combineLatest([
this._stockService.StockInStock({ articleIds: [item.itemId], stockId: s.id }),
this._stockService.StockInStock({
articleIds: [item.itemId],
stockId: s.id,
}),
this.getTakeAwaySupplier(),
this.getDefaultBranch(),
]),
@@ -201,11 +227,19 @@ export class DomainAvailabilityService {
quantity: number;
}): Observable<AvailabilityDTO> {
return combineLatest([
this._stockService.StockStockRequest({ stockRequest: { branchIds: [branch.id], itemId } }),
this._stockService.StockStockRequest({
stockRequest: { branchIds: [branch.id], itemId },
}),
this.getTakeAwaySupplier(),
]).pipe(
map(([response, supplier]) => {
return this._mapToTakeAwayAvailability({ response, supplier, branchId: branch.id, quantity, price });
return this._mapToTakeAwayAvailability({
response,
supplier,
branchId: branch.id,
quantity,
price,
});
}),
shareReplay(1),
);
@@ -222,9 +256,13 @@ export class DomainAvailabilityService {
quantity: number;
branchId?: number;
}): Observable<AvailabilityDTO> {
const request = branchId ? this.getStockByBranch(branchId) : this.getDefaultStock();
const request = branchId
? this.getStockByBranch(branchId)
: this.getDefaultStock();
return request.pipe(
switchMap((s) => this._stockService.StockInStockByEAN({ eans, stockId: s.id })),
switchMap((s) =>
this._stockService.StockInStockByEAN({ eans, stockId: s.id }),
),
withLatestFrom(this.getTakeAwaySupplier(), this.getDefaultBranch()),
map(([response, supplier, defaultBranch]) => {
return this._mapToTakeAwayAvailability({
@@ -239,10 +277,19 @@ export class DomainAvailabilityService {
);
}
getTakeAwayAvailabilitiesByEans({ eans }: { eans: string[] }): Observable<StockInfoDTO[]> {
getTakeAwayAvailabilitiesByEans({
eans,
}: {
eans: string[];
}): Observable<StockInfoDTO[]> {
const eansFiltered = Array.from(new Set(eans));
return this.getDefaultStock().pipe(
switchMap((s) => this._stockService.StockInStockByEAN({ eans: eansFiltered, stockId: s.id })),
switchMap((s) =>
this._stockService.StockInStockByEAN({
eans: eansFiltered,
stockId: s.id,
}),
),
withLatestFrom(this.getTakeAwaySupplier(), this.getDefaultBranch()),
map((response) => response[0].result),
shareReplay(1),
@@ -276,7 +323,13 @@ export class DomainAvailabilityService {
}
@memorize({ ttl: 10000 })
getDeliveryAvailability({ item, quantity }: { item: ItemData; quantity: number }): Observable<AvailabilityDTO> {
getDeliveryAvailability({
item,
quantity,
}: {
item: ItemData;
quantity: number;
}): Observable<AvailabilityDTO> {
return this.memorizedAvailabilityShippingAvailability([
{
ean: item?.ean,
@@ -292,7 +345,13 @@ export class DomainAvailabilityService {
}
@memorize({ ttl: 10000 })
getDigDeliveryAvailability({ item, quantity }: { item: ItemData; quantity: number }): Observable<AvailabilityDTO> {
getDigDeliveryAvailability({
item,
quantity,
}: {
item: ItemData;
quantity: number;
}): Observable<AvailabilityDTO> {
return this.memorizedAvailabilityShippingAvailability([
{
qty: quantity,
@@ -312,7 +371,10 @@ export class DomainAvailabilityService {
sscText: preferred?.sscText,
supplier: { id: preferred?.supplierId },
isPrebooked: preferred?.isPrebooked,
estimatedShippingDate: preferred?.requestStatusCode === '32' ? preferred?.altAt : preferred?.at,
estimatedShippingDate:
preferred?.requestStatusCode === '32'
? preferred?.altAt
: preferred?.at,
estimatedDelivery: preferred?.estimatedDelivery,
price: preferred?.price,
logistician: { id: preferred?.logisticianId },
@@ -343,7 +405,11 @@ export class DomainAvailabilityService {
return currentBranch$.pipe(
timeout(5000),
mergeMap((defaultBranch) =>
this.getPickUpAvailability({ item, quantity, branch: branch ?? defaultBranch }).pipe(
this.getPickUpAvailability({
item,
quantity,
branch: branch ?? defaultBranch,
}).pipe(
mergeMap((availability) =>
logistician$.pipe(
map((logistician) => ({
@@ -359,7 +425,11 @@ export class DomainAvailabilityService {
}
@memorize({ ttl: 10000 })
getDownloadAvailability({ item }: { item: ItemData }): Observable<AvailabilityDTO> {
getDownloadAvailability({
item,
}: {
item: ItemData;
}): Observable<AvailabilityDTO> {
return this.memorizedAvailabilityShippingAvailability([
{
ean: item?.ean,
@@ -378,7 +448,10 @@ export class DomainAvailabilityService {
sscText: preferred?.sscText,
supplier: { id: preferred?.supplierId },
isPrebooked: preferred?.isPrebooked,
estimatedShippingDate: preferred?.requestStatusCode === '32' ? preferred?.altAt : preferred?.at,
estimatedShippingDate:
preferred?.requestStatusCode === '32'
? preferred?.altAt
: preferred?.at,
price: preferred?.price,
supplierProductNumber: preferred?.supplierProductNumber,
logistician: { id: preferred?.logisticianId },
@@ -392,12 +465,18 @@ export class DomainAvailabilityService {
}
@memorize({ ttl: 10000 })
getTakeAwayAvailabilities(items: { id: number; price: PriceDTO }[], branchId: number) {
getTakeAwayAvailabilities(
items: { id: number; price: PriceDTO }[],
branchId: number,
) {
return this._stockService.StockGetStocksByBranch({ branchId }).pipe(
map((req) => req.result?.find((_) => true)?.id),
switchMap((stockId) =>
stockId
? this._stockService.StockInStock({ articleIds: items.map((i) => i.id), stockId })
? this._stockService.StockInStock({
articleIds: items.map((i) => i.id),
stockId,
})
: of({ result: [] } as ResponseArgsOfIEnumerableOfStockInfoDTO),
),
timeout(20000),
@@ -417,10 +496,19 @@ export class DomainAvailabilityService {
}
@memorize({ ttl: 10000 })
getPickUpAvailabilities(payload: AvailabilityRequestDTO[], preferred?: boolean) {
return this._availabilityService.AvailabilityStoreAvailability(payload).pipe(
getPickUpAvailabilities(
payload: AvailabilityRequestDTO[],
preferred?: boolean,
) {
return this._availabilityService
.AvailabilityStoreAvailability(payload)
.pipe(
timeout(20000),
map((response) => (preferred ? this._mapToPickUpAvailability(response.result) : response.result)),
map((response) =>
preferred
? this._mapToPickUpAvailability(response.result)
: response.result,
),
);
}
@@ -448,7 +536,10 @@ export class DomainAvailabilityService {
timeout(20000),
switchMap((availability) =>
logistician$.pipe(
map((logistician) => ({ availability: [...availability], logistician: { id: logistician.id } })),
map((logistician) => ({
availability: [...availability],
logistician: { id: logistician.id },
})),
),
),
shareReplay(1),
@@ -465,7 +556,10 @@ export class DomainAvailabilityService {
return availability?.price || catalogAvailability?.price;
case 'delivery':
case 'dig-delivery':
if (catalogAvailability?.price?.value?.value < availability?.price?.value?.value) {
if (
catalogAvailability?.price?.value?.value <
availability?.price?.value?.value
) {
return catalogAvailability?.price;
}
return availability?.price || catalogAvailability?.price;
@@ -477,7 +571,9 @@ export class DomainAvailabilityService {
if (availability?.supplier?.id === 16 && availability?.inStock == 0) {
return false;
}
return [2, 32, 256, 1024, 2048, 4096].some((code) => availability?.availabilityType === code);
return [2, 32, 256, 1024, 2048, 4096].some(
(code) => availability?.availabilityType === code,
);
}
private _mapToTakeAwayAvailability({
@@ -524,7 +620,10 @@ export class DomainAvailabilityService {
const availability = {
itemId: stockInfo.itemId,
availabilityType: quantity <= inStock ? (1024 as AvailabilityType) : (1 as AvailabilityType), // 1024 (=Available)
availabilityType:
quantity <= inStock
? (1024 as AvailabilityType)
: (1 as AvailabilityType), // 1024 (=Available)
inStock: inStock,
supplierSSC: quantity <= inStock ? '999' : '',
supplierSSCText: quantity <= inStock ? 'Filialentnahme' : '',
@@ -539,7 +638,10 @@ export class DomainAvailabilityService {
): Availability<AvailabilityDTO, SwaggerAvailabilityDTO>[] {
if (isArray(availabilities)) {
const preferred = availabilities.filter((f) => f.preferred === 1);
const totalAvailable = availabilities.reduce((sum, av) => sum + (av?.qty || 0), 0);
const totalAvailable = availabilities.reduce(
(sum, av) => sum + (av?.qty || 0),
0,
);
return preferred.map((p) => {
return [
@@ -550,7 +652,8 @@ export class DomainAvailabilityService {
sscText: p?.sscText,
supplier: { id: p?.supplierId },
isPrebooked: p?.isPrebooked,
estimatedShippingDate: p?.requestStatusCode === '32' ? p?.altAt : p?.at,
estimatedShippingDate:
p?.requestStatusCode === '32' ? p?.altAt : p?.at,
price: p?.price,
inStock: totalAvailable,
supplierProductNumber: p?.supplierProductNumber,
@@ -565,7 +668,9 @@ export class DomainAvailabilityService {
}
}
private _mapToShippingAvailability(availabilities: SwaggerAvailabilityDTO[]): AvailabilityDTO[] {
private _mapToShippingAvailability(
availabilities: SwaggerAvailabilityDTO[],
): AvailabilityDTO[] {
const preferred = availabilities.filter((f) => f.preferred === 1);
return preferred.map((p) => {
return {
@@ -585,7 +690,10 @@ export class DomainAvailabilityService {
});
}
getInStockByEan(params: { eans: string[]; branchId?: number }): Observable<Record<string, StockInfoDTO>> {
getInStockByEan(params: {
eans: string[];
branchId?: number;
}): Observable<Record<string, StockInfoDTO>> {
let branchId$ = of(params.branchId);
if (!params.branchId) {
@@ -597,31 +705,41 @@ export class DomainAvailabilityService {
const stock$ = branchId$.pipe(
mergeMap((branchId) =>
this._stockService.StockGetStocksByBranch({ branchId }).pipe(map((response) => response.result?.[0])),
this._stockService
.StockGetStocksByBranch({ branchId })
.pipe(map((response) => response.result?.[0])),
),
);
return stock$.pipe(
mergeMap((stock) =>
this._stockService.StockInStockByEAN({ eans: params.eans, stockId: stock.id }).pipe(
this._stockService
.StockInStockByEAN({ eans: params.eans, stockId: stock.id })
.pipe(
map((response) => {
const result = response.result ?? [];
for (const stockInfo of result) {
stockInfo.ean = stockInfo.ean;
}
return result.reduce<Record<string, StockInfoDTO>>((acc, stockInfo) => {
return result.reduce<Record<string, StockInfoDTO>>(
(acc, stockInfo) => {
acc[stockInfo.ean] = stockInfo;
return acc;
}, {});
},
{},
);
}),
),
),
);
}
getInStock({ itemIds, branchId }: { itemIds: number[]; branchId: number }): Observable<StockInfoDTO[]> {
getInStock({
itemIds,
branchId,
}: {
itemIds: number[];
branchId: number;
}): Observable<StockInfoDTO[]> {
return this.getStockByBranch(branchId).pipe(
mergeMap((stock) =>
this._stockService

View File

@@ -1,4 +1,3 @@
export * from './availability.module';
export * from './availability.service';
export * from './defs';
export * from './in-stock.service';

View File

@@ -1,18 +0,0 @@
import { ModuleWithProviders, NgModule } from '@angular/core';
import { DomainCatalogService } from './catalog.service';
import { ThumbnailUrlPipe } from './thumbnail-url.pipe';
import { DomainCatalogThumbnailService } from './thumbnail.service';
@NgModule({
declarations: [ThumbnailUrlPipe],
imports: [],
exports: [ThumbnailUrlPipe],
})
export class DomainCatalogModule {
static forRoot(): ModuleWithProviders<DomainCatalogModule> {
return {
ngModule: DomainCatalogModule,
providers: [DomainCatalogService, DomainCatalogThumbnailService],
};
}
}

View File

@@ -9,7 +9,7 @@ import {
import { memorize } from '@utils/common';
import { map, share, shareReplay } from 'rxjs/operators';
@Injectable()
@Injectable({ providedIn: 'root' })
export class DomainCatalogService {
constructor(
private searchService: SearchService,
@@ -34,7 +34,9 @@ export class DomainCatalogService {
}
getSearchHistory({ take }: { take: number }) {
return this.searchService.SearchHistory(take ?? 5).pipe(map((res) => res.result));
return this.searchService
.SearchHistory(take ?? 5)
.pipe(map((res) => res.result));
}
@memorize({ ttl: 120000 })
@@ -84,7 +86,11 @@ export class DomainCatalogService {
}
@memorize()
getPromotionPoints({ items }: { items: { id: number; quantity: number; price?: number }[] }) {
getPromotionPoints({
items,
}: {
items: { id: number; quantity: number; price?: number }[];
}) {
return this.promotionService.PromotionLesepunkte(items).pipe(shareReplay());
}

View File

@@ -1,4 +1,3 @@
export * from './catalog.module';
export * from './catalog.service';
export * from './thumbnail-url.pipe';
export * from './thumbnail.service';

View File

@@ -6,7 +6,7 @@ import { DomainCatalogThumbnailService } from './thumbnail.service';
@Pipe({
name: 'thumbnailUrl',
pure: false,
standalone: false,
standalone: true,
})
export class ThumbnailUrlPipe implements PipeTransform, OnDestroy {
private input$ = new BehaviorSubject<{ width?: number; height?: number; ean?: string }>(undefined);

View File

@@ -3,15 +3,23 @@ import { memorize } from '@utils/common';
import { map, shareReplay } from 'rxjs/operators';
import { DomainCatalogService } from './catalog.service';
@Injectable()
@Injectable({ providedIn: 'root' })
export class DomainCatalogThumbnailService {
constructor(private domainCatalogService: DomainCatalogService) {}
@memorize()
getThumnaulUrl({ ean, height, width }: { width?: number; height?: number; ean?: string }) {
getThumnaulUrl({
ean,
height,
width,
}: {
width?: number;
height?: number;
ean?: string;
}) {
return this.domainCatalogService.getSettings().pipe(
map((settings) => {
let thumbnailUrl = settings.imageUrl.replace(/{ean}/, ean);
const thumbnailUrl = settings.imageUrl.replace(/{ean}/, ean);
return thumbnailUrl;
}),
shareReplay(),

View File

@@ -1,29 +1,15 @@
import { ModuleWithProviders, NgModule } from '@angular/core';
import { StoreModule } from '@ngrx/store';
import { EnvironmentProviders, makeEnvironmentProviders } from '@angular/core';
import { provideEffects } from '@ngrx/effects';
import { provideState } from '@ngrx/store';
import { DomainCheckoutService } from './checkout.service';
import { DomainCheckoutEffects } from './store/domain-checkout.effects';
import { domainCheckoutReducer } from './store/domain-checkout.reducer';
import { storeFeatureName } from './store/domain-checkout.state';
import { EffectsModule } from '@ngrx/effects';
import { DomainCheckoutEffects } from './store/domain-checkout.effects';
@NgModule({
declarations: [],
imports: [StoreModule.forFeature(storeFeatureName, domainCheckoutReducer)],
providers: [DomainCheckoutService],
})
export class DomainCheckoutModule {
static forRoot(): ModuleWithProviders<DomainCheckoutModule> {
return {
ngModule: RootDomainCheckoutModule,
providers: [DomainCheckoutService],
};
}
export function provideDomainCheckout(): EnvironmentProviders {
return makeEnvironmentProviders([
provideState({ name: storeFeatureName, reducer: domainCheckoutReducer }),
provideEffects(DomainCheckoutEffects),
DomainCheckoutService,
]);
}
@NgModule({
imports: [
StoreModule.forFeature(storeFeatureName, domainCheckoutReducer),
EffectsModule.forFeature([DomainCheckoutEffects]),
],
})
export class RootDomainCheckoutModule {}

View File

@@ -1071,7 +1071,7 @@ export class DomainCheckoutService {
});
} else if (orderType === 'B2B-Versand') {
const branch = await this.applicationService
.getSelectedBranch$(processId)
.getSelectedBranch$()
.pipe(first())
.toPromise();
availability$ =

View File

@@ -1,7 +1,7 @@
import { Injectable } from '@angular/core';
import { InfoService } from '@generated/swagger/isa-api';
@Injectable()
@Injectable({ providedIn: 'root' })
export class DomainDashboardService {
constructor(private readonly _infoService: InfoService) {}

View File

@@ -1,12 +0,0 @@
import { ModuleWithProviders, NgModule } from '@angular/core';
import { DomainDashboardService } from './dashboard.service';
@NgModule({})
export class DomainIsaModule {
static forRoot(): ModuleWithProviders<DomainIsaModule> {
return {
ngModule: DomainIsaModule,
providers: [DomainDashboardService],
};
}
}

View File

@@ -1,3 +1,2 @@
export * from './dashboard.service';
export * from './defs';
export * from './domain-isa.module';

View File

@@ -1,9 +1,13 @@
import { Injectable } from '@angular/core';
import { AbholfachService, AutocompleteTokenDTO, QueryTokenDTO } from '@generated/swagger/oms-api';
import {
AbholfachService,
AutocompleteTokenDTO,
QueryTokenDTO,
} from '@generated/swagger/oms-api';
import { DateAdapter } from '@ui/common';
import { memorize } from '@utils/common';
import { shareReplay } from 'rxjs/operators';
@Injectable()
@Injectable({ providedIn: 'root' })
export class DomainGoodsService {
constructor(
private abholfachService: AbholfachService,
@@ -19,11 +23,15 @@ export class DomainGoodsService {
}
wareneingangComplete(autocompleteToken: AutocompleteTokenDTO) {
return this.abholfachService.AbholfachWareneingangAutocomplete(autocompleteToken);
return this.abholfachService.AbholfachWareneingangAutocomplete(
autocompleteToken,
);
}
warenausgabeComplete(autocompleteToken: AutocompleteTokenDTO) {
return this.abholfachService.AbholfachWarenausgabeAutocomplete(autocompleteToken);
return this.abholfachService.AbholfachWarenausgabeAutocomplete(
autocompleteToken,
);
}
getWareneingangItemByOrderNumber(orderNumber: string) {
@@ -81,12 +89,16 @@ export class DomainGoodsService {
@memorize()
goodsInQuerySettings() {
return this.abholfachService.AbholfachWareneingangQuerySettings().pipe(shareReplay());
return this.abholfachService
.AbholfachWareneingangQuerySettings()
.pipe(shareReplay());
}
@memorize()
goodsOutQuerySettings() {
return this.abholfachService.AbholfachWarenausgabeQuerySettings().pipe(shareReplay());
return this.abholfachService
.AbholfachWarenausgabeQuerySettings()
.pipe(shareReplay());
}
goodsInList(queryToken: QueryTokenDTO) {
@@ -95,7 +107,9 @@ export class DomainGoodsService {
@memorize()
goodsInListQuerySettings() {
return this.abholfachService.AbholfachWareneingangslisteQuerySettings().pipe(shareReplay());
return this.abholfachService
.AbholfachWareneingangslisteQuerySettings()
.pipe(shareReplay());
}
goodsInCleanupList() {

View File

@@ -2,6 +2,5 @@ export * from './action-handler-services';
export * from './action-handlers';
export * from './customer-order.service';
export * from './goods.service';
export * from './oms.module';
export * from './oms.service';
export * from './receipt.service';

View File

@@ -1,14 +0,0 @@
import { ModuleWithProviders, NgModule } from '@angular/core';
import { DomainGoodsService } from './goods.service';
import { DomainOmsService } from './oms.service';
import { DomainReceiptService } from './receipt.service';
@NgModule()
export class DomainOmsModule {
static forRoot(): ModuleWithProviders<DomainOmsModule> {
return {
ngModule: DomainOmsModule,
providers: [DomainOmsService, DomainGoodsService, DomainReceiptService],
};
}
}

View File

@@ -22,7 +22,7 @@ import { memorize } from '@utils/common';
import { Observable } from 'rxjs';
import { map, shareReplay } from 'rxjs/operators';
@Injectable()
@Injectable({ providedIn: 'root' })
export class DomainOmsService {
constructor(
private orderService: OrderService,
@@ -33,9 +33,16 @@ export class DomainOmsService {
private _orderCheckoutService: OrderCheckoutService,
) {}
getOrderItemsByCustomerNumber(customerNumber: string, skip: number): Observable<OrderListItemDTO[]> {
getOrderItemsByCustomerNumber(
customerNumber: string,
skip: number,
): Observable<OrderListItemDTO[]> {
return this.orderService
.OrderGetOrdersByBuyerNumber({ buyerNumber: customerNumber, take: 20, skip })
.OrderGetOrdersByBuyerNumber({
buyerNumber: customerNumber,
take: 20,
skip,
})
.pipe(map((orders) => orders.result));
}
@@ -55,7 +62,9 @@ export class DomainOmsService {
getReceipts(
orderItemSubsetIds: number[],
): Observable<ValueTupleOfLongAndReceiptTypeAndEntityDTOContainerOfReceiptDTO[]> {
): Observable<
ValueTupleOfLongAndReceiptTypeAndEntityDTOContainerOfReceiptDTO[]
> {
return this.receiptService
.ReceiptGetReceiptsByOrderItemSubset({
payload: {
@@ -68,25 +77,43 @@ export class DomainOmsService {
}
getReorderReasons() {
return this._orderCheckoutService.OrderCheckoutGetReorderReasons().pipe(map((response) => response.result));
return this._orderCheckoutService
.OrderCheckoutGetReorderReasons()
.pipe(map((response) => response.result));
}
@memorize()
getVATs() {
return this.vatService.VATGetVATs({}).pipe(map((response) => response.result));
return this.vatService
.VATGetVATs({})
.pipe(map((response) => response.result));
}
// ttl 4 Stunden
@memorize({ ttl: 14400000 })
getStockStatusCodes({ supplierId, eagerLoading = 0 }: { supplierId: number; eagerLoading?: number }) {
return this.stockStatusCodeService.StockStatusCodeGetStockStatusCodes({ supplierId, eagerLoading }).pipe(
getStockStatusCodes({
supplierId,
eagerLoading = 0,
}: {
supplierId: number;
eagerLoading?: number;
}) {
return this.stockStatusCodeService
.StockStatusCodeGetStockStatusCodes({ supplierId, eagerLoading })
.pipe(
map((response) => response.result),
shareReplay(),
);
}
patchOrderItem(payload: { orderItemId: number; orderId: number; orderItem: Partial<OrderItemDTO> }) {
return this.orderService.OrderPatchOrderItem(payload).pipe(map((response) => response.result));
patchOrderItem(payload: {
orderItemId: number;
orderId: number;
orderItem: Partial<OrderItemDTO>;
}) {
return this.orderService
.OrderPatchOrderItem(payload)
.pipe(map((response) => response.result));
}
patchOrderItemSubset(payload: {
@@ -95,7 +122,9 @@ export class DomainOmsService {
orderId: number;
orderItemSubset: Partial<OrderItemSubsetDTO>;
}) {
return this.orderService.OrderPatchOrderItemSubset(payload).pipe(map((response) => response.result));
return this.orderService
.OrderPatchOrderItemSubset(payload)
.pipe(map((response) => response.result));
}
patchComment({
@@ -150,13 +179,20 @@ export class DomainOmsService {
orderItemSubsetId,
orderItemSubset: {
estimatedShippingDate:
estimatedShippingDate instanceof Date ? estimatedShippingDate.toJSON() : estimatedShippingDate,
estimatedShippingDate instanceof Date
? estimatedShippingDate.toJSON()
: estimatedShippingDate,
},
})
.pipe(map((response) => response.result));
}
setPickUpDeadline(orderId: number, orderItemId: number, orderItemSubsetId: number, pickUpDeadline: string) {
setPickUpDeadline(
orderId: number,
orderItemId: number,
orderItemSubsetId: number,
pickUpDeadline: string,
) {
return this.orderService
.OrderPatchOrderItemSubset({
orderId,
@@ -178,7 +214,9 @@ export class DomainOmsService {
}
changeStockStatusCode(payload: ChangeStockStatusCodeValues[]) {
return this.orderService.OrderChangeStockStatusCode(payload).pipe(map((response) => response.result));
return this.orderService
.OrderChangeStockStatusCode(payload)
.pipe(map((response) => response.result));
}
orderAtSupplier({
@@ -197,7 +235,13 @@ export class DomainOmsService {
});
}
getNotifications(orderId: number): Observable<{ selected: NotificationChannel; email: string; mobile: string }> {
getNotifications(
orderId: number,
): Observable<{
selected: NotificationChannel;
email: string;
mobile: string;
}> {
return this.getOrder(orderId).pipe(
map((order) => ({
selected: order.notificationChannels,
@@ -208,10 +252,15 @@ export class DomainOmsService {
}
getOrderSource(orderId: number): Observable<string> {
return this.getOrder(orderId).pipe(map((order) => order?.features?.orderSource));
return this.getOrder(orderId).pipe(
map((order) => order?.features?.orderSource),
);
}
updateNotifications(orderId: number, changes: { selected: NotificationChannel; email: string; mobile: string }) {
updateNotifications(
orderId: number,
changes: { selected: NotificationChannel; email: string; mobile: string },
) {
const communicationDetails = {
email: changes.email,
mobile: changes.mobile,
@@ -224,7 +273,11 @@ export class DomainOmsService {
delete communicationDetails.mobile;
}
return this.updateOrder({ orderId, notificationChannels: changes.selected, communicationDetails });
return this.updateOrder({
orderId,
notificationChannels: changes.selected,
communicationDetails,
});
}
updateOrder({
@@ -270,7 +323,13 @@ export class DomainOmsService {
.pipe(map((res) => res.result));
}
generateNotifications({ orderId, taskTypes }: { orderId: number; taskTypes: string[] }) {
generateNotifications({
orderId,
taskTypes,
}: {
orderId: number;
taskTypes: string[];
}) {
return this.orderService.OrderRegenerateOrderItemStatusTasks({
orderId,
taskTypes,
@@ -302,10 +361,16 @@ export class DomainOmsService {
.pipe(
map((res) =>
res.result
.sort((a, b) => new Date(b.completed).getTime() - new Date(a.completed).getTime())
.sort(
(a, b) =>
new Date(b.completed).getTime() -
new Date(a.completed).getTime(),
)
.reduce(
(data, result) => {
(data[result.name] = data[result.name] || []).push(new Date(result.completed));
(data[result.name] = data[result.name] || []).push(
new Date(result.completed),
);
return data;
},
{} as Record<string, Date[]>,

View File

@@ -1,9 +1,12 @@
import { Injectable } from '@angular/core';
import { ReceiptOrderItemSubsetReferenceValues, ReceiptService } from '@generated/swagger/oms-api';
import {
ReceiptOrderItemSubsetReferenceValues,
ReceiptService,
} from '@generated/swagger/oms-api';
import { memorize } from '@utils/common';
import { shareReplay } from 'rxjs/operators';
@Injectable()
@Injectable({ providedIn: 'root' })
export class DomainReceiptService {
constructor(private receiptService: ReceiptService) {}

View File

@@ -1,4 +1,3 @@
export * from './defs';
export * from './mappings';
export * from './remission.module';
export * from './remission.service';

View File

@@ -1,17 +0,0 @@
import { ModuleWithProviders, NgModule } from '@angular/core';
import { DomainRemissionService } from './remission.service';
@NgModule({})
export class DomainRemissionModule {
static forRoot(): ModuleWithProviders<DomainRemissionModule> {
return {
ngModule: RootDomainRemissionModule,
};
}
}
@NgModule({
imports: [],
providers: [DomainRemissionService],
})
export class RootDomainRemissionModule {}

View File

@@ -30,7 +30,7 @@ import {
import { Logger } from '@core/logger';
import { RemissionPlacementType } from '@domain/remission';
@Injectable()
@Injectable({ providedIn: 'root' })
export class DomainRemissionService {
constructor(
private readonly _logger: Logger,
@@ -214,7 +214,7 @@ export class DomainRemissionService {
getStockInformation(
items: RemissionListItem[],
recalculate: boolean = false,
recalculate = false,
) {
return this.getCurrentStock().pipe(
switchMap((stock) =>
@@ -407,10 +407,10 @@ export class DomainRemissionService {
async deleteReturn(returnId: number) {
const returnDto = await this.getReturn(returnId).toPromise();
for (const receipt of returnDto?.receipts) {
await this.deleteReceipt(returnDto.id, receipt.id);
for (const receipt of returnDto?.receipts ?? []) {
await this.deleteReceipt(returnDto!.id, receipt.id);
}
await this.deleteRemission(returnDto.id);
await this.deleteRemission(returnDto!.id);
}
addReturnItem({

View File

@@ -1,28 +1,39 @@
import { enableProdMode, isDevMode } from "@angular/core";
import { platformBrowserDynamic } from "@angular/platform-browser-dynamic";
import { CONFIG_DATA } from "@isa/core/config";
import { setDefaultOptions } from "date-fns";
import { de } from "date-fns/locale";
import * as moment from "moment";
import "moment/locale/de";
import { enableProdMode, isDevMode } from '@angular/core';
import { CONFIG_DATA } from '@isa/core/config';
import { setDefaultOptions } from 'date-fns';
import { de } from 'date-fns/locale';
import localeDe from '@angular/common/locales/de';
import localeDeExtra from '@angular/common/locales/extra/de';
import * as moment from 'moment';
import 'moment/locale/de';
setDefaultOptions({ locale: de });
moment.locale("de");
moment.locale('de');
import { AppModule } from "./app/app.module";
registerLocaleData(localeDe, localeDeExtra);
registerLocaleData(localeDe, 'de', localeDeExtra);
import { App } from './app/app';
import { appConfig } from './app/app.config';
import { bootstrapApplication } from '@angular/platform-browser';
import { registerLocaleData } from '@angular/common';
if (!isDevMode()) {
enableProdMode();
}
async function bootstrap() {
const configRes = await fetch("/config/config.json");
const configRes = await fetch('/config/config.json');
const config = await configRes.json();
platformBrowserDynamic([
await bootstrapApplication(App, {
...appConfig,
providers: [
{ provide: CONFIG_DATA, useValue: config },
]).bootstrapModule(AppModule);
...appConfig.providers,
],
});
}
try {

View File

@@ -26,7 +26,7 @@ export class ModalAvailabilitiesComponent {
item = this.modalRef.data.item;
itemId = this.modalRef.data.itemId || this.modalRef.data.item.id;
userbranch$ = combineLatest([
this.applicationService.getSelectedBranch$(this.applicationService.activatedProcessId),
this.applicationService.getSelectedBranch$(),
this.domainAvailabilityService.getDefaultBranch(),
]).pipe(map(([selectedBranch, defaultBranch]) => selectedBranch || defaultBranch));

View File

@@ -192,11 +192,7 @@ export class ArticleDetailsComponent implements OnInit, OnDestroy {
}),
);
selectedBranchId$ = this.applicationService.activatedProcessId$.pipe(
switchMap((processId) =>
this.applicationService.getSelectedBranch$(processId),
),
);
selectedBranchId$ = this.applicationService.getSelectedBranch$();
get isTablet$() {
return this._environment.matchTablet$;
@@ -328,7 +324,7 @@ export class ArticleDetailsComponent implements OnInit, OnDestroy {
debounceTime(0),
switchMap((params) =>
this.applicationService
.getSelectedBranch$(Number(params.processId))
.getSelectedBranch$()
.pipe(map((selectedBranch) => ({ params, selectedBranch }))),
),
)

View File

@@ -98,11 +98,9 @@ export class ArticleSearchMainComponent implements OnInit, OnDestroy {
);
this.subscriptions.add(
this.application.activatedProcessId$
.pipe(
debounceTime(0),
switchMap((processId) => this.application.getSelectedBranch$(processId)),
)
this.application
.getSelectedBranch$()
.pipe(debounceTime(0))
.subscribe((selectedBranch) => {
const branchChanged = selectedBranch?.id !== this.searchService?.selectedBranch?.id;
if (branchChanged) {
@@ -143,7 +141,7 @@ export class ArticleSearchMainComponent implements OnInit, OnDestroy {
const clean = { ...params };
for (const key in clean) {
if (key === 'main_qs' || key?.includes('order_by')) {
if (key === 'main_qs') {
clean[key] = undefined;
} else if (key?.includes('order_by')) {
delete clean[key];

View File

@@ -40,7 +40,7 @@ export class SearchResultItemComponent extends ComponentStore<SearchResultItemCo
readonly item$ = this.select((s) => s.item);
@Input() selected: boolean = false;
@Input() selected = false;
@Input()
get selectable() {
@@ -91,9 +91,7 @@ export class SearchResultItemComponent extends ComponentStore<SearchResultItemCo
defaultBranch$ = this._availability.getDefaultBranch();
selectedBranchId$ = this.applicationService.activatedProcessId$.pipe(
switchMap((processId) => this.applicationService.getSelectedBranch$(processId)),
);
selectedBranchId$ = this.applicationService.getSelectedBranch$();
isOrderBranch$ = combineLatest([this.defaultBranch$, this.selectedBranchId$]).pipe(
map(([defaultBranch, selectedBranch]) => {

View File

@@ -157,7 +157,7 @@ export class ArticleSearchResultsComponent
.pipe(
debounceTime(0),
switchMap(([processId, queryParams]) =>
this.application.getSelectedBranch$(processId).pipe(
this.application.getSelectedBranch$().pipe(
map((selectedBranch) => ({
processId,
queryParams,

View File

@@ -3,7 +3,7 @@ import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { RouterModule } from '@angular/router';
import { DomainCatalogModule } from '@domain/catalog';
import { ThumbnailUrlPipe } from '@domain/catalog';
import { UiCommonModule } from '@ui/common';
import { UiIconModule } from '@ui/icon';
import { UiSelectBulletModule } from '@ui/select-bullet';
@@ -26,7 +26,7 @@ import { MatomoModule } from 'ngx-matomo-client';
CommonModule,
FormsModule,
RouterModule,
DomainCatalogModule,
ThumbnailUrlPipe,
UiCommonModule,
UiIconModule,
UiSelectBulletModule,

View File

@@ -77,9 +77,7 @@ export class PageCatalogComponent implements OnInit, AfterViewInit, OnDestroy {
ngOnInit() {
this.activatedProcessId$ = this.application.activatedProcessId$.pipe(map((processId) => String(processId)));
this.selectedBranch$ = this.activatedProcessId$.pipe(
switchMap((processId) => this.application.getSelectedBranch$(Number(processId))),
);
this.selectedBranch$ = this.application.getSelectedBranch$();
this.stockTooltipText$ = combineLatest([this.defaultBranch$, this.selectedBranch$]).pipe(
map(([defaultBranch, selectedBranch]) => {

View File

@@ -14,8 +14,8 @@ import { UiTooltipModule } from '@ui/tooltip';
imports: [
CommonModule,
PageCatalogRoutingModule,
ArticleSearchModule,
ArticleDetailsModule,
ArticleSearchModule,
BreadcrumbModule,
BranchSelectorComponent,
SharedSplitscreenComponent,

View File

@@ -73,11 +73,9 @@ export class CustomerOrderSearchMainComponent implements OnInit, OnDestroy {
);
this._subscriptions.add(
this._application.activatedProcessId$
.pipe(
debounceTime(0),
switchMap((processId) => this._application.getSelectedBranch$(processId)),
)
this._application
.getSelectedBranch$()
.pipe(debounceTime(0))
.subscribe((selectedBranch) => {
const branchChanged = selectedBranch?.id !== this._customerOrderSearchStore?.selectedBranch?.id;
if (branchChanged) {

View File

@@ -183,7 +183,7 @@ export class CustomerOrderSearchResultsComponent
debounceTime(150),
switchMap(([processId, params]) =>
this._application
.getSelectedBranch$(processId)
.getSelectedBranch$()
.pipe(map((selectedBranch) => ({ processId, params, selectedBranch }))),
),
)

View File

@@ -49,9 +49,7 @@ export class CustomerOrderComponent implements OnInit, AfterViewInit, OnDestroy
) {}
ngOnInit(): void {
this.selectedBranch$ = this.application.activatedProcessId$.pipe(
switchMap((processId) => this.application.getSelectedBranch$(Number(processId))),
);
this.selectedBranch$ = this.application.getSelectedBranch$();
/* Ticket #4544 - Suchrequest abbrechen bei Prozesswechsel
/ um zu verhindern, dass die Suche in einen anderen Kundenbestellungen Prozess übernommen wird

View File

@@ -3,7 +3,10 @@ import { Icon, IconAlias, IconConfig } from './interfaces';
import { IconLoader } from './loader';
import { Observable, Subject, isObservable } from 'rxjs';
@Injectable()
/**
* @deprecated Use UiIconModule from '@isa/ui/icon' instead.
*/
@Injectable({ providedIn: 'root' })
export class IconRegistry {
private _icons = new Map<string, Icon>();
private _aliases = new Map<string, string>();

View File

@@ -12,10 +12,17 @@ import { IconRegistry } from './icon-registry';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
/**
* @deprecated Use UiIconModule from '@isa/ui/icon' instead.
*/
@Component({
selector: 'shared-icon',
template: `
<svg [style.width.rem]="size / 16" [style.height.rem]="size / 16" [attr.viewBox]="viewBox">
<svg
[style.width.rem]="size / 16"
[style.height.rem]="size / 16"
[attr.viewBox]="viewBox"
>
<path fill="currentColor" [attr.d]="data" />
</svg>
`,
@@ -31,7 +38,7 @@ export class IconComponent implements OnInit, OnDestroy, OnChanges {
viewBox: string;
@Input()
size: number = 24;
size = 24;
private _onDestroy$ = new Subject<void>();
@@ -41,7 +48,9 @@ export class IconComponent implements OnInit, OnDestroy, OnChanges {
) {}
ngOnInit(): void {
this._iconRegistry.updated.pipe(takeUntil(this._onDestroy$)).subscribe(() => {
this._iconRegistry.updated
.pipe(takeUntil(this._onDestroy$))
.subscribe(() => {
this.updateIcon();
});
}

View File

@@ -3,6 +3,9 @@ import { IconComponent } from './icon.component';
import { IconLoader, JsonIconLoader } from './loader';
import { IconRegistry } from './icon-registry';
/**
* @deprecated Use UiIconModule from '@isa/ui/icon' instead.
*/
export function provideIcon(loaderProvider?: Provider) {
const providers: Provider[] = [IconRegistry];
if (!loaderProvider) {
@@ -17,6 +20,9 @@ export function provideIcon(loaderProvider?: Provider) {
return providers;
}
/**
* @deprecated Use UiIconModule from '@isa/ui/icon' instead.
*/
@NgModule({
imports: [IconComponent],
exports: [IconComponent],

View File

@@ -1,5 +1,8 @@
import { Component, ChangeDetectionStrategy, Input } from '@angular/core';
/**
* @deprecated Use UiIconModule from '@isa/ui/icon' instead.
*/
@Component({
selector: 'ui-icon-badge',
templateUrl: 'icon-badge.component.html',

View File

@@ -2,7 +2,10 @@ import { Injectable } from '@angular/core';
import { SvgIcon } from './defs';
import { IconAlias } from './defs/icon-alias';
@Injectable()
/**
* @deprecated Use UiIconModule from '@isa/ui/icon' instead.
*/
@Injectable({ providedIn: 'root' })
export class IconRegistry {
private _icons = new Map<string, SvgIcon>();
private _aliases = new Map<string, string>();

View File

@@ -1,6 +1,16 @@
import { Component, ChangeDetectionStrategy, Input, Optional, Inject, HostBinding } from '@angular/core';
import {
Component,
ChangeDetectionStrategy,
Input,
Optional,
Inject,
HostBinding,
} from '@angular/core';
import { UI_ICON_HREF, UI_ICON_VIEW_BOX } from './tokens';
/**
* @deprecated Use UiIconModule from '@isa/ui/icon' instead.
*/
@Component({
selector: 'ui-icon',
templateUrl: 'icon.component.html',

View File

@@ -1,10 +1,24 @@
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Input, OnChanges, SimpleChanges } from '@angular/core';
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
Input,
OnChanges,
SimpleChanges,
} from '@angular/core';
import { IconRegistry } from './icon-registry';
/**
* @deprecated Use UiIconModule from '@isa/ui/icon' instead.
*/
@Component({
selector: 'ui-svg-icon',
template: `
<svg [style.width.rem]="size / 16" [style.height.rem]="size / 16" [attr.viewBox]="viewBox">
<svg
[style.width.rem]="size / 16"
[style.height.rem]="size / 16"
[attr.viewBox]="viewBox"
>
<path fill="currentColor" [attr.d]="data" />
</svg>
`,
@@ -20,7 +34,7 @@ export class UISvgIconComponent implements OnChanges {
viewBox: string;
@Input()
size: number = 24;
size = 24;
constructor(
private readonly _iconRegistry: IconRegistry,

View File

@@ -7,6 +7,9 @@ import { IconRegistry } from './icon-registry';
import { UI_ICON_CFG } from './tokens';
import { UiIconConfig } from './icon-config';
/**
* @deprecated Use UiIconModule from '@isa/ui/icon' instead.
*/
export function _rootIconRegistryFactory(config: UiIconConfig): IconRegistry {
const registry = new IconRegistry();
@@ -27,6 +30,9 @@ export function _rootIconRegistryFactory(config: UiIconConfig): IconRegistry {
return registry;
}
/**
* @deprecated Use UiIconModule from '@isa/ui/icon' instead.
*/
@NgModule({
imports: [CommonModule],
declarations: [UiIconComponent, UiIconBadgeComponent, UISvgIconComponent],

682
claude-code-guide.md Normal file
View File

@@ -0,0 +1,682 @@
# The Complete Claude Code Guide
## From Configuration to Mastery
*A comprehensive reference for instructions, agents, commands, skills, hooks, and best practices*
*Compiled from Anthropic Engineering Blog, Official Documentation, and Community Best Practices — November 2025*
---
## Table of Contents
1. [Introduction](#introduction)
2. [Part 1: Effective Instructions Through CLAUDE.md](#part-1-effective-instructions-through-claudemd)
3. [Part 2: Commands](#part-2-commands)
4. [Part 3: The Agentic Architecture](#part-3-the-agentic-architecture)
5. [Part 4: Agent Skills](#part-4-agent-skills)
6. [Part 5: Hooks](#part-5-hooks)
7. [Part 6: Best Practices](#part-6-best-practices)
8. [Part 7: Context Engineering](#part-7-context-engineering)
9. [Part 8: Advanced Features](#part-8-advanced-features)
10. [Conclusion](#conclusion)
---
## Introduction
Claude Code represents a paradigm shift in AI-assisted development—an agentic command-line tool that provides near-raw model access without forcing specific workflows. Unlike traditional code assistants that offer suggestions, Claude Code follows an autonomous feedback loop: **gather context, take action, verify work, and repeat**.
This guide synthesizes official Anthropic documentation, engineering blog posts, and community best practices into a comprehensive reference for maximizing productivity with Claude Code. Three key configuration layers determine Claude's behavior:
- **CLAUDE.md files** for project context
- **Hooks** for deterministic control
- **Skills** for modular capabilities
---
## Part 1: Effective Instructions Through CLAUDE.md
CLAUDE.md serves as Claude's persistent memory—a special file automatically loaded into context at session start. This file fundamentally shapes how Claude understands and works with your codebase.
### The Configuration Hierarchy
Claude loads CLAUDE.md files in a specific precedence order, allowing layered configuration from organization-wide policies down to personal preferences:
| Location | Scope | Version Control |
|----------|-------|-----------------|
| `/Library/Application Support/ClaudeCode/CLAUDE.md` (macOS) | Enterprise-wide | IT-managed |
| `~/.claude/CLAUDE.md` | All projects | Personal |
| `./CLAUDE.md` or `./.claude/CLAUDE.md` | Project-wide | Committed to git |
| `./CLAUDE.local.md` | Project-specific personal | Git-ignored |
| Child directories | On-demand loading | Per-directory |
Claude recursively loads CLAUDE.md files from the current working directory up to the root, then pulls in child directory files on-demand when accessing those locations.
### Structure and Syntax
A well-crafted CLAUDE.md contains concise, actionable information organized into clear sections:
```markdown
# Bash commands
- npm run build: Build the project
- npm run typecheck: Run the typechecker
- npm test -- --watch: Run tests in watch mode
# Code style
- Use ES modules (import/export), not CommonJS (require)
- Destructure imports when possible: import { foo } from 'bar'
- Prefer named exports over default exports
# Workflow
- Always typecheck after making code changes
- Prefer running single tests over the full suite for performance
- Commit logical units of work with descriptive messages
# Architecture
- Frontend: Next.js with TypeScript in /app
- Backend: Node.js with Express in /api
- Database: PostgreSQL with Prisma ORM
```
### Import Syntax
The `@path/to/file` syntax extends CLAUDE.md's capabilities by referencing external files. Maximum recursion depth is **5 hops**.
Example: *"For complex usage or if you encounter FooBarError, see @docs/troubleshooting.md for advanced steps."*
### Critical Anti-Patterns to Avoid
- **Don't @-file documentation directly:** Embedding entire files bloats context unnecessarily. Instead, provide paths with context: "For complex usage or if you encounter FooBarError, see path/to/docs.md"
- **Don't just say "never" without alternatives:** Negative-only constraints trap the agent. Always pair prohibitions with preferred approaches: "Never use the --foo flag; prefer --bar instead"
- **Keep it concise:** Large teams at Anthropic cap their CLAUDE.md files at approximately **13KB**. If CLI commands require paragraphs to explain, write a simpler bash wrapper instead—keeping CLAUDE.md concise forces better tooling design.
- **Use CLAUDE.md as a forcing function:** Keeping it short forces better tooling design.
### Quick Memory Feature
- Press **`#`** during any session to add memories that Claude automatically incorporates into the appropriate CLAUDE.md file
- Use **`/memory`** to view and edit all loaded memories
- Use **`/init`** to bootstrap a new CLAUDE.md by having Claude analyze your codebase
---
## Part 2: Commands
Claude Code provides extensive command functionality through built-in slash commands and user-definable custom commands.
### Essential Built-in Commands
| Command | Purpose |
|---------|---------|
| `/clear` | Reset context window—**use frequently between tasks** |
| `/compact` | Compress context while preserving critical information |
| `/context` | Visualize current token usage in the 200k window |
| `/init` | Generate CLAUDE.md from codebase analysis |
| `/memory` | View and edit CLAUDE.md files |
| `/permissions` | Manage tool allowlists interactively |
| `/hooks` | Configure automation hooks via menu interface |
| `/model` | Switch between Claude models (Opus, Sonnet, Haiku) |
| `/rewind` | Roll back conversation and code state |
| `/add-dir` | Add directories to current session |
| `/terminal-setup` | Configure terminal for optimal Claude Code usage |
| `/ide` | Connect to IDE for linter integration |
### Keyboard Shortcuts
| Shortcut | Action |
|----------|--------|
| **Escape** | Stop Claude mid-execution |
| **Escape twice** | Jump back to previous messages or fork conversation |
| **Shift+Tab** | Toggle auto-accept mode (⏵⏵ indicator) |
| **Shift+Tab twice** | Activate Plan Mode |
| **Ctrl+V** | Paste images |
| **Up arrow** | Navigate chat history |
| **#** | Add quick memory to CLAUDE.md |
| **@** | Tag files with tab-completion |
### Creating Custom Slash Commands
Store prompt templates as Markdown files in `.claude/commands/` (project-scoped) or `~/.claude/commands/` (user-scoped). Commands support frontmatter metadata:
```markdown
---
allowed-tools: Bash(git add:*), Bash(git status:*), Bash(git commit:*)
argument-hint: [message]
description: Create a git commit with the specified message
model: claude-3-5-haiku-20241022
---
Create a git commit with message: $ARGUMENTS
Follow these steps:
1. Run `git status` to check staged changes
2. Review what will be committed
3. Create the commit with the provided message
4. Confirm success
```
**Dynamic variables in custom commands:**
- `$ARGUMENTS`: All arguments passed to the command
- `$1`, `$2`, `$3`: Positional arguments
- `@filename`: Include file contents
- `!command`: Execute bash command before processing
Use subdirectories for namespaced organization: `.claude/commands/testing/unit.md` becomes `/project:testing:unit`.
> 💡 **Best Practice:** Keep slash commands as simple shortcuts, not complex workflows. If you have a long list of complex custom commands, you've created an anti-pattern.
---
## Part 3: The Agentic Architecture
Claude Code functions as a fully autonomous agent with access to powerful tools. Understanding this architecture helps you leverage its capabilities effectively.
### Core Design Principle
The key design principle behind Claude Code is that Claude needs the same tools that programmers use every day. By giving Claude access to the user's computer via the terminal, it can read files, write and edit files, run tests, debug, and iterate until tasks succeed.
### The Agent Feedback Loop
1. **Gather Context:** Navigate filesystem, read files, use tools to understand the task
2. **Take Action:** Execute bash commands, edit files, run scripts
3. **Verify Work:** Run tests, check linting, validate output
4. **Repeat:** Continue until task is complete
### Permission Modes
| Mode | Behavior | Activation |
|------|----------|------------|
| Normal | Asks permission for risky actions | Default |
| Auto-Accept | Executes without confirmation | Shift+Tab toggle |
| Plan Mode | Research only, no modifications | `--permission-mode plan` or Shift+Tab×2 |
| Dangerous | Skips all permissions | `--dangerously-skip-permissions` (containers only) |
### Extended Thinking Keywords
Claude Code maps trigger words to increasing thinking budgets. Use these progressively for more complex analysis:
**`"think"` < `"think hard"` < `"think harder"` < `"ultrathink"`**
Each level allocates progressively more computational budget:
- `"think"` triggers ~4,000 tokens
- Medium phrases ~10,000 tokens
- `"ultrathink"` provides maximum analysis depth at ~32,000 tokens
### Multi-Agent Patterns
Claude Code excels at multi-agent workflows. Anthropic's internal research shows that **multi-agent Claude Opus 4 with Sonnet 4 subagents outperformed single-agent Opus 4 by 90.2%** on research evaluations.
#### The Orchestrator-Worker Pattern
1. A lead agent analyzes the query and develops strategy
2. Subagents spawn in parallel with isolated context windows
3. Each subagent acts as an "intelligent filter," returning condensed findings
4. The lead agent synthesizes results into coherent output
#### Practical Multi-Claude Workflows
- **Writer + Reviewer:** One Claude writes code, another reviews—use `/clear` between or run in separate terminals
- **Git worktrees:** `git worktree add ../project-feature-a feature-a` enables parallel sessions on different branches
- **Task() feature:** Use Claude's built-in Task() to spawn clones of the general agent with isolated context
- **Multiple checkouts:** Create 3-4 git checkouts in separate folders, run different Claude instances with different tasks
### Headless Mode for Automation
Claude Code supports non-interactive execution for CI/CD and scripting:
```bash
# Basic execution
claude -p "your prompt here"
# With JSON output for parsing
claude -p "analyze code" --output-format json
# Streaming JSON for real-time processing
claude -p "analyze code" --output-format stream-json
# Continue previous conversation
claude --continue -p "follow up question"
# Resume specific session
claude --resume <session-id> -p "continue task"
```
---
## Part 4: Agent Skills
Skills extend Claude's functionality through organized folders containing instructions, scripts, and resources. Unlike slash commands (user-invoked), **skills are model-invoked**—Claude autonomously decides when to use them based on task context.
### What is a Skill?
A skill is a directory containing a SKILL.md file with organized folders of instructions, scripts, and resources that give agents additional capabilities. Building a skill for an agent is like putting together an onboarding guide for a new hire.
### Skill Structure
Skills live in `~/.claude/skills/` (personal) or `.claude/skills/` (project). Each skill requires a SKILL.md file with YAML frontmatter:
```markdown
---
name: generating-commit-messages
description: Generates clear commit messages from git diffs. Use when writing commit messages or reviewing staged changes.
---
# Generating Commit Messages
## Instructions
1. Run `git diff --staged` to see changes
2. Analyze the nature and scope of modifications
3. Suggest a commit message with:
- Summary under 50 characters (imperative mood)
- Detailed description if needed
- List of affected components
## Best Practices
- Use present tense ("Add feature" not "Added feature")
- Explain what and why, not how
- Reference issue numbers when applicable
```
The **description field is critical**—it's the primary signal Claude uses to decide when to invoke a skill.
### Progressive Disclosure
Progressive disclosure is the core design principle that makes Agent Skills flexible and scalable. Like a well-organized manual:
1. **At startup:** Only skill names and descriptions load into context
2. **When triggered:** Full SKILL.md content loads when the skill is relevant
3. **On-demand:** Additional referenced files load during execution
This means you can have many skills available without bloating every session's context window.
### Skills vs. Slash Commands
| Aspect | Slash Commands | Agent Skills |
|--------|----------------|--------------|
| Invocation | User-invoked (`/command`) | Model-invoked (automatic) |
| Complexity | Simple prompts, single file | Multiple files + scripts |
| Use case | Quick shortcuts | Comprehensive workflows |
| Discovery | Listed in `/help` | Based on task context |
### Best Practices for Building Skills
- **Start with evaluation:** Identify specific gaps in your agents' capabilities by running them on representative tasks and observing where they struggle
- **Structure for scale:** When the SKILL.md file becomes unwieldy, split its content into separate files and reference them
- **Think from Claude's perspective:** Pay special attention to the name and description—Claude uses these when deciding whether to trigger the skill
- **Iterate with Claude:** Ask Claude to capture its successful approaches into reusable context within a skill
---
## Part 5: Hooks
Hooks provide deterministic control over Claude Code's behavior through shell commands that execute at specific lifecycle points. While CLAUDE.md offers "should-do" suggestions, **hooks enforce "must-do" rules**.
### Hook Events
| Event | Trigger | Can Block? |
|-------|---------|------------|
| `SessionStart` | Session begins | No |
| `SessionEnd` | Session ends | No |
| `UserPromptSubmit` | User submits prompt | Yes |
| `PreToolUse` | Before tool execution | Yes |
| `PostToolUse` | After tool completion | No |
| `Stop` | Claude completes response | No |
| `Notification` | Claude needs user input | No |
| `PreCompact` | Before context compaction | No |
| `PermissionRequest` | When permission dialog shown | No |
| `SubagentStop` | When subagent tasks complete | No |
### Configuration Structure
Configure hooks in settings files (`~/.claude/settings.json`, `.claude/settings.json`, or `.claude/settings.local.json`):
```json
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "if echo \"$CLAUDE_FILE_PATHS\" | grep -q '\\.py$'; then black \"$CLAUDE_FILE_PATHS\"; fi"
}
]
}
]
}
}
```
### Matcher Syntax
- **Exact match:** `"Write"` matches only Write tool
- **Regex:** `"Edit|Write"` matches either tool
- **Wildcard:** `"*"` or `""` matches everything
- **File patterns:** `"Write(*.py)"` matches Python file writes
### Exit Codes
- **Exit 0:** Success (stdout shown in transcript mode)
- **Exit 2:** Blocking error (stderr fed back to Claude for correction)
- **Other codes:** Non-blocking error (stderr shown to user)
### Practical Hook Examples
**Auto-format TypeScript after edits:**
```json
{
"hooks": {
"PostToolUse": [{
"matcher": "Edit|Write",
"hooks": [{
"type": "command",
"command": "jq -r '.tool_input.file_path' | { read file_path; if echo \"$file_path\" | grep -q '\\.ts$'; then npx prettier --write \"$file_path\"; fi; }"
}]
}]
}
}
```
**Desktop notification when Claude finishes (macOS):**
```json
{
"hooks": {
"Stop": [{
"hooks": [{
"type": "command",
"command": "osascript -e 'display notification \"Claude has finished!\" with title \"✅ Claude Done\" sound name \"Glass\"'"
}]
}]
}
}
```
**Block dangerous commands:**
```json
{
"hooks": {
"PreToolUse": [{
"matcher": "Bash",
"hooks": [{
"type": "command",
"command": "if [[ \"$CLAUDE_TOOL_INPUT\" == *\"rm -rf\"* ]]; then echo 'Blocked!' && exit 2; fi"
}]
}]
}
}
```
### Hook Strategies
- **Block-at-Submit Hooks:** Primary strategy—check state at commit time, forcing Claude into a "test-and-fix" loop until the build is green
- **Hint Hooks:** Non-blocking hooks that provide "fire-and-forget" feedback for suboptimal behavior
> 💡 **Best Practice:** Do NOT use "block-at-write" hooks. Blocking an agent mid-plan confuses it. Let the agent finish its work, then check the final result at commit time.
### Security Best Practices for Hooks
- Always quote shell variables: `"$VAR"` not `$VAR`
- Validate and sanitize all inputs
- Block path traversal by checking for `..`
- Use absolute paths with `$CLAUDE_PROJECT_DIR`
- Default timeout: 60 seconds per command
---
## Part 6: Best Practices
### The Explore-Plan-Code-Commit Workflow
This four-phase workflow consistently produces better results than immediate coding:
1. **Explore:** Ask Claude to read relevant files, images, or URLs—explicitly say "don't write code yet"
2. **Plan:** Request a plan using thinking keywords ("think hard about the approach")
3. **Document:** Have Claude create a plan document or GitHub issue as a checkpoint
4. **Execute:** Implement the solution, then commit and create a PR
### Test-Driven Development with Claude
TDD aligns perfectly with Claude Code's verification-oriented nature:
1. Ask Claude to write tests based on expected input/output pairs
2. Have Claude run tests and confirm they fail (no implementation yet)
3. Commit the tests
4. Ask Claude to write code that passes tests without modifying them
5. Use subagents to verify implementation isn't overfitting
6. Commit the working code
### Prompting Strategies
Specificity dramatically improves success rates:
| Ineffective | Effective |
|-------------|-----------|
| "add tests for foo.py" | "Write a new test case for foo.py, covering the edge case where the user is logged out. Avoid mocks." |
| "why is this API weird?" | "Look through ExecutionFactory's git history and summarize how its API evolved" |
| "add a calendar widget" | "Look at HotDogWidget.php for our widget pattern. Follow it to implement a calendar widget with month selection and pagination." |
#### Key Prompting Principles
- Give all context—Claude can't read your mind
- Mention edge cases explicitly
- Reference similar patterns in the codebase
- Provide concrete examples instead of abstract descriptions
- Break large tasks into smaller, verifiable chunks
- Encourage Claude to ask clarifying questions during planning
### Context Management
The 200k token context window fills quickly. Monitor with `/context` and manage proactively:
- **Use `/clear` aggressively** between unrelated tasks
- **Avoid `/compact`** when possible—auto-compaction is opaque and error-prone
- **Document and clear** for complex tasks: dump progress to a `.md` file, `/clear`, then resume by reading the file
- Fresh monorepo sessions start at ~20k tokens baseline
### Common Pitfalls to Avoid
1. **Not using /clear enough:** Context pollution causes unpredictable behavior
2. **Treating Claude like autocomplete:** Real power comes from planning first
3. **Vague prompts:** Specificity dramatically improves success rate
4. **Massive one-shot tasks:** Break into smaller, verifiable chunks
5. **Not giving visual context:** Screenshots improve UI work significantly
6. **Ignoring the escape key:** Course-correct actively rather than letting Claude go down rabbit holes
7. **Not staging git changes:** Stage early and often as checkpoints
8. **Complex custom slash commands:** Keep them as simple shortcuts, not replacements for good CLAUDE.md
### Configuration for Claude 4.x Models
Claude 4.x models follow instructions more precisely but require explicit requests for "above and beyond" behavior. Add these prompts to CLAUDE.md:
**For proactive action:**
```markdown
By default, implement changes rather than only suggesting them. If intent is unclear, infer the most useful likely action and proceed, using tools to discover missing details instead of guessing.
```
**To prevent over-engineering (especially for Opus 4.5):**
```markdown
Avoid over-engineering. Only make changes directly requested or clearly necessary. Don't add features, refactor code, or make "improvements" beyond what was asked. A bug fix doesn't need surrounding code cleaned up.
```
**To minimize hallucinations:**
```markdown
Never speculate about code you have not opened. If the user references a specific file, you MUST read the file before answering. Investigate and read relevant files BEFORE answering questions about the codebase.
```
---
## Part 7: Context Engineering
Context engineering is the natural progression of prompt engineering. While prompt engineering focuses on writing effective prompts, context engineering manages the entire context state including system instructions, tools, external data, and message history.
### Why Context Engineering Matters
LLMs, like humans, lose focus at a certain point. This phenomenon is called **context rot**: as the number of tokens increases, the model's ability to accurately recall information decreases. Context must be treated as a finite resource with diminishing marginal returns.
### The Guiding Principle
> **Find the smallest possible set of high-signal tokens that maximize the likelihood of your desired outcome.**
### System Prompts: The Goldilocks Zone
System prompts should be extremely clear and use simple, direct language at the right "altitude":
- **Too prescriptive:** Hardcoding complex if-else logic creates brittle agents
- **Too vague:** High-level guidance fails to give concrete signals
- **Just right:** Specific enough to guide behavior, flexible enough to provide strong heuristics
### Just-In-Time Context Retrieval
Rather than pre-processing all relevant data up front, agents can maintain lightweight identifiers (file paths, queries, links) and use these references to dynamically load data at runtime.
This approach mirrors human cognition: we don't memorize entire corpuses, but use external organization systems like file systems, inboxes, and bookmarks to retrieve relevant information on demand.
Claude Code uses this hybrid model: CLAUDE.md files are naively dropped into context up front, while primitives like `glob` and `grep` allow it to navigate its environment and retrieve files just-in-time.
### Techniques for Long-Horizon Tasks
#### Compaction
Take a conversation nearing the context window limit, summarize its contents, and reinitiate with the summary. The art of compaction lies in selecting what to keep vs. discard:
- Start by maximizing recall to capture all relevant information
- Then iterate to improve precision by eliminating superfluous content
- One safe form: clearing tool calls and results deep in message history
#### Structured Note-Taking (Agentic Memory)
Regularly write notes persisted outside the context window that get pulled back in when needed:
- Like Claude Code creating a to-do list
- Or your custom agent maintaining a `NOTES.md` file
- Enables tracking progress across complex tasks
#### Sub-Agent Architectures
Specialized sub-agents handle focused tasks with clean context windows:
- Each subagent might use tens of thousands of tokens exploring
- But returns only a condensed summary (1,000-2,000 tokens)
- The lead agent focuses on synthesizing results
- Clear separation of concerns keeps the main context clean
---
## Part 8: Advanced Features
### Code Execution with MCP
Anthropic's code execution with MCP pattern restructures how agents interact with tools. Instead of loading all tool definitions upfront, agents write code to interact with MCP servers, achieving up to **98.7% reduction in token consumption** (from 150,000 to 2,000 tokens).
#### How It Works
Present MCP servers as code APIs in a filesystem structure:
```
servers
├── google-drive
│ ├── getDocument.ts
│ └── index.ts
├── salesforce
│ ├── updateRecord.ts
│ └── index.ts
└── ... (other servers)
```
The agent discovers tools by exploring the filesystem, loading only the definitions it needs for the current task.
#### Benefits
- **Progressive disclosure:** Load tools on-demand rather than all up-front
- **Context-efficient data handling:** Filter and transform data in the execution environment before returning to the model
- **Privacy-preserving operations:** Intermediate results stay in the execution environment by default
- **State persistence and skills:** Save working code as reusable functions in a `./skills/` directory
### Claude Code GitHub Action
The GitHub Action runs Claude Code in a GHA container. You control the entire container and environment, giving you more access to data and stronger sandboxing and audit controls than any other product provides.
**Use cases:**
- Build custom "PR-from-anywhere" tooling triggered from Slack, Jira, or CloudWatch alerts
- Review GHA logs for common mistakes to create a data-driven improvement flywheel
- Supports all advanced features including Hooks and MCP
**Example meta-improvement loop:**
```bash
$ query-claude-gha-logs --since 5d | claude -p "see what the other claudes were getting stuck on and fix it, then put up a PR"
```
### Sandboxing Features
Claude Code includes native sandboxing with filesystem and network isolation:
- **Filesystem isolation:** Claude can only access or modify specific directories, preventing prompt-injected Claude from modifying sensitive system files
- **Network isolation:** Claude can only connect to approved servers, preventing exfiltration of sensitive information
- **Activation:** `claude --sandbox`
Both isolation types are needed—without network isolation, a compromised agent could exfiltrate files; without filesystem isolation, it could escape the sandbox and gain network access.
### Claude Agent SDK
The SDK that powers Claude Code can power many other types of agents too. Use it for:
- **Massive parallel scripting:** Write bash scripts that call `claude -p "..."` in parallel for large-scale refactors
- **Building internal chat tools:** Wrap complex processes in a simple chat interface for non-technical users
- **Rapid agent prototyping:** Build and test agentic prototypes before committing to full deployment scaffolding
### Settings.json Configuration
Key configurations for advanced users:
- **HTTPS_PROXY/HTTP_PROXY:** For debugging and fine-grained network sandboxing
- **MCP_TOOL_TIMEOUT/BASH_MAX_TIMEOUT_MS:** Increase for long, complex commands
- **ANTHROPIC_API_KEY:** Use enterprise API keys for usage-based pricing
- **permissions:** Self-audit the list of commands allowed to auto-run
---
## Conclusion
Claude Code's power emerges from the thoughtful combination of its configuration systems:
- **CLAUDE.md** establishes context and preferences, shaping how Claude understands your project
- **Hooks** enforce deterministic rules, ensuring consistent behavior regardless of how Claude interprets instructions
- **Skills** provide modular capabilities that Claude invokes autonomously when appropriate
- **Commands** offer user-triggered shortcuts for common workflows
The key insight from Anthropic's engineering team: **start simple, add complexity only when needed**. A well-crafted CLAUDE.md file often eliminates the need for elaborate hooks or custom skills. Use the explore-plan-code-commit workflow to prevent Claude from jumping straight to implementation. Clear context frequently. Be specific in your prompts.
Multi-agent workflows represent the frontier of Claude Code productivity, with orchestrator-worker patterns achieving **90% better results** than single-agent approaches on complex tasks. As you gain experience, experiment with git worktrees for parallel sessions, headless mode for automation, and the built-in Task() feature for spawning focused subagents.
The tool rewards investment in configuration. The secret isn't in the prompts—it's in the process. Plan first, think appropriately hard, collaborate actively, and teach Claude about your specific context through CLAUDE.md files.
---
## Sources
- [Anthropic Engineering: Claude Code Best Practices](https://www.anthropic.com/engineering/claude-code-best-practices)
- [Anthropic Engineering: Building Agents with the Claude Agent SDK](https://www.anthropic.com/engineering/building-agents-with-the-claude-agent-sdk)
- [Anthropic Engineering: Equipping Agents with Agent Skills](https://www.anthropic.com/engineering/equipping-agents-for-the-real-world-with-agent-skills)
- [Anthropic Engineering: Effective Context Engineering for AI Agents](https://www.anthropic.com/engineering/effective-context-engineering-for-ai-agents)
- [Anthropic Engineering: Writing Effective Tools for Agents](https://www.anthropic.com/engineering/writing-tools-for-agents)
- [Anthropic Engineering: Code Execution with MCP](https://www.anthropic.com/engineering/code-execution-with-mcp)
- [Anthropic Engineering: How We Built Our Multi-Agent Research System](https://www.anthropic.com/engineering/multi-agent-research-system)
- [Claude Code Documentation](https://docs.anthropic.com/en/docs/claude-code/)
- [Claude 4.x Prompting Best Practices](https://platform.claude.com/docs/en/build-with-claude/prompt-engineering/claude-4-best-practices)
- [How I Use Every Claude Code Feature - Shrivu Shankar](https://blog.sshh.io/p/how-i-use-every-claude-code-feature)
- [Mastering the Vibe: Claude Code Best Practices - Dinanjana Gunaratne](https://dinanjana.medium.com/mastering-the-vibe-claude-code-best-practices-that-actually-work-823371daf64c)
---
*— End of Guide —*

View File

@@ -1,11 +1,11 @@
# Library Reference Guide
> **Last Updated:** 2025-11-28
> **Last Updated:** 2025-12-10
> **Angular Version:** 20.3.6
> **Nx Version:** 21.3.2
> **Total Libraries:** 74
> **Total Libraries:** 81
All 74 libraries in the monorepo have comprehensive README.md documentation located at `libs/[domain]/[layer]/[feature]/README.md`.
All 81 libraries in the monorepo have comprehensive README.md documentation located at `libs/[domain]/[layer]/[feature]/README.md`.
**IMPORTANT: Always use the `docs-researcher` subagent** to retrieve and analyze library documentation. This keeps the main context clean and prevents pollution.
@@ -66,7 +66,7 @@ A comprehensive loyalty rewards catalog feature for Angular applications support
---
## Common Libraries (3 libraries)
## Common Libraries (4 libraries)
### `@isa/common/data-access`
A foundational data access library providing core utilities, error handling, RxJS operators, response models, and advanced batching infrastructure for Angular applications.
@@ -83,6 +83,11 @@ A comprehensive print management library for Angular applications providing prin
**Location:** `libs/common/print/`
### `@isa/common/title-management`
Reusable title management patterns for Angular applications with reactive updates and tab integration.
**Location:** `libs/common/title-management/`
---
## Core Libraries (6 libraries)
@@ -97,16 +102,16 @@ A lightweight, type-safe configuration management system for Angular application
**Location:** `libs/core/config/`
### `@isa/core/connectivity`
**Type:** Util Library
**Location:** `libs/core/connectivity/`
### `@isa/core/logging`
A structured, high-performance logging library for Angular applications with hierarchical context support and flexible sink architecture.
**Location:** `libs/core/logging/`
### `@isa/core/navigation`
A reusable Angular library providing **context preservation** for multi-step navigation flows with automatic tab-scoped storage.
**Location:** `libs/core/navigation/`
### `@isa/core/storage`
A powerful, type-safe storage library for Angular applications built on top of NgRx Signals. This library provides seamless integration between NgRx Signal Stores and various storage backends including localStorage, sessionStorage, IndexedDB, and server-side user state.
@@ -425,6 +430,40 @@ A lightweight Zod utility library for safe parsing with automatic fallback to or
---
## Shell Domain (6 libraries)
### `@isa/shell/common`
**Type:** Util Library
**Location:** `libs/shell/common/`
### `@isa/shell/header`
**Type:** Feature Library
**Location:** `libs/shell/header/`
### `@isa/shell/layout`
**Type:** Feature Library
**Location:** `libs/shell/layout/`
### `@isa/shell/navigation`
Collapsible navigation menu components for the application shell sidebar.
**Location:** `libs/shell/navigation/`
### `@isa/shell/notifications`
**Type:** Feature Library
**Location:** `libs/shell/notifications/`
### `@isa/shell/tabs`
UI components for displaying and managing browser-like tabs in the application shell.
**Location:** `libs/shell/tabs/`
---
## How to Use This Guide
1. **Quick Lookup**: Use this guide to find the purpose of any library in the monorepo

View File

@@ -4,6 +4,7 @@
* Provides role-based authorization utilities for the ISA Frontend application.
*/
export { AuthService } from './lib/auth.service';
export { RoleService } from './lib/role.service';
export { IfRoleDirective } from './lib/if-role.directive';
export { TokenProvider, TOKEN_PROVIDER, parseJwt } from './lib/token-provider';

View File

@@ -0,0 +1,14 @@
import { inject, Injectable } from '@angular/core';
import { OAuthService } from 'angular-oauth2-oidc';
import { logger } from '@isa/core/logging';
@Injectable({ providedIn: 'root' })
export class AuthService {
#logger = logger({ service: 'AuthService' });
#oAuthService = inject(OAuthService);
logout(): void {
this.#logger.info('User logging out');
this.#oAuthService.logOut();
}
}

View File

@@ -21,8 +21,8 @@ import { Role } from './role';
providedIn: 'root',
})
export class RoleService {
private readonly _log = logger({ service: 'RoleService' });
private readonly _tokenProvider = inject(TOKEN_PROVIDER);
#logger = logger({ service: 'RoleService' });
#tokenProvider = inject(TOKEN_PROVIDER);
/**
* Check if the authenticated user has specific role(s)
@@ -45,10 +45,10 @@ export class RoleService {
const roles = coerceArray(role);
try {
const userRoles = this._tokenProvider.getClaimByKey('role');
const userRoles = this.#tokenProvider.getClaimByKey('role');
if (!userRoles) {
this._log.debug('No roles found in token claims');
this.#logger.debug('No roles found in token claims');
return false;
}
@@ -57,14 +57,14 @@ export class RoleService {
const hasAllRoles = roles.every((r) => userRolesArray.includes(r));
this._log.debug(`Role check: ${roles.join(', ')} => ${hasAllRoles}`, () => ({
this.#logger.debug(`Role check: ${roles.join(', ')} => ${hasAllRoles}`, () => ({
requiredRoles: roles,
userRoles: userRolesArray,
}));
return hasAllRoles;
} catch (error) {
this._log.error('Error checking roles', error as Error, () => ({ requiredRoles: roles }));
this.#logger.error('Error checking roles', error as Error, () => ({ requiredRoles: roles }));
return false;
}
}

View File

@@ -0,0 +1,95 @@
# @isa/core/connectivity
> **Type:** Util Library
> **Domain:** Core
> **Path:** `libs/core/connectivity`
## Overview
Network connectivity monitoring service that tracks browser online/offline status using the Navigator API.
## Features
- Real-time network status detection
- Observable and Signal-based APIs
- Immediate emission on subscription
- Shared replay for multiple subscribers
## Installation
```typescript
import {
NetworkStatusService,
NetworkStatus,
injectNetworkStatus$,
injectNetworkStatus
} from '@isa/core/connectivity';
```
## Usage
### As Observable
```typescript
@Component({...})
export class MyComponent {
networkService = inject(NetworkStatusService);
constructor() {
this.networkService.status$.subscribe(status => {
console.log('Network status:', status); // 'online' | 'offline'
});
}
}
```
### Using Injection Functions
```typescript
@Component({...})
export class MyComponent {
// As Observable
networkStatus$ = injectNetworkStatus$();
// As Signal
networkStatus = injectNetworkStatus();
isOffline = computed(() => this.networkStatus() === 'offline');
}
```
## API
### NetworkStatusService
| Member | Type | Description |
|--------|------|-------------|
| `status$` | `Observable<NetworkStatus>` | Emits network status changes |
### Types
```typescript
type NetworkStatus = 'online' | 'offline';
```
### Injection Functions
| Function | Returns | Description |
|----------|---------|-------------|
| `injectNetworkStatus$()` | `Observable<NetworkStatus>` | Observable of network status |
| `injectNetworkStatus()` | `Signal<NetworkStatus \| undefined>` | Signal of network status |
## Dependencies
**External:**
- `rxjs` - Observable streams
## Testing
```bash
npx nx test core-connectivity
```
## Related Libraries
- [`@isa/shell/layout`](../../shell/layout) - Uses for network status banner

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: 'core',
style: 'camelCase',
},
],
'@angular-eslint/component-selector': [
'error',
{
type: 'element',
prefix: 'core',
style: 'kebab-case',
},
],
},
},
{
files: ['**/*.html'],
// Override or add rules here
rules: {},
},
];

View File

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

View File

@@ -0,0 +1,6 @@
export {
NetworkStatusService,
NetworkStatus,
injectNetworkStatus$,
injectNetworkStatus,
} from './lib/network-status.service';

View File

@@ -0,0 +1,137 @@
import { TestBed } from '@angular/core/testing';
import { firstValueFrom, take, toArray } from 'rxjs';
import {
NetworkStatusService,
injectNetworkStatus$,
injectNetworkStatus,
} from './network-status.service';
describe('NetworkStatusService', () => {
let service: NetworkStatusService;
let originalNavigatorOnLine: boolean;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(NetworkStatusService);
originalNavigatorOnLine = navigator.onLine;
});
afterEach(() => {
Object.defineProperty(navigator, 'onLine', {
value: originalNavigatorOnLine,
writable: true,
configurable: true,
});
});
const setNavigatorOnLine = (value: boolean) => {
Object.defineProperty(navigator, 'onLine', {
value,
writable: true,
configurable: true,
});
};
describe('status$', () => {
it('should emit initial status based on navigator.onLine', async () => {
setNavigatorOnLine(true);
const newService = TestBed.inject(NetworkStatusService);
const status = await firstValueFrom(newService.status$);
expect(status).toBe('online');
});
it('should emit "offline" when navigator.onLine is false', async () => {
setNavigatorOnLine(false);
const status = await firstValueFrom(service.status$);
expect(status).toBe('offline');
});
it('should emit "online" when online event is dispatched', async () => {
setNavigatorOnLine(false);
const statusPromise = firstValueFrom(service.status$.pipe(take(2), toArray()));
setNavigatorOnLine(true);
window.dispatchEvent(new Event('online'));
const statuses = await statusPromise;
expect(statuses).toContain('online');
});
it('should emit "offline" when offline event is dispatched', async () => {
setNavigatorOnLine(true);
const statusPromise = firstValueFrom(service.status$.pipe(take(2), toArray()));
setNavigatorOnLine(false);
window.dispatchEvent(new Event('offline'));
const statuses = await statusPromise;
expect(statuses).toContain('offline');
});
it('should share the same observable instance (shareReplay)', async () => {
const subscription1Values: string[] = [];
const subscription2Values: string[] = [];
const sub1 = service.status$.subscribe((v) => subscription1Values.push(v));
const sub2 = service.status$.subscribe((v) => subscription2Values.push(v));
// Both subscribers should receive the same initial value
expect(subscription1Values.length).toBeGreaterThan(0);
expect(subscription2Values.length).toBeGreaterThan(0);
expect(subscription1Values[0]).toBe(subscription2Values[0]);
sub1.unsubscribe();
sub2.unsubscribe();
});
it('should replay last value to new subscribers', async () => {
// First subscriber gets the value
const firstValue = await firstValueFrom(service.status$);
// Second subscriber should get the same replayed value
const secondValue = await firstValueFrom(service.status$);
expect(firstValue).toBe(secondValue);
});
});
describe('injectNetworkStatus$', () => {
it('should return status$ observable from service', () => {
TestBed.runInInjectionContext(() => {
const status$ = injectNetworkStatus$();
expect(status$).toBe(service.status$);
});
});
});
describe('injectNetworkStatus', () => {
it('should return a signal with current network status', () => {
setNavigatorOnLine(true);
TestBed.runInInjectionContext(() => {
const statusSignal = injectNetworkStatus();
expect(statusSignal()).toBe('online');
});
});
it('should return a signal that reflects offline status', () => {
setNavigatorOnLine(false);
TestBed.runInInjectionContext(() => {
const statusSignal = injectNetworkStatus();
expect(statusSignal()).toBe('offline');
});
});
});
});

View File

@@ -0,0 +1,63 @@
import { inject, Injectable } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { logger } from '@isa/core/logging';
import {
map,
Observable,
fromEvent,
merge,
startWith,
shareReplay,
tap,
} from 'rxjs';
/** Network connectivity status values. */
export type NetworkStatus = 'online' | 'offline';
/**
* Service for monitoring browser network connectivity status.
*
* Listens to browser online/offline events and provides a reactive
* observable that emits the current network status. The observable
* is shared and replays the latest value to new subscribers.
*
* @example
* ```typescript
* readonly networkService = inject(NetworkStatusService);
*
* // Subscribe to status changes
* this.networkService.status$.subscribe(status => {
* console.log('Network status:', status);
* });
* ```
*/
@Injectable({ providedIn: 'root' })
export class NetworkStatusService {
#logger = logger({ service: 'NetworkStatusService' });
/**
* Observable that emits current network status ('online' | 'offline').
* Emits immediately on subscription with current state.
*/
readonly status$: Observable<NetworkStatus> = merge(
fromEvent(window, 'online'),
fromEvent(window, 'offline'),
).pipe(
startWith(null), // emit immediately
map((): NetworkStatus => (navigator.onLine ? 'online' : 'offline')),
tap((status) => this.#logger.debug('Network status changed', () => ({ status }))),
shareReplay({ bufferSize: 1, refCount: true }),
);
}
/**
* Injection function to get the network status observable.
* @returns Observable of network status
*/
export const injectNetworkStatus$ = () => inject(NetworkStatusService).status$;
/**
* Injection function to get network status as a signal.
* @returns Signal of network status (undefined initially until first emission)
*/
export const injectNetworkStatus = () => toSignal(injectNetworkStatus$());

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

View File

@@ -1,6 +1,18 @@
import { inject } from '@angular/core';
import { computed, inject, Signal } from '@angular/core';
import { Params, QueryParamsHandling } from '@angular/router';
import { Config } from '@isa/core/config';
import { z } from 'zod';
import { TabService } from './tab';
const ReservedProcessIdsSchema = z.object({
goodsOut: z.number(),
goodsIn: z.number(),
taskCalendar: z.number(),
packageInspection: z.number(),
assortment: z.number(),
pickupShelf: z.number(),
});
/**
* Injects the current activated tab as a signal.
* @returns A signal that emits the current activated tab or null if no tab is activated.
@@ -16,3 +28,226 @@ export function injectTab() {
export function injectTabId() {
return inject(TabService).activatedTabId;
}
/**
* Returns a tab ID that is safe to use for navigation.
* If the current tab ID is a reserved process ID, generates a new ID using Date.now().
*/
function getNavigableTabId(
activeTabId: number | null | undefined,
reservedIds: Set<number>,
): number {
if (
activeTabId === undefined ||
activeTabId === null ||
reservedIds.has(activeTabId)
) {
return Date.now();
}
return activeTabId;
}
/**
* Injects the reserved process IDs from config.
*/
function injectReservedProcessIds(): Set<number> {
const config = inject(Config);
const processIds = config.get('process.ids', ReservedProcessIdsSchema);
return new Set(Object.values(processIds));
}
/**
* Options for configuring tab route navigation.
*/
export type TabRouteOptions = {
/** Query parameters to append to the route URL. */
queryParams?: Params;
/** Strategy for handling existing query parameters when navigating. */
queryParamsHandling?: QueryParamsHandling;
};
/**
* Auxiliary route outlets configuration.
* @example
* { outlets: { primary: ['filter'], side: ['search'] } }
* { outlets: { primary: ['details', itemId], side: null } }
*/
export type AuxiliaryOutlets = {
outlets: Record<string, (string | number)[] | string | null>;
};
/**
* A path segment can be a string, number, or an auxiliary outlets object.
*/
export type PathSegment = string | number | AuxiliaryOutlets;
/**
* A route configuration for tab-based navigation.
* Contains the route path segments and optional query parameters.
*/
export type TabRoute = {
/** Route path segments including the tab ID prefix. */
route: PathSegment[];
/** Query parameters to append to the route URL. */
queryParams?: Params;
/** Strategy for handling existing query parameters when navigating. */
queryParamsHandling?: QueryParamsHandling;
};
/**
* A route configuration with an associated label for display purposes.
* Useful for navigation menus and breadcrumbs.
*/
export type LabeledTabRoute = TabRoute & { label: string };
/**
* Creates a computed signal that returns a route object with the current tab ID
* or a new tab ID (using Date.now()) if no tab is active.
*
* @param pathSegments - The path segments to append after the tab ID (supports auxiliary outlets)
* @param options - Optional query params and handling
* @returns A computed signal with the route object
*
* @example
* // Basic usage
* const route = injectTabRoute(['product']);
*
* // With query params
* const route = injectTabRoute(['product'], { queryParams: { view: 'grid' } });
*
* // With auxiliary outlets
* const route = injectTabRoute(['product', { outlets: { primary: ['filter'], side: ['search'] } }]);
*/
export function injectTabRoute(
pathSegments: PathSegment[],
options?: TabRouteOptions,
): Signal<TabRoute> {
const activeTabId = injectTabId();
const reservedIds = injectReservedProcessIds();
return computed(() => {
const tabId = getNavigableTabId(activeTabId(), reservedIds);
return {
route: ['/', tabId, ...pathSegments],
...(options?.queryParams && { queryParams: options.queryParams }),
...(options?.queryParamsHandling && {
queryParamsHandling: options.queryParamsHandling,
}),
};
});
}
/**
* Creates a computed signal that returns a labeled route object with the current tab ID
* or a new tab ID (using Date.now()) if no tab is active.
*
* @param label - The label for the route
* @param pathSegments - The path segments to append after the tab ID (supports auxiliary outlets)
* @param options - Optional query params and handling
* @returns A computed signal with the labeled route object
*
* @example
* // Basic usage
* const route = injectLabeledTabRoute('Products', ['product']);
*
* // With query params
* const route = injectLabeledTabRoute('Products', ['product'], { queryParams: { view: 'grid' } });
*
* // With auxiliary outlets
* const route = injectLabeledTabRoute('Products', ['product', { outlets: { primary: ['filter'], side: ['search'] } }]);
*/
export function injectLabeledTabRoute(
label: string,
pathSegments: PathSegment[],
options?: TabRouteOptions,
): Signal<LabeledTabRoute> {
const activeTabId = injectTabId();
const reservedIds = injectReservedProcessIds();
return computed(() => {
const tabId = getNavigableTabId(activeTabId(), reservedIds);
return {
label,
route: ['/', tabId, ...pathSegments],
...(options?.queryParams && { queryParams: options.queryParams }),
...(options?.queryParamsHandling && {
queryParamsHandling: options.queryParamsHandling,
}),
};
});
}
/**
* Creates a computed signal that returns a legacy route object with the current tab ID
* or a new tab ID (using Date.now()) for routes under /kunde/:tabId/...
*
* @param pathSegments - The path segments to append after /kunde/:tabId (supports auxiliary outlets)
* @param options - Optional query params and handling
* @returns A computed signal with the route object
*
* @deprecated Use `injectTabRoute` instead. Legacy routes under /kunde/:tabId will be migrated to /:tabId.
*
* @example
* // Creates route like /kunde/123456/product
* const route = injectLegacyTabRoute(['product']);
*
* // With auxiliary outlets: /kunde/123456/product/(primary:filter//side:search)
* const route = injectLegacyTabRoute(['product', { outlets: { primary: ['filter'], side: ['search'] } }]);
*/
export function injectLegacyTabRoute(
pathSegments: PathSegment[],
options?: TabRouteOptions,
): Signal<TabRoute> {
const activeTabId = injectTabId();
const reservedIds = injectReservedProcessIds();
return computed(() => {
const tabId = getNavigableTabId(activeTabId(), reservedIds);
return {
route: ['/kunde', tabId, ...pathSegments],
...(options?.queryParams && { queryParams: options.queryParams }),
...(options?.queryParamsHandling && {
queryParamsHandling: options.queryParamsHandling,
}),
};
});
}
/**
* Creates a computed signal that returns a labeled legacy route object with the current tab ID
* or a new tab ID (using Date.now()) for routes under /kunde/:tabId/...
*
* @param label - The label for the route
* @param pathSegments - The path segments to append after /kunde/:tabId (supports auxiliary outlets)
* @param options - Optional query params and handling
* @returns A computed signal with the labeled route object
*
* @deprecated Use `injectLabeledTabRoute` instead. Legacy routes under /kunde/:tabId will be migrated to /:tabId.
*
* @example
* // Creates route like /kunde/123456/product
* const route = injectLabeledLegacyTabRoute('Products', ['product']);
*
* // With auxiliary outlets: /kunde/123456/product/(primary:filter//side:search)
* const route = injectLabeledLegacyTabRoute('Products', ['product', { outlets: { primary: ['filter'], side: ['search'] } }]);
*/
export function injectLabeledLegacyTabRoute(
label: string,
pathSegments: PathSegment[],
options?: TabRouteOptions,
): Signal<LabeledTabRoute> {
const activeTabId = injectTabId();
const reservedIds = injectReservedProcessIds();
return computed(() => {
const tabId = getNavigableTabId(activeTabId(), reservedIds);
return {
label,
route: ['/kunde', tabId, ...pathSegments],
...(options?.queryParams && { queryParams: options.queryParams }),
...(options?.queryParamsHandling && {
queryParamsHandling: options.queryParamsHandling,
}),
};
});
}

View File

@@ -8,6 +8,7 @@ import {
} from '@ngrx/signals';
import {
addEntity,
removeAllEntities,
removeEntity,
updateEntity,
withEntities,
@@ -56,6 +57,15 @@ export const TabService = signalStore(
}
return store.entities().find((e) => e.id === activeTabId) ?? null;
}),
/**
* Returns tabs sorted by activation time (most recently activated first).
* Tabs without activatedAt are placed at the end.
*/
tabsByActivationOrder: computed<Tab[]>(() => {
return [...store.entities()].sort(
(a, b) => (b.activatedAt ?? 0) - (a.activatedAt ?? 0),
);
}),
})),
withMethods((store) => ({
addTab(add: z.input<typeof AddTabSchema>) {
@@ -139,13 +149,43 @@ export const TabService = signalStore(
metadataKeys: Object.keys(metadata),
}));
},
removeTab(id: number) {
/**
* Removes a tab by ID.
* @returns The previously active tab if the removed tab was active, null otherwise.
*/
removeTab(id: number): Tab | null {
const wasActive = store.activatedTabId() === id;
// Find the next tab to activate before removing
let previousTab: Tab | null = null;
if (wasActive) {
previousTab =
store
.tabsByActivationOrder()
.find((tab) => tab.id !== id && tab.activatedAt !== undefined) ??
null;
}
patchState(store, removeEntity(id));
if (wasActive) {
patchState(store, { activatedTabId: null });
}
store._logger.info('Tab removed', () => ({ tabId: id, wasActive }));
store._logger.info('Tab removed', () => ({
tabId: id,
wasActive,
previousTabId: previousTab?.id ?? null,
}));
return previousTab;
},
/**
* Removes all tabs.
*/
removeAllTabs(): void {
const tabCount = store.entities().length;
patchState(store, removeAllEntities(), { activatedTabId: null });
store._logger.info('All tabs removed', () => ({ count: tabCount }));
},
navigateToLocation(
id: number,

View File

File diff suppressed because one or more lines are too long

234
libs/shell/common/README.md Normal file
View File

@@ -0,0 +1,234 @@
# shell-common
> **Type:** Util Library
> **Domain:** Shell
> **Path:** `libs/shell/common`
## Overview
Shared services and types for shell-domain components. Provides state management for navigation, font size, and notifications.
## Services
### NavigationService
Controls the navigation drawer open/closed state.
```typescript
import { NavigationService } from '@isa/shell/common';
@Component({...})
export class MyComponent {
navigationService = inject(NavigationService);
// Read state (readonly signal)
isOpen = this.navigationService.get;
// Toggle navigation
toggleNav() {
this.navigationService.toggle();
}
// Set specific state
closeNav() {
this.navigationService.set(false);
}
}
```
**API:**
- `get` - Readonly signal of navigation state (`boolean`)
- `toggle()` - Toggles navigation open/closed
- `set(state: boolean)` - Sets navigation state
### FontSizeService
Manages application-wide font size for accessibility.
```typescript
import { FontSizeService, FontSize } from '@isa/shell/common';
@Component({...})
export class MyComponent {
fontSizeService = inject(FontSizeService);
// Read current size
currentSize = this.fontSizeService.get;
// Get size in pixels
currentPx = this.fontSizeService.getPx;
// Change font size
setLarge() {
this.fontSizeService.set('large');
}
// Convert rem to px
getPixels(rem: number) {
return this.fontSizeService.remToPx(rem);
}
}
```
**API:**
- `get` - Readonly signal of current font size
- `getPx` - Computed signal of font size in pixels
- `set(size: FontSize)` - Sets font size
- `remToPx(rem: number)` - Converts rem to pixels
- `fontSizeEffect` - Effect that syncs font size to document
**Types:**
```typescript
type FontSize = 'small' | 'medium' | 'large';
// Maps to: 14px | 16px | 18px
```
### TabsCollapsedService
Controls the collapsed/expanded state of the shell tabs bar.
```typescript
import { TabsCollapsedService } from '@isa/shell/common';
@Component({...})
export class MyComponent {
tabsCollapsed = inject(TabsCollapsedService);
// Read state (readonly signal)
isCollapsed = this.tabsCollapsed.get;
// Toggle collapsed state
toggleTabs() {
this.tabsCollapsed.toggle();
}
// Set specific state
expandTabs() {
this.tabsCollapsed.set(false);
}
}
```
**API:**
- `get` - Readonly signal of collapsed state (`boolean`)
- `toggle()` - Toggles collapsed/expanded state
- `set(state: boolean)` - Sets collapsed state (no-op if already equal)
### NotificationsService
Manages application notifications with read status tracking.
```typescript
import { NotificationsService, Notification } from '@isa/shell/common';
@Component({...})
export class MyComponent {
notificationsService = inject(NotificationsService);
// Read all notifications
notifications = this.notificationsService.get;
// Get unread count
unreadCount = this.notificationsService.unreadCount;
// Add notification
notify() {
this.notificationsService.add({
id: 'unique-id',
group: 'Orders',
title: 'New Order',
message: 'Order #123 received',
timestamp: Date.now(),
action: {
type: 'navigate',
label: 'View',
target: 'internal',
route: '/orders/123'
}
});
}
// Mark as read
markRead(id: string) {
this.notificationsService.markAsRead(id);
}
}
```
**API:**
- `get` - Readonly signal of all notifications
- `unreadCount` - Computed signal of unread count
- `add(notification: Notification)` - Adds notification
- `remove(id: NotificationId)` - Removes notification
- `clear()` - Removes all notifications
- `markAsRead(id: NotificationId)` - Marks single notification as read
- `markAllAsRead()` - Marks all notifications as read
## Types
### Notification
```typescript
type Notification = {
id: string | number;
group: string;
title: string;
message: string;
action: NotificationAction;
markedAsRead?: number; // timestamp
timestamp: number;
};
```
### NotificationAction
```typescript
type NotificationAction =
| NotificationActionNavigate
| NotificationActionCallback;
type NotificationActionNavigate = {
type: 'navigate';
label: string;
target: 'internal' | 'external';
route: string;
};
type NotificationActionCallback = {
type: 'callback';
label: string;
callback: () => void;
};
```
## Installation
```typescript
import {
NavigationService,
FontSizeService,
FontSize,
TabsCollapsedService,
NotificationsService,
Notification
} from '@isa/shell/common';
```
## Dependencies
**Internal:**
- `@isa/core/logging` - Logger factory
## Testing
```bash
npx nx test shell-common
```
## Related Libraries
- [`@isa/shell/header`](../header) - Uses NavigationService, FontSizeService, NotificationsService
- [`@isa/shell/layout`](../layout) - Uses NavigationService, FontSizeService, TabsCollabsedService
- [`@isa/shell/tabs`](../tabs) - Uses TabsCollabsedService
- [`@isa/shell/notifications`](../notifications) - Uses NotificationsService
- [`@isa/shell/navigation`](../navigation) - Uses NavigationService

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: 'shell',
style: 'camelCase',
},
],
'@angular-eslint/component-selector': [
'error',
{
type: 'element',
prefix: 'shell',
style: 'kebab-case',
},
],
},
},
{
files: ['**/*.html'],
// Override or add rules here
rules: {},
},
];

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