Merged PR 1867: #4769 Remi 3.0 - Remission – Scannen und Suchen

- feat(remission-list): Zwischencommit
- feat(ui-input-controls): Adjusted Dropdown Styling and Added Droption Option Disable Class, Refs: #4769
- feat(remission): implement remission list feature shell and category select
- Merge branch 'develop' into feature/4769-Remission-Liste
This commit is contained in:
Nino Righi
2025-06-23 15:23:54 +00:00
committed by Lorenz Hilpert
parent 4cf0ce820e
commit d53540b8db
36 changed files with 567 additions and 25 deletions

View File

@@ -1 +1,3 @@
export * from './lib/services';
export * from './lib/models';
export * from './lib/helpers';

View File

@@ -0,0 +1,74 @@
import {
RemissionListCategoryKey,
RemissionListCategoryRoute,
RemissionListCategoryRouteValue,
} from '../models';
import { getRemissionListCategoryKeyFromRoute } from './get-remission-list-category-key-from-route.helper';
describe('getRemissionListCategoryKeyFromRoute', () => {
it('should return the correct key for each valid route value', () => {
// Arrange & Act & Assert
(
Object.keys(RemissionListCategoryRoute) as RemissionListCategoryKey[]
).forEach((key) => {
const routeValue = RemissionListCategoryRoute[key];
const result = getRemissionListCategoryKeyFromRoute(routeValue);
expect(result).toBe(key);
});
});
it('should return undefined for an invalid route value', () => {
// Arrange
const invalidRoute = 'not-a-real-route' as RemissionListCategoryRouteValue;
// Act
const result = getRemissionListCategoryKeyFromRoute(invalidRoute);
// Assert
expect(result).toBeUndefined();
});
it('should return undefined for undefined input', () => {
// Act
const result = getRemissionListCategoryKeyFromRoute(
undefined as unknown as RemissionListCategoryRouteValue,
);
// Assert
expect(result).toBeUndefined();
});
it('should return undefined for null input', () => {
// Act
const result = getRemissionListCategoryKeyFromRoute(
null as unknown as RemissionListCategoryRouteValue,
);
// Assert
expect(result).toBeUndefined();
});
it('should not throw for any input type', () => {
// Arrange
const inputs = [
undefined,
null,
123,
{},
[],
'',
Symbol('sym'),
true,
false,
];
// Act & Assert
inputs.forEach((input) => {
expect(() =>
getRemissionListCategoryKeyFromRoute(
input as RemissionListCategoryRouteValue,
),
).not.toThrow();
});
});
});

View File

@@ -0,0 +1,13 @@
import { RemissionListCategoryKey } from '../models';
import {
RemissionListCategoryRoute,
RemissionListCategoryRouteValue,
} from '../models/remission-list-category-routes';
export const getRemissionListCategoryKeyFromRoute = (
route: RemissionListCategoryRouteValue,
): RemissionListCategoryKey | undefined => {
return (
Object.keys(RemissionListCategoryRoute) as RemissionListCategoryKey[]
).find((key) => RemissionListCategoryRoute[key] === route);
};

View File

@@ -0,0 +1 @@
export * from './get-remission-list-category-key-from-route.helper';

View File

@@ -1,3 +1,5 @@
export * from './remission-list-category';
export * from './remission-list-category-routes';
export * from './price-value';
export * from './price';
export * from './product';

View File

@@ -0,0 +1,10 @@
export const RemissionListCategoryRoute = {
Pflicht: '',
Abteilung: 'department',
Koerperlos: 'bodyless',
} as const;
export type RemissionListCategoryRouteKey =
keyof typeof RemissionListCategoryRoute;
export type RemissionListCategoryRouteValue =
(typeof RemissionListCategoryRoute)[RemissionListCategoryRouteKey];

View File

@@ -0,0 +1,13 @@
export const RemissionListCategory = {
Pflicht: 'Pflichtremission',
Abteilung: 'Abteilungsremission',
Koerperlos: 'Körperlose Remi',
} as const;
export type RemissionListCategoryKey = keyof typeof RemissionListCategory;
export const RemissionListCategoryKeys = Object.keys(
RemissionListCategory,
) as RemissionListCategoryKey[];
export type RemissionListCategory =
(typeof RemissionListCategory)[keyof typeof RemissionListCategory];

View File

@@ -0,0 +1,3 @@
export * from './remission-instock.service';
export * from './remission-product-group.service';
export * from './remission-search.service';

View File

@@ -0,0 +1,6 @@
import { Injectable } from '@angular/core';
@Injectable({ providedIn: 'root' })
export class RemissionInstockService {
constructor() {}
}

View File

@@ -0,0 +1,6 @@
import { Injectable } from '@angular/core';
@Injectable({ providedIn: 'root' })
export class RemissionProductGroupService {
constructor() {}
}

View File

@@ -0,0 +1,33 @@
import { Injectable } from '@angular/core';
import { RemissionListCategory, RemissionListCategoryKey } from '../models';
import {
RemissionListCategoryRoute,
RemissionListCategoryRouteValue,
} from '../models/remission-list-category-routes';
@Injectable({ providedIn: 'root' })
export class RemissionSearchService {
// 2xSettings + Pflicht + Abteilungsremi
remissionListCategories(): {
key: RemissionListCategoryKey;
value: RemissionListCategory;
route: RemissionListCategoryRouteValue;
}[] {
return (
Object.keys(RemissionListCategory) as RemissionListCategoryKey[]
).map((key) => ({
key,
value: RemissionListCategory[key],
route: RemissionListCategoryRoute[key],
}));
}
fetchList(): Promise<unknown> {
return new Promise((resolve) => {
setTimeout(() => {
resolve({ data: 'Remission list data' });
}, 1000);
});
}
}

View File

@@ -0,0 +1,9 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
@Component({
selector: 'remission-feature-remission-list-item',
templateUrl: './remission-list-item.component.html',
styleUrl: './remission-list-item.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class RemissionListItemComponent {}

View File

@@ -0,0 +1,17 @@
<ui-dropdown
class="remission-feature-remission-list-select__dropdown"
[value]="selectedCategoryKey()"
[appearance]="DropdownAppearance.Grey"
(valueChange)="changeRemissionCategory($event)"
data-which="remission-list-select-dropdown"
[attr.data-what]="`remission-list-selected-value-${selectedCategoryKey()}`"
>
@for (kv of remissionListCategories; track kv.key) {
<ui-dropdown-option
[attr.data-what]="`remission-list-option-${kv.value}`"
[disabled]="kv.value === RemissionListCategory.Koerperlos"
[value]="kv.key"
>{{ kv.value }}</ui-dropdown-option
>
}
</ui-dropdown>

View File

@@ -0,0 +1,62 @@
import {
ChangeDetectionStrategy,
Component,
inject,
computed,
} from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { ActivatedRoute, Router } from '@angular/router';
import {
getRemissionListCategoryKeyFromRoute,
RemissionListCategory,
RemissionListCategoryKey,
RemissionListCategoryKeys,
RemissionListCategoryRoute,
RemissionListCategoryRouteValue,
RemissionSearchService,
} from '@isa/remission/data-access';
import {
DropdownAppearance,
DropdownButtonComponent,
DropdownOptionComponent,
} from '@isa/ui/input-controls';
@Component({
selector: 'remission-feature-remission-list-select',
templateUrl: './remission-list-select.component.html',
styleUrl: './remission-list-select.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [DropdownButtonComponent, DropdownOptionComponent],
})
export class RemissionListSelectComponent {
DropdownAppearance = DropdownAppearance;
RemissionListCategory = RemissionListCategory;
remissionSearchService = inject(RemissionSearchService);
router = inject(Router);
route = inject(ActivatedRoute);
routeUrl = toSignal(this.route.url);
remissionListCategories =
this.remissionSearchService.remissionListCategories();
selectedCategoryKey = computed(() => {
const url = this.routeUrl();
const path = url?.map((segment) => segment.path).join('/') ?? '';
const defaultPath = RemissionListCategoryKeys[0]; // Default Path = 'Pflicht'
return (
getRemissionListCategoryKeyFromRoute(
path as RemissionListCategoryRouteValue,
) ?? defaultPath
);
});
async changeRemissionCategory(key: RemissionListCategoryKey | undefined) {
if (!key) return;
const route = RemissionListCategoryRoute[key];
if (route) {
await this.router.navigate([route], { relativeTo: this.route });
} else {
await this.router.navigate(['..'], { relativeTo: this.route });
}
}
}

View File

@@ -0,0 +1,12 @@
<remission-feature-remission-start-card></remission-feature-remission-start-card>
<remission-feature-remission-list-select></remission-feature-remission-list-select>
<filter-controls-panel (triggerSearch)="search()"></filter-controls-panel>
<span
class="text-isa-neutral-900 isa-text-body-2-regular self-start"
data-what="result-count"
>
0 Einträge
</span>

View File

@@ -0,0 +1,70 @@
import {
ChangeDetectionStrategy,
Component,
inject,
effect,
} from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import {
provideFilter,
withQuerySettingsFactory,
withQueryParamsSync,
FilterControlsPanelComponent,
FilterService,
} from '@isa/shared/filter';
import { injectRestoreScrollPosition } from '@isa/utils/scroll-position';
import { createRemissionResource } from './resources/remission-list.resource';
import { RemissionStartCardComponent } from './remission-start-card/remission-start-card.component';
import { RemissionListSelectComponent } from './remission-list-select/remission-list-select.component';
function querySettingsFactory() {
return inject(ActivatedRoute).snapshot.data['querySettings'];
}
@Component({
selector: 'remission-feature-remission-list',
templateUrl: './remission-list.component.html',
styleUrl: './remission-list.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [
provideFilter(
withQuerySettingsFactory(querySettingsFactory),
withQueryParamsSync(),
),
],
imports: [
RemissionStartCardComponent,
FilterControlsPanelComponent,
RemissionListSelectComponent,
],
host: {
'[class]':
'"w-full flex flex-col gap-4 mt-5 isa-desktop:mt-6 overflow-x-hidden"',
},
})
export class RemissionListComponent {
/** Service for managing filters and search queries */
#filterService = inject(FilterService);
/** Utility for restoring scroll position when returning to this view */
restoreScrollPosition = injectRestoreScrollPosition();
remissionResource = createRemissionResource(() => 2);
constructor() {
effect(() => {
const res = this.remissionResource;
const status = res.status();
console.log('Remission Resource Status:', status, res.value());
});
}
/**
* Initiates a search operation with the current filter settings
* Navigates directly to the receipt if only one result is found
*/
search() {
this.#filterService.commit();
console.log(this.#filterService.query());
}
}

View File

@@ -0,0 +1,20 @@
<div class="remission-feature-remission-start-card__title-container">
<h2 class="isa-text-subtitle-1-regular">Remission</h2>
<p class="isa-text-body-1-regular">
Starten Sie die Remission indem Sie den Warenbegleitschein erstellen.
Anschließend können Sie Artikel hinzufügen.
</p>
</div>
<button
class="remission-feature-remission-start-card__start-cta"
data-which="start-remission"
data-what="start-remission"
uiButton
color="brand"
size="large"
(click)="startRemission()"
>
Starten
</button>

View File

@@ -0,0 +1,11 @@
:host {
@apply w-full flex flex-row gap-6 rounded-2xl bg-isa-neutral-400 p-6 justify-between;
}
.remission-feature-remission-start-card__title-container {
@apply flex flex-col gap-4 text-isa-neutral-900;
}
.remission-feature-remission-start-card__start-cta {
@apply h-12 w-40 justify-self-end;
}

View File

@@ -0,0 +1,15 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { ButtonComponent } from '@isa/ui/buttons';
@Component({
selector: 'remission-feature-remission-start-card',
templateUrl: './remission-start-card.component.html',
styleUrl: './remission-start-card.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ButtonComponent],
})
export class RemissionStartCardComponent {
startRemission() {
console.log('Start');
}
}

View File

@@ -1,14 +0,0 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { RouterOutlet } from '@angular/router';
@Component({
selector: 'remission-feature-remission-list',
template: `<router-outlet></router-outlet>`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [RouterOutlet],
host: {
'[class]':
'"flex flex-col gap-5 isa-desktop:gap-6 items-center overflow-x-hidden"',
},
})
export class RemissionListComponent {}

View File

@@ -0,0 +1,11 @@
import { inject } from '@angular/core';
import { ResolveFn } from '@angular/router';
import { QuerySettingsDTO } from '@generated/swagger/oms-api';
import { ReturnSearchService } from '@isa/oms/data-access';
export const querySettingsDepartmentResolverFn: ResolveFn<
QuerySettingsDTO
> = () => {
console.log('department');
return inject(ReturnSearchService).querySettings();
};

View File

@@ -0,0 +1,9 @@
import { inject } from '@angular/core';
import { ResolveFn } from '@angular/router';
import { QuerySettingsDTO } from '@generated/swagger/oms-api';
import { ReturnSearchService } from '@isa/oms/data-access';
export const querySettingsResolverFn: ResolveFn<QuerySettingsDTO> = () => {
console.log('pflicht');
return inject(ReturnSearchService).querySettings();
};

View File

@@ -0,0 +1,17 @@
import { inject, resource } from '@angular/core';
import { RemissionSearchService } from '@isa/remission/data-access';
export const createRemissionResource = (params: () => number) => {
const remissionSearchService = inject(RemissionSearchService);
return resource({
params,
loader: async ({ abortSignal, params }) => {
if (!params) {
return undefined;
}
return await remissionSearchService.fetchList();
//
},
});
};

View File

@@ -1,9 +1,19 @@
import { Routes } from '@angular/router';
import { RemissionListComponent } from './resmission-list.component';
import { RemissionListComponent } from './remission-list.component';
import { querySettingsResolverFn } from './resolvers/query-settings.resolver-fn';
import { querySettingsDepartmentResolverFn } from './resolvers/query-settings-department.resolver-fn';
export const routes: Routes = [
{
path: '',
component: RemissionListComponent,
resolve: { querySettings: querySettingsResolverFn },
data: { scrollPositionRestoration: true },
},
{
path: 'department',
component: RemissionListComponent,
resolve: { querySettings: querySettingsDepartmentResolverFn },
data: { scrollPositionRestoration: true },
},
];

View File

@@ -5,3 +5,4 @@ export * from './lib/actions';
export * from './lib/menus/filter-menu';
export * from './lib/menus/input-menu';
export * from './lib/order-by';
export * from './lib/controls-panel';

View File

@@ -0,0 +1,38 @@
<div class="w-full flex flex-row justify-between items-start">
<filter-search-bar-input
class="flex flex-row gap-4 h-12"
[appearance]="'results'"
inputKey="qs"
(triggerSearch)="triggerSearch.emit()"
data-what="search-input"
></filter-search-bar-input>
<div class="flex flex-row gap-4 items-center">
<filter-filter-menu-button
(applied)="triggerSearch.emit()"
[rollbackOnClose]="true"
></filter-filter-menu-button>
@if (mobileBreakpoint()) {
<ui-icon-button
type="button"
(click)="showOrderByToolbarMobile.set(!showOrderByToolbarMobile())"
[class.active]="showOrderByToolbarMobile()"
data-what="sort-button-mobile"
name="isaActionSort"
></ui-icon-button>
} @else {
<filter-order-by-toolbar
(toggled)="triggerSearch.emit()"
></filter-order-by-toolbar>
}
</div>
</div>
@if (mobileBreakpoint() && showOrderByToolbarMobile()) {
<filter-order-by-toolbar
class="w-full"
(toggled)="triggerSearch.emit()"
data-what="sort-toolbar-mobile"
></filter-order-by-toolbar>
}

View File

@@ -0,0 +1,3 @@
.filter-controls-panel {
@apply flex flex-col gap-4;
}

View File

@@ -0,0 +1,45 @@
import {
ChangeDetectionStrategy,
Component,
linkedSignal,
output,
ViewEncapsulation,
} from '@angular/core';
import { SearchBarInputComponent } from '../inputs';
import { FilterMenuButtonComponent } from '../menus/filter-menu';
import { provideIcons } from '@ng-icons/core';
import { isaActionFilter, isaActionSort } from '@isa/icons';
import { IconButtonComponent } from '@isa/ui/buttons';
import { OrderByToolbarComponent } from '../order-by';
import { Breakpoint, breakpoint } from '@isa/ui/layout';
@Component({
selector: 'filter-controls-panel',
templateUrl: './controls-panel.component.html',
styleUrls: ['./controls-panel.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
standalone: true,
imports: [
SearchBarInputComponent,
FilterMenuButtonComponent,
IconButtonComponent,
OrderByToolbarComponent,
],
host: {
'[class]': "['filter-controls-panel']",
},
providers: [provideIcons({ isaActionSort, isaActionFilter })],
})
export class FilterControlsPanelComponent {
triggerSearch = output<void>();
/** Signal tracking whether the viewport is at tablet size or above */
mobileBreakpoint = breakpoint([Breakpoint.Tablet]);
/**
* Signal controlling the visibility of the order-by toolbar on mobile
* Initially shows toolbar when NOT on mobile
*/
showOrderByToolbarMobile = linkedSignal(() => !this.mobileBreakpoint());
}

View File

@@ -0,0 +1 @@
export * from './controls-panel.component';

View File

@@ -8,7 +8,11 @@ import {
output,
} from '@angular/core';
import { FilterInput, FilterService } from '../../core';
import { Overlay, CdkOverlayOrigin, CdkConnectedOverlay } from '@angular/cdk/overlay';
import {
Overlay,
CdkOverlayOrigin,
CdkConnectedOverlay,
} from '@angular/cdk/overlay';
import { NgIconComponent, provideIcons } from '@ng-icons/core';
import { isaActionChevronDown, isaActionChevronUp } from '@isa/icons';
import { FilterInputMenuComponent } from './input-menu.component';
@@ -23,7 +27,12 @@ import { FilterInputMenuComponent } from './input-menu.component';
templateUrl: './input-menu-button.component.html',
styleUrls: ['./input-menu-button.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [NgIconComponent, FilterInputMenuComponent, CdkOverlayOrigin, CdkConnectedOverlay],
imports: [
NgIconComponent,
FilterInputMenuComponent,
CdkOverlayOrigin,
CdkConnectedOverlay,
],
providers: [provideIcons({ isaActionChevronDown, isaActionChevronUp })],
})
export class FilterInputMenuButtonComponent {

View File

@@ -49,12 +49,13 @@
@apply bg-isa-neutral-500;
}
&:active {
@apply border-isa-accent-blue text-isa-accent-blue bg-isa-white;
&:active,
&.has-value {
@apply border-isa-accent-blue text-isa-accent-blue;
}
&.open {
@apply border-isa-neutral-900 text-isa-neutral-900;
@apply border-isa-neutral-900 text-isa-neutral-900 bg-isa-white;
}
}
@@ -95,5 +96,15 @@
&.selected {
@apply text-isa-accent-blue;
}
&.disabled {
@apply text-isa-neutral-400;
&.active,
&:focus,
&:hover {
@apply bg-isa-white;
}
}
}
}

View File

@@ -7,6 +7,7 @@ import {
effect,
ElementRef,
inject,
Input,
input,
model,
signal,
@@ -24,8 +25,9 @@ import { DropdownAppearance } from './dropdown.types';
selector: 'ui-dropdown-option',
template: '<ng-content></ng-content>',
host: {
'[class]': '["ui-dropdown-option", activeClass(), selectedClass()]',
role: 'option',
'[class]':
'["ui-dropdown-option", activeClass(), selectedClass(), disabledClass()]',
'role': 'option',
'[attr.aria-selected]': 'selected()',
'[attr.tabindex]': '-1',
'(click)': 'select()',
@@ -37,6 +39,19 @@ export class DropdownOptionComponent<T> implements Highlightable {
active = signal(false);
readonly _disabled = signal<boolean>(false);
@Input()
get disabled(): boolean {
return this._disabled();
}
set disabled(value: boolean) {
this._disabled.set(value);
}
disabledClass = computed(() => (this.disabled ? 'disabled' : ''));
activeClass = computed(() => (this.active() ? 'active' : ''));
setActiveStyles(): void {
@@ -62,6 +77,9 @@ export class DropdownOptionComponent<T> implements Highlightable {
value = input.required<T>();
select() {
if (this.disabled) {
return;
}
this.host.select(this);
this.host.close();
}
@@ -83,8 +101,8 @@ export class DropdownOptionComponent<T> implements Highlightable {
],
host: {
'[class]':
'["ui-dropdown", appearanceClass(), isOpenClass(), disabledClass()]',
role: 'combobox',
'["ui-dropdown", appearanceClass(), isOpenClass(), disabledClass(), valueClass()]',
'role': 'combobox',
'aria-haspopup': 'listbox',
'[attr.id]': 'id()',
'[attr.tabindex]': 'disabled() ? -1 : tabIndex()',
@@ -112,6 +130,8 @@ export class DropdownButtonComponent<T>
disabledClass = computed(() => (this.disabled() ? 'disabled' : ''));
valueClass = computed(() => (this.value() ? 'has-value' : ''));
id = input<string>();
value = model<T>();
@@ -173,7 +193,9 @@ export class DropdownButtonComponent<T>
this.keyManger?.destroy();
this.keyManger = new ActiveDescendantKeyManager<
DropdownOptionComponent<T>
>(this.options()).withWrap();
>(this.options())
.withWrap()
.skipPredicate((option) => option.disabled);
});
}