diff --git a/libs/shell/layout/src/lib/shell-layout.component.html b/libs/shell/layout/src/lib/shell-layout.component.html index 0538bb0f3..f74c28ab1 100644 --- a/libs/shell/layout/src/lib/shell-layout.component.html +++ b/libs/shell/layout/src/lib/shell-layout.component.html @@ -1,5 +1,10 @@ + +@if (renderNavigation()) { + +} +
diff --git a/libs/shell/layout/src/lib/shell-layout.component.ts b/libs/shell/layout/src/lib/shell-layout.component.ts index e0c8ac674..b4b90b72a 100644 --- a/libs/shell/layout/src/lib/shell-layout.component.ts +++ b/libs/shell/layout/src/lib/shell-layout.component.ts @@ -1,7 +1,14 @@ -import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { + ChangeDetectionStrategy, + Component, + computed, + inject, +} from '@angular/core'; import { NetworkStatusBannerComponent } from './components/network-status-banner.component'; - +import { NavigationService } from '@isa/shell/common'; import { ShellHeaderComponent } from '@isa/shell/header'; +import { ShellNavigationComponent } from '@isa/shell/navigation'; +import { Breakpoint, breakpoint } from '@isa/ui/layout'; @Component({ selector: 'shell-layout', @@ -9,9 +16,27 @@ import { ShellHeaderComponent } from '@isa/shell/header'; templateUrl: './shell-layout.component.html', styleUrls: ['./shell-layout.component.css'], changeDetection: ChangeDetectionStrategy.OnPush, - imports: [NetworkStatusBannerComponent, ShellHeaderComponent], + imports: [ + NetworkStatusBannerComponent, + ShellHeaderComponent, + ShellNavigationComponent, + ], host: { class: 'text-isa-neutral-900', }, }) -export class ShellLayoutComponent {} +export class ShellLayoutComponent { + #navigationService = inject(NavigationService); + + readonly tablet = breakpoint(Breakpoint.Tablet); + + readonly renderNavigation = computed(() => { + const tablet = this.tablet(); + + if (!tablet) { + return true; + } + + return this.#navigationService.get(); + }); +} diff --git a/libs/shell/navigation/src/index.ts b/libs/shell/navigation/src/index.ts index 4a52e42c5..8870efac0 100644 --- a/libs/shell/navigation/src/index.ts +++ b/libs/shell/navigation/src/index.ts @@ -1 +1 @@ -export * from './lib/shell-navigation/shell-navigation.component'; +export * from './lib/shell-navigation.component'; diff --git a/libs/shell/navigation/src/lib/components/navigation-group/navigation-group.component.css b/libs/shell/navigation/src/lib/components/navigation-group/navigation-group.component.css new file mode 100644 index 000000000..224543935 --- /dev/null +++ b/libs/shell/navigation/src/lib/components/navigation-group/navigation-group.component.css @@ -0,0 +1,3 @@ +:host { + @apply block self-stretch; +} diff --git a/libs/shell/navigation/src/lib/components/navigation-group/navigation-group.component.html b/libs/shell/navigation/src/lib/components/navigation-group/navigation-group.component.html new file mode 100644 index 000000000..49dda3704 --- /dev/null +++ b/libs/shell/navigation/src/lib/components/navigation-group/navigation-group.component.html @@ -0,0 +1,10 @@ +
+
+ {{ group().label }} +
+
+@for (item of group().items; track item.label) { + +} diff --git a/libs/shell/navigation/src/lib/components/navigation-group/navigation-group.component.ts b/libs/shell/navigation/src/lib/components/navigation-group/navigation-group.component.ts new file mode 100644 index 000000000..2af69be37 --- /dev/null +++ b/libs/shell/navigation/src/lib/components/navigation-group/navigation-group.component.ts @@ -0,0 +1,14 @@ +import { ChangeDetectionStrategy, Component, input } from '@angular/core'; +import { NavigationGroup } from '../../types'; +import { ShellNavigationItemComponent } from '../navigation-item/navigation-item.component'; + +@Component({ + selector: 'shell-navigation-group', + templateUrl: './navigation-group.component.html', + styleUrls: ['./navigation-group.component.css'], + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ShellNavigationItemComponent], +}) +export class ShellNavigationGroupComponent { + readonly group = input.required(); +} diff --git a/libs/shell/navigation/src/lib/components/navigation-item/navigation-item.component.css b/libs/shell/navigation/src/lib/components/navigation-item/navigation-item.component.css new file mode 100644 index 000000000..e1574133c --- /dev/null +++ b/libs/shell/navigation/src/lib/components/navigation-item/navigation-item.component.css @@ -0,0 +1,87 @@ +:host { + @apply block; +} + +a, +button { + @apply isa-text-body-1-regular; + transition: + background-color 0.15s ease-out, + font-weight 0.15s ease-out; +} + +a.active, +a:hover, +a:focus, +button:hover, +button:focus { + @apply bg-isa-neutral-200; +} + +a.active .label { + @apply isa-text-body-1-semibold; +} + +.active-bar { + transform: scaleX(0); + transform-origin: right; + opacity: 0; +} + +a.active .active-bar { + animation: barReveal 0.25s ease-out forwards; +} + +@keyframes barReveal { + from { + transform: scaleX(0); + opacity: 0; + } + to { + transform: scaleX(1); + opacity: 1; + } +} + +.chevron { + transition: transform 0.2s ease-out; +} + +.chevron.expanded { + transform: rotate(90deg); +} + +.sub-items { + display: grid; + grid-template-rows: 0fr; + opacity: 0; + transition: + grid-template-rows 0.25s ease-out, + opacity 0.25s ease-out; +} + +.sub-items.expanded { + grid-template-rows: 1fr; + opacity: 1; +} + +.sub-items.collapsed { + grid-template-rows: 0fr; + opacity: 0; +} + +@media (prefers-reduced-motion: reduce) { + .chevron { + transition: none; + } + + a.active .active-bar { + animation: none; + transform: scaleX(1); + opacity: 1; + } + + .sub-items { + transition: none; + } +} diff --git a/libs/shell/navigation/src/lib/components/navigation-item/navigation-item.component.html b/libs/shell/navigation/src/lib/components/navigation-item/navigation-item.component.html new file mode 100644 index 000000000..81b87beae --- /dev/null +++ b/libs/shell/navigation/src/lib/components/navigation-item/navigation-item.component.html @@ -0,0 +1,67 @@ +@if (route(); as r) { + + + +} @else { + +} + +@if (hasChildren()) { +
+
+ @for (child of item().childRoutes; track $index) { + + } +
+
+} + + +
+
+
+ {{ item().label }} +
+ @if (hasChildren()) { + + } +
diff --git a/libs/shell/navigation/src/lib/components/navigation-item/navigation-item.component.ts b/libs/shell/navigation/src/lib/components/navigation-item/navigation-item.component.ts new file mode 100644 index 000000000..9a9d93566 --- /dev/null +++ b/libs/shell/navigation/src/lib/components/navigation-item/navigation-item.component.ts @@ -0,0 +1,94 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + effect, + inject, + Injector, + input, + isSignal, + runInInjectionContext, + signal, + viewChildren, +} from '@angular/core'; +import { NavigationItem } from '../../types'; +import { DomSanitizer } from '@angular/platform-browser'; +import { isaActionChevronRight } from '@isa/icons'; +import { NgIcon, provideIcons } from '@ng-icons/core'; +import { NgTemplateOutlet } from '@angular/common'; +import { RouterLink, RouterLinkActive } from '@angular/router'; +import { ShellNavigationSubItemComponent } from '../navigation-sub-item/navigation-sub-item.component'; + +@Component({ + selector: 'shell-navigation-item', + templateUrl: './navigation-item.component.html', + styleUrls: ['./navigation-item.component.css'], + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + NgIcon, + NgTemplateOutlet, + RouterLink, + RouterLinkActive, + ShellNavigationSubItemComponent, + ], + providers: [ + provideIcons({ + isaActionChevronRight, + }), + ], +}) +export class ShellNavigationItemComponent { + #injector = inject(Injector); + #sanitizer = inject(DomSanitizer); + + readonly subItems = viewChildren(ShellNavigationSubItemComponent); + + expanded = signal(false); + + readonly item = input.required(); + + sanitizedHtml = computed(() => { + const icon = this.item().icon; + + if (!icon) { + return ''; + } + + return this.#sanitizer.bypassSecurityTrustHtml(icon); + }); + + hasChildren = computed(() => { + return (this.item().childRoutes?.length ?? 0) > 0; + }); + + toggleExpand(): void { + this.expanded.update((value) => !value); + } + + readonly route = computed(() => { + const route = this.item().route; + + if (typeof route === 'function') { + const result = runInInjectionContext(this.#injector, () => route()); + return isSignal(result) ? result() : result; + } + + return route; + }); + + constructor() { + // Auto-expand when a child route becomes active. + // This is a valid effect use case: one-way sync of UI state based on router state. + // It only expands (never auto-collapses), preserving user's manual toggle preference. + effect(() => { + const subItems = this.subItems(); + if (!subItems.length) return; + + const hasActiveChild = subItems.some((subItem) => subItem.isActive()); + + if (hasActiveChild) { + this.expanded.set(true); + } + }); + } +} diff --git a/libs/shell/navigation/src/lib/components/navigation-sub-item/navigation-sub-item.component.css b/libs/shell/navigation/src/lib/components/navigation-sub-item/navigation-sub-item.component.css new file mode 100644 index 000000000..4351f725b --- /dev/null +++ b/libs/shell/navigation/src/lib/components/navigation-sub-item/navigation-sub-item.component.css @@ -0,0 +1,68 @@ +:host { + display: contents; +} + +a { + @apply isa-text-body-2-regular; + transition: + background-color 0.15s ease-out, + font-weight 0.15s ease-out; +} + +a.slide-in { + animation: slideIn 0.2s ease-out backwards; + animation-delay: calc(var(--i, 0) * 50ms); +} + +a.active, +a:hover, +a:focus, +a:active { + @apply bg-isa-neutral-200 isa-text-body-2-semibold; +} + +a .active-bar { + @apply hidden; + transform: scaleX(0); + transform-origin: right; + opacity: 0; +} + +a.active .active-bar { + @apply block; + animation: barReveal 0.25s ease-out forwards; +} + +@keyframes slideIn { + from { + opacity: 0; + transform: translateX(-8px); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +@keyframes barReveal { + from { + transform: scaleX(0); + opacity: 0; + } + to { + transform: scaleX(1); + opacity: 1; + } +} + +@media (prefers-reduced-motion: reduce) { + a.slide-in { + animation: none; + } + + a.active .active-bar { + animation: none; + transform: scaleX(1); + opacity: 1; + } +} diff --git a/libs/shell/navigation/src/lib/components/navigation-sub-item/navigation-sub-item.component.html b/libs/shell/navigation/src/lib/components/navigation-sub-item/navigation-sub-item.component.html new file mode 100644 index 000000000..54f2e603e --- /dev/null +++ b/libs/shell/navigation/src/lib/components/navigation-sub-item/navigation-sub-item.component.html @@ -0,0 +1,21 @@ +@if (route(); as r) { + +
+ {{ label() }} +
+
+
+} diff --git a/libs/shell/navigation/src/lib/components/navigation-sub-item/navigation-sub-item.component.ts b/libs/shell/navigation/src/lib/components/navigation-sub-item/navigation-sub-item.component.ts new file mode 100644 index 000000000..e5c4a90eb --- /dev/null +++ b/libs/shell/navigation/src/lib/components/navigation-sub-item/navigation-sub-item.component.ts @@ -0,0 +1,58 @@ +import { + afterNextRender, + ChangeDetectionStrategy, + Component, + computed, + inject, + Injector, + input, + isSignal, + runInInjectionContext, + signal, + viewChild, +} from '@angular/core'; +import { RouterLink, RouterLinkActive } from '@angular/router'; +import { + NavigationRouteWithLabel, + NavigationRouteWithLabelFn, +} from '../../types'; + +@Component({ + selector: 'shell-navigation-sub-item', + templateUrl: './navigation-sub-item.component.html', + styleUrls: ['./navigation-sub-item.component.css'], + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [RouterLink, RouterLinkActive], +}) +export class ShellNavigationSubItemComponent { + #injector = inject(Injector); + + readonly routerLinkActive = viewChild(RouterLinkActive); + readonly isActive = signal(false); + + constructor() { + afterNextRender(() => { + const rla = this.routerLinkActive(); + if (rla?.isActive) { + this.isActive.set(true); + } + }); + } + + readonly item = input.required< + NavigationRouteWithLabel | NavigationRouteWithLabelFn + >(); + + readonly route = computed(() => { + const item = this.item(); + + if (typeof item === 'function') { + const result = runInInjectionContext(this.#injector, () => item()); + return isSignal(result) ? result() : result; + } + + return item; + }); + + readonly label = computed(() => this.route()?.label ?? ''); +} diff --git a/libs/shell/navigation/src/lib/navigations.ts b/libs/shell/navigation/src/lib/navigations.ts new file mode 100644 index 000000000..6064f729e --- /dev/null +++ b/libs/shell/navigation/src/lib/navigations.ts @@ -0,0 +1,187 @@ +import { + isaNavigationAbholfach, + isaNavigationArtikelsuche, + isaNavigationCalender, + isaNavigationDashboard, + isaNavigationKunden, + isaNavigationRemission1, + isaNavigationReturn, + isaNavigationSortiment, + isaNavigationWarenausgabe, + isaNavigationWareneingang1, +} from '@isa/icons'; +import { injectTabId } from '@isa/core/tabs'; + +import { NavigationGroup, NavigationItem } from './types'; +import { computed } from '@angular/core'; + +export const navigations: Array = [ + // Standalone item with dynamic tabId route + { + type: 'item', + icon: isaNavigationDashboard, + label: 'Dashboard', + route: { + route: '/dashboard', + }, + }, + + // KUNDEN group + { + type: 'group', + label: 'Kunden', + items: [ + { + type: 'item', + icon: isaNavigationArtikelsuche, + label: 'Artikelsuche', + route: () => { + const activeTabId = injectTabId(); + return computed(() => { + const tabId = activeTabId() ?? Date.now(); + return { + route: ['/kunde', tabId, 'product'], + }; + }); + }, + }, + { + type: 'item', + icon: isaNavigationKunden, + label: 'Kunden', + route: () => { + const activeTabId = injectTabId(); + return computed(() => { + const tabId = activeTabId() ?? Date.now(); + return { + route: ['/kunde', tabId, 'customer'], + }; + }); + }, + }, + { + type: 'item', + icon: isaNavigationWarenausgabe, + label: 'Warenausgabe', + route: () => { + const activeTabId = injectTabId(); + return computed(() => { + const tabId = activeTabId() ?? Date.now(); + return { + route: ['/kunde', tabId, 'pickup-shelf'], + }; + }); + }, + }, + { + type: 'item', + icon: isaNavigationReturn, + label: 'Rückgabe', + route: () => { + const activeTabId = injectTabId(); + return computed(() => { + const tabId = activeTabId() ?? Date.now(); + return { + route: ['/', tabId, 'return'], + }; + }); + }, + }, + ], + }, + + // FILIALE group + { + type: 'group', + label: 'Filiale', + items: [ + { + type: 'item', + icon: isaNavigationCalender, + label: 'Kalender', + route: { + route: '/filiale/task-calendar', + }, + }, + { + type: 'item', + icon: isaNavigationSortiment, + label: 'Sortiment', + route: { + route: '/filiale/assortment', + }, + }, + { + type: 'item', + icon: isaNavigationAbholfach, + label: 'Abholfach', + childRoutes: [ + { + label: 'Einbuchen', + route: '/filiale/pickup-shelf', + }, + { + label: 'Reservierung', + route: '/filiale/goods/in/reservation', + queryParams: { view: 'reservation' }, + }, + { + label: 'Ausräumen', + route: '/filiale/goods/in/cleanup', + queryParams: { view: 'cleanup' }, + }, + { + label: 'Remi Vorschau', + route: '/filiale/goods/in/preview', + queryParams: { view: 'remission' }, + }, + { + label: 'Fehlende', + route: '/filiale/goods/in/list', + queryParams: { view: 'wareneingangsliste' }, + }, + ], + }, + { + type: 'item', + icon: isaNavigationRemission1, + label: 'Remission', + childRoutes: [ + () => { + const activeTabId = injectTabId(); + return computed(() => { + const tabId = activeTabId() ?? Date.now(); + return { + label: 'Remission', + route: ['/', tabId, 'remission'], + }; + }); + }, + () => { + const activeTabId = injectTabId(); + return computed(() => { + const tabId = activeTabId() ?? Date.now(); + return { + label: 'Warenbegleitscheine', + route: ['/', tabId, 'remission', 'return-receipt'], + }; + }); + }, + ], + }, + { + type: 'item', + icon: isaNavigationWareneingang1, + label: 'Wareneingang', + route: { + route: '/filiale/package-inspection/packages', + queryParams: { + filter_status: '0;8', + filter_zeitraum: '"2025-11-26T23:00:00Z"-"2025-12-04T23:00:00Z"', + filter_lieferant: 'libri;knv%7Cknvbs', + }, + }, + }, + ], + }, +]; diff --git a/libs/shell/navigation/src/lib/shell-navigation/shell-navigation.component.css b/libs/shell/navigation/src/lib/shell-navigation.component.css similarity index 100% rename from libs/shell/navigation/src/lib/shell-navigation/shell-navigation.component.css rename to libs/shell/navigation/src/lib/shell-navigation.component.css diff --git a/libs/shell/navigation/src/lib/shell-navigation.component.html b/libs/shell/navigation/src/lib/shell-navigation.component.html new file mode 100644 index 000000000..5c5a4f3aa --- /dev/null +++ b/libs/shell/navigation/src/lib/shell-navigation.component.html @@ -0,0 +1,16 @@ + diff --git a/libs/shell/navigation/src/lib/shell-navigation.component.ts b/libs/shell/navigation/src/lib/shell-navigation.component.ts new file mode 100644 index 000000000..d77a3bdb2 --- /dev/null +++ b/libs/shell/navigation/src/lib/shell-navigation.component.ts @@ -0,0 +1,14 @@ +import { Component } from '@angular/core'; +import { navigations } from './navigations'; +import { ShellNavigationGroupComponent } from './components/navigation-group/navigation-group.component'; +import { ShellNavigationItemComponent } from './components/navigation-item/navigation-item.component'; + +@Component({ + selector: 'shell-navigation', + imports: [ShellNavigationGroupComponent, ShellNavigationItemComponent], + templateUrl: './shell-navigation.component.html', + styleUrl: './shell-navigation.component.css', +}) +export class ShellNavigationComponent { + readonly navigations = navigations; +} diff --git a/libs/shell/navigation/src/lib/shell-navigation/shell-navigation.component.html b/libs/shell/navigation/src/lib/shell-navigation/shell-navigation.component.html deleted file mode 100644 index 32dca0864..000000000 --- a/libs/shell/navigation/src/lib/shell-navigation/shell-navigation.component.html +++ /dev/null @@ -1 +0,0 @@ -

ShellNavigation works!

diff --git a/libs/shell/navigation/src/lib/shell-navigation/shell-navigation.component.spec.ts b/libs/shell/navigation/src/lib/shell-navigation/shell-navigation.component.spec.ts deleted file mode 100644 index dc4824af6..000000000 --- a/libs/shell/navigation/src/lib/shell-navigation/shell-navigation.component.spec.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { ShellNavigationComponent } from './shell-navigation.component'; - -describe('ShellNavigationComponent', () => { - let component: ShellNavigationComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [ShellNavigationComponent], - }).compileComponents(); - - fixture = TestBed.createComponent(ShellNavigationComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/libs/shell/navigation/src/lib/shell-navigation/shell-navigation.component.ts b/libs/shell/navigation/src/lib/shell-navigation/shell-navigation.component.ts deleted file mode 100644 index f17b10e38..000000000 --- a/libs/shell/navigation/src/lib/shell-navigation/shell-navigation.component.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Component } from '@angular/core'; -import { CommonModule } from '@angular/common'; - -@Component({ - selector: 'shell-shell-navigation', - imports: [CommonModule], - templateUrl: './shell-navigation.component.html', - styleUrl: './shell-navigation.component.css', -}) -export class ShellNavigationComponent {} diff --git a/libs/shell/navigation/src/lib/types.ts b/libs/shell/navigation/src/lib/types.ts new file mode 100644 index 000000000..bcca4a0e4 --- /dev/null +++ b/libs/shell/navigation/src/lib/types.ts @@ -0,0 +1,33 @@ +import { Signal } from '@angular/core'; +import { Params, QueryParamsHandling, UrlTree } from '@angular/router'; + +export type NavigationRoute = { + route: string | unknown[] | UrlTree; + queryParams?: Params; + queryParamsHandling?: QueryParamsHandling; +}; + +export type NavigationRouteWithLabel = NavigationRoute & { + label: string; +}; + +export type NavigationRouteFn = () => NavigationRoute | Signal; + +export type NavigationRouteWithLabelFn = () => + | NavigationRouteWithLabel + | Signal; + +export type NavigationItem = { + type: 'item'; + icon?: string; + label: string; + + route?: NavigationRoute | NavigationRouteFn; + childRoutes?: Array; +}; + +export type NavigationGroup = { + type: 'group'; + label: string; + items: Array; +};