Compare commits

...

13 Commits

Author SHA1 Message Date
Nino Righi
a086111ab5 Merged PR 1966: Adjustments for #5320, #5360, #5361
Adjustments for #5320, #5360, #5361
2025-10-06 19:02:45 +00:00
Nino Righi
15a4718e58 Merged PR 1965: feat(remission-list): improve item update handling and UI feedback
feat(remission-list): improve item update handling and UI feedback

Enhance the remission list item management by introducing a more robust
update mechanism that tracks both item removal and impediment updates.
Previously, the component only tracked deletion progress, but now it
handles both deletion and update scenarios, allowing for better state
management and user feedback.

Key changes:
- Replace simple inProgress boolean with UpdateItem interface containing
  inProgress state, itemId, and optional impediment
- Update local items signal directly when items are removed or updated,
  eliminating unnecessary API calls and improving performance
- Add visual highlight to "Remi Menge ändern" button when dialog is open
  using a border style for better accessibility
- Improve error handling by tracking specific item operations
- Ensure selected items are properly removed from store when deleted
  or updated

The new approach optimizes list reloads by only fetching data when
necessary and provides clearer visual feedback during item operations.

Unit Tests updated also

Ref: #5361
2025-10-06 08:41:47 +00:00
Nino Righi
40592b4477 Merged PR 1964: feat(shared-filter): add canApply input to filter input menu components
feat(shared-filter): add canApply input to filter input menu components

Add canApply input parameter to FilterInputMenuButtonComponent and FilterInputMenuComponent to control when filter actions can be applied. Update RemissionListDepartmentElementsComponent to use canApply flag and implement rollback functionality when filter menu is closed without applying changes.

- Add canApply input to FilterInputMenuButtonComponent with default false
- Pass canApply parameter through to FilterInputMenuComponent
- Update remission department filter to use canApply=true
- Implement rollbackFilterInput method for filter state management
- Change selectedDepartments to selectedDepartment for single selection
- Update capacity resource to work with single department selection

Ref: #5320
2025-10-06 08:41:22 +00:00
Nino Righi
d430f544f0 Merged PR 1963: feat(utils): add scroll-top button component
feat(utils): add scroll-top button component

Add a reusable ScrollTopButtonComponent that provides smooth scrolling
to the top of a page or specific element. The component automatically
shows/hides based on scroll position and respects user's reduced motion
preferences.

Key features:
- Supports both window and element-specific scrolling
- Configurable position with sensible defaults
- Accessibility compliant with proper aria-label
- Respects prefers-reduced-motion media query
- Debounced scroll event handling for performance

Integrate the component into remission list and search dialog
components to improve user navigation experience.

Ref: #5360
2025-10-06 08:41:08 +00:00
Nino Righi
62e586cfda Merged PR 1951: fix(remission-list): ensure list reload after search dialog closes
fix(remission-list): ensure list reload after search dialog closes

Move reloadListAndReturnData() call outside the conditional block
to guarantee data refresh regardless of dialog result. Previously,
the list would only reload when items were selected, causing stale
data when the dialog was cancelled or closed without selection.

Ref: #5342
2025-09-16 12:41:05 +00:00
Nino Righi
304f8a64e5 Merged PR 1949: feat(isa-app): migrate remission navigation to tab-based routing system
feat(isa-app): migrate remission navigation to tab-based routing system

Replace hardcoded /filiale/remission routes with dynamic tab-based paths
using TabService. This enables proper process isolation and multi-tab
support for remission workflows.

Changes include:
- Update notification component to use dynamic remission paths
- Migrate goods-in remission preview to tab-based navigation
- Refactor side menu to use new remission routing structure
- Remove legacy remission route from app routing module
- Add linkedSignal for reactive path generation

BREAKING CHANGE: Direct navigation to /filiale/remission is no longer supported.
Users must access remission through the new tab-based system.

Ref: #5323, #5324, #5325
2025-09-15 13:11:47 +00:00
Nino Righi
c672ae4012 Merged PR 1948: fix(remission-error): simplify error handling in remission components
fix(remission-error): simplify error handling in remission components

Refactor error handling to use consistent error message extraction pattern.
Remove dependency on ResponseArgsError type and streamline error processing
in both RemissionListComponent and RemissionReturnReceiptDetailsItemComponent.
Extract error handling logic into separate methods for better maintainability.

Ref: #5331
2025-09-12 10:15:13 +00:00
Nino Righi
fd693a4beb Merged PR 1947: #5331 Set correct Prototype
#5331 Set correct Prototype
2025-09-11 15:42:55 +00:00
Nino Righi
2c70339f23 Merged PR 1945: fix(remission-list): auto-select single search result when remission started
fix(remission-list): auto-select single search result when remission started

Enhance search result handling to automatically select items when only
one result is found during an active remission. This improves user
workflow by eliminating the extra click required to select obvious
single results.

- Add preselectRemissionItem method to handle automatic selection
- Update emptySearchResultEffect to handle single hit scenario
- Clear selected items at start of effect to prevent stale selections
- Only auto-select if item has available stock and can be remitted
- Improve effect documentation with detailed behavior explanation

Ref: #5338
2025-09-11 14:21:19 +00:00
Nino Righi
59f0cc7d43 Merged PR 1946: fix(remission-list, remission-return-receipt-details, libs-dialog): improve error handling with dedicated error dialog
fix(remission-list, remission-return-receipt-details, libs-dialog): improve error handling with dedicated error dialog

- Add RemissionResponseArgsErrorMessage constants for standardized error messages
- Create FeedbackErrorDialogComponent for consistent error display across the app
- Implement enhanced error handling in RemissionListComponent.handleRemitItemsError()
- Update RemissionReturnReceiptDetailsItemComponent to use new error dialog pattern
- Add injectFeedbackErrorDialog convenience function for easy error dialog injection
- Include comprehensive unit tests for new dialog component
- Replace generic error handling with specific ResponseArgsError handling
- Clear remission state when "AlreadyCompleted" error occurs

The new error dialog provides a standardized way to display backend error
messages to users with consistent styling and behavior. Error handling now
properly differentiates between different error types and takes appropriate
actions like clearing state for completed remissions.

Ref: #5331
2025-09-11 14:06:14 +00:00
Nino Righi
0ca58fe1bf Merged PR 1942: feat(remission-list, search-item-to-remit-dialog): simplify dialog flow by re...
feat(remission-list, search-item-to-remit-dialog): simplify dialog flow by removing conditional views

Refactor the search item to remit dialog to use a dedicated quantity and reason
dialog instead of conditional views within the main dialog. This change improves
user experience by providing clearer navigation and better separation of concerns.

Key changes:
- Remove item signal and conditional template logic from SearchItemToRemitDialogComponent
- Create new SelectRemiQuantityAndReasonDialogComponent for quantity/reason selection
- Update SearchItemToRemitComponent to open quantity dialog instead of setting item state
- Simplify dialog data interface by removing isDepartment property
- Improve stock filtering logic to show only items with available stock
- Fix import path for QuantityAndReason interface

This refactor eliminates complex state management within the dialog and provides
a more intuitive user flow with dedicated dialogs for each step.

Ref: #5326
2025-09-10 14:18:17 +00:00
Nino
8cf80a60a0 Merge branch 'develop' into release/4.1 2025-09-05 08:19:36 +02:00
Nino
2cb1f9ec99 chore(azure-pipelines): Version bump 4.1 2025-08-14 17:09:13 +02:00
52 changed files with 1218 additions and 585 deletions

View File

@@ -153,12 +153,12 @@ const routes: Routes = [
import('@page/goods-in').then((m) => m.GoodsInModule),
canActivate: [CanActivateGoodsInGuard],
},
{
path: 'remission',
loadChildren: () =>
import('@page/remission').then((m) => m.PageRemissionModule),
canActivate: [CanActivateRemissionGuard],
},
// {
// path: 'remission',
// loadChildren: () =>
// import('@page/remission').then((m) => m.PageRemissionModule),
// canActivate: [CanActivateRemissionGuard],
// },
{
path: 'package-inspection',
loadChildren: () =>

View File

@@ -1,10 +1,18 @@
<div class="notification-list scroll-bar">
@for (notification of notifications; track notification) {
<modal-notifications-list-item [item]="notification" (itemSelected)="itemSelected($event)"></modal-notifications-list-item>
<modal-notifications-list-item
[item]="notification"
(itemSelected)="itemSelected($event)"
></modal-notifications-list-item>
<hr />
}
</div>
<div class="actions">
<a class="cta-primary" [routerLink]="['/filiale/remission/create']" (click)="navigated.emit()">Zur Remission</a>
<a
class="cta-primary"
[routerLink]="remissionPath()"
(click)="navigated.emit()"
>Zur Remission</a
>
</div>

View File

@@ -1,8 +1,17 @@
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output, inject } from '@angular/core';
import {
ChangeDetectionStrategy,
Component,
EventEmitter,
Input,
Output,
inject,
linkedSignal,
} from '@angular/core';
import { Router } from '@angular/router';
import { PickupShelfInNavigationService } from '@shared/services/navigation';
import { UiFilter } from '@ui/filter';
import { MessageBoardItemDTO } from '@hub/notifications';
import { TabService } from '@isa/core/tabs';
@Component({
selector: 'modal-notifications-remission-group',
@@ -11,7 +20,10 @@ import { MessageBoardItemDTO } from '@hub/notifications';
standalone: false,
})
export class ModalNotificationsRemissionGroupComponent {
private _pickupShelfInNavigationService = inject(PickupShelfInNavigationService);
tabService = inject(TabService);
private _pickupShelfInNavigationService = inject(
PickupShelfInNavigationService,
);
@Input()
notifications: MessageBoardItemDTO[];
@@ -19,11 +31,19 @@ export class ModalNotificationsRemissionGroupComponent {
@Output()
navigated = new EventEmitter<void>();
remissionPath = linkedSignal(() => [
'/',
this.tabService.activatedTab()?.id || this.tabService.nextId(),
'remission',
]);
constructor(private _router: Router) {}
itemSelected(item: MessageBoardItemDTO) {
const defaultNav = this._pickupShelfInNavigationService.listRoute();
const queryParams = UiFilter.getQueryParamsFromQueryTokenDTO(item.queryToken);
const queryParams = UiFilter.getQueryParamsFromQueryTokenDTO(
item.queryToken,
);
this._router.navigate(defaultNav.path, {
queryParams: {
...defaultNav.queryParams,

View File

@@ -16,13 +16,34 @@
[deltaEnd]="150"
[itemLength]="itemLength$ | async"
[containerHeight]="24.5"
>
@for (bueryNumberGroup of items$ | async | groupBy: byBuyerNumberFn; track bueryNumberGroup) {
>
@for (
bueryNumberGroup of items$ | async | groupBy: byBuyerNumberFn;
track bueryNumberGroup
) {
<shared-goods-in-out-order-group>
@for (orderNumberGroup of bueryNumberGroup.items | groupBy: byOrderNumberFn; track orderNumberGroup; let lastOrderNumber = $last) {
@for (processingStatusGroup of orderNumberGroup.items | groupBy: byProcessingStatusFn; track processingStatusGroup; let lastProcessingStatus = $last) {
@for (compartmentCodeGroup of processingStatusGroup.items | groupBy: byCompartmentCodeFn; track compartmentCodeGroup; let lastCompartmentCode = $last) {
@for (item of compartmentCodeGroup.items; track item; let firstItem = $first) {
@for (
orderNumberGroup of bueryNumberGroup.items | groupBy: byOrderNumberFn;
track orderNumberGroup;
let lastOrderNumber = $last
) {
@for (
processingStatusGroup of orderNumberGroup.items
| groupBy: byProcessingStatusFn;
track processingStatusGroup;
let lastProcessingStatus = $last
) {
@for (
compartmentCodeGroup of processingStatusGroup.items
| groupBy: byCompartmentCodeFn;
track compartmentCodeGroup;
let lastCompartmentCode = $last
) {
@for (
item of compartmentCodeGroup.items;
track item;
let firstItem = $first
) {
<shared-goods-in-out-order-group-item
[item]="item"
[showCompartmentCode]="firstItem"
@@ -49,7 +70,6 @@
<div class="empty-message">Es sind im Moment keine Artikel vorhanden</div>
}
<div class="actions">
@if (actions$ | async; as actions) {
@for (action of actions; track action) {
@@ -57,19 +77,27 @@
[disabled]="(changeActionLoader$ | async) || (loading$ | async)"
class="cta-action cta-action-primary"
(click)="handleAction(action)"
>
<ui-spinner
[show]="(changeActionLoader$ | async) || (loading$ | async)"
>{{ action.label }}</ui-spinner
>
<ui-spinner [show]="(changeActionLoader$ | async) || (loading$ | async)">{{ action.label }}</ui-spinner>
</button>
}
}
@if (listEmpty$ | async) {
<a class="cta-action cta-action-secondary" [routerLink]="['/filiale', 'goods', 'in']">
<a
class="cta-action cta-action-secondary"
[routerLink]="['/filiale', 'goods', 'in']"
>
Zur Bestellpostensuche
</a>
}
@if (listEmpty$ | async) {
<a class="cta-action cta-action-primary" [routerLink]="['/filiale', 'remission']">Zur Remission</a>
<a class="cta-action cta-action-primary" [routerLink]="remissionPath()"
>Zur Remission</a
>
}
</div>

View File

@@ -1,7 +1,18 @@
import { ChangeDetectionStrategy, Component, OnDestroy, OnInit, ViewChild, inject } from '@angular/core';
import {
ChangeDetectionStrategy,
Component,
OnDestroy,
OnInit,
ViewChild,
inject,
linkedSignal,
} from '@angular/core';
import { Router } from '@angular/router';
import { BreadcrumbService } from '@core/breadcrumb';
import { KeyValueDTOOfStringAndString, OrderItemListItemDTO } from '@generated/swagger/oms-api';
import {
KeyValueDTOOfStringAndString,
OrderItemListItemDTO,
} from '@generated/swagger/oms-api';
import { UiErrorModalComponent, UiModalService } from '@ui/modal';
import { UiScrollContainerComponent } from '@ui/scroll-container';
import { BehaviorSubject, combineLatest, Subject } from 'rxjs';
@@ -11,6 +22,7 @@ import { Config } from '@core/config';
import { ToasterService } from '@shared/shell';
import { PickupShelfInNavigationService } from '@shared/services/navigation';
import { CacheService } from '@core/cache';
import { TabService } from '@isa/core/tabs';
@Component({
selector: 'page-goods-in-remission-preview',
@@ -21,8 +33,12 @@ import { CacheService } from '@core/cache';
standalone: false,
})
export class GoodsInRemissionPreviewComponent implements OnInit, OnDestroy {
private _pickupShelfInNavigationService = inject(PickupShelfInNavigationService);
@ViewChild(UiScrollContainerComponent) scrollContainer: UiScrollContainerComponent;
tabService = inject(TabService);
private _pickupShelfInNavigationService = inject(
PickupShelfInNavigationService,
);
@ViewChild(UiScrollContainerComponent)
scrollContainer: UiScrollContainerComponent;
items$ = this._store.results$;
@@ -50,10 +66,18 @@ export class GoodsInRemissionPreviewComponent implements OnInit, OnDestroy {
byProcessingStatusFn = (item: OrderItemListItemDTO) => item.processingStatus;
byCompartmentCodeFn = (item: OrderItemListItemDTO) =>
item.compartmentInfo ? `${item.compartmentCode}_${item.compartmentInfo}` : item.compartmentCode;
item.compartmentInfo
? `${item.compartmentCode}_${item.compartmentInfo}`
: item.compartmentCode;
private readonly SCROLL_POSITION_TOKEN = 'REMISSION_PREVIEW_SCROLL_POSITION';
remissionPath = linkedSignal(() => [
'/',
this.tabService.activatedTab()?.id || this.tabService.nextId(),
'remission',
]);
constructor(
private _breadcrumb: BreadcrumbService,
private _store: GoodsInRemissionPreviewStore,
@@ -78,12 +102,18 @@ export class GoodsInRemissionPreviewComponent implements OnInit, OnDestroy {
}
private _removeScrollPositionFromCache(): void {
this._cache.delete({ processId: this._config.get('process.ids.goodsIn'), token: this.SCROLL_POSITION_TOKEN });
this._cache.delete({
processId: this._config.get('process.ids.goodsIn'),
token: this.SCROLL_POSITION_TOKEN,
});
}
private _addScrollPositionToCache(): void {
this._cache.set<number>(
{ processId: this._config.get('process.ids.goodsIn'), token: this.SCROLL_POSITION_TOKEN },
{
processId: this._config.get('process.ids.goodsIn'),
token: this.SCROLL_POSITION_TOKEN,
},
this.scrollContainer?.scrollPos,
);
}
@@ -108,7 +138,10 @@ export class GoodsInRemissionPreviewComponent implements OnInit, OnDestroy {
async updateBreadcrumb() {
const crumbs = await this._breadcrumb
.getBreadcrumbsByKeyAndTags$(this._config.get('process.ids.goodsIn'), ['goods-in', 'preview'])
.getBreadcrumbsByKeyAndTags$(this._config.get('process.ids.goodsIn'), [
'goods-in',
'preview',
])
.pipe(first())
.toPromise();
for (const crumb of crumbs) {
@@ -120,12 +153,15 @@ export class GoodsInRemissionPreviewComponent implements OnInit, OnDestroy {
async removeBreadcrumbs() {
let breadcrumbsToDelete = await this._breadcrumb
.getBreadcrumbsByKeyAndTags$(this._config.get('process.ids.goodsIn'), ['goods-in'])
.getBreadcrumbsByKeyAndTags$(this._config.get('process.ids.goodsIn'), [
'goods-in',
])
.pipe(first())
.toPromise();
breadcrumbsToDelete = breadcrumbsToDelete.filter(
(crumb) => !crumb.tags.includes('preview') && !crumb.tags.includes('main'),
(crumb) =>
!crumb.tags.includes('preview') && !crumb.tags.includes('main'),
);
breadcrumbsToDelete.forEach((crumb) => {
@@ -133,11 +169,17 @@ export class GoodsInRemissionPreviewComponent implements OnInit, OnDestroy {
});
const detailsCrumbs = await this._breadcrumb
.getBreadcrumbsByKeyAndTags$(this._config.get('process.ids.goodsIn'), ['goods-in', 'details'])
.getBreadcrumbsByKeyAndTags$(this._config.get('process.ids.goodsIn'), [
'goods-in',
'details',
])
.pipe(first())
.toPromise();
const editCrumbs = await this._breadcrumb
.getBreadcrumbsByKeyAndTags$(this._config.get('process.ids.goodsIn'), ['goods-in', 'edit'])
.getBreadcrumbsByKeyAndTags$(this._config.get('process.ids.goodsIn'), [
'goods-in',
'edit',
])
.pipe(first())
.toPromise();
@@ -152,32 +194,44 @@ export class GoodsInRemissionPreviewComponent implements OnInit, OnDestroy {
initInitialSearch() {
if (this._store.hits === 0) {
this._store.searchResult$.pipe(takeUntil(this._onDestroy$)).subscribe(async (result) => {
await this.createBreadcrumb();
this._store.searchResult$
.pipe(takeUntil(this._onDestroy$))
.subscribe(async (result) => {
await this.createBreadcrumb();
this.scrollContainer?.scrollTo((await this._getScrollPositionFromCache()) ?? 0);
this._removeScrollPositionFromCache();
});
this.scrollContainer?.scrollTo(
(await this._getScrollPositionFromCache()) ?? 0,
);
this._removeScrollPositionFromCache();
});
}
this._store.search();
}
async navigateToRemission() {
await this._router.navigate(['/filiale/remission']);
await this._router.navigate(this.remissionPath());
}
navigateToDetails(orderItem: OrderItemListItemDTO) {
const nav = this._pickupShelfInNavigationService.detailRoute({ item: orderItem, side: false });
const nav = this._pickupShelfInNavigationService.detailRoute({
item: orderItem,
side: false,
});
this._router.navigate(nav.path, { queryParams: { ...nav.queryParams, view: 'remission' } });
this._router.navigate(nav.path, {
queryParams: { ...nav.queryParams, view: 'remission' },
});
}
async handleAction(action: KeyValueDTOOfStringAndString) {
this.changeActionLoader$.next(true);
try {
const response = await this._store.createRemissionFromPreview().pipe(first()).toPromise();
const response = await this._store
.createRemissionFromPreview()
.pipe(first())
.toPromise();
if (!response?.dialog) {
this._toast.open({

View File

@@ -254,35 +254,6 @@
</div>
</div>
@if (remissionNavigation$ | async; as remissionNavigation) {
<a
class="side-menu-group-item"
(click)="closeSideMenu()"
[routerLink]="remissionNavigation.path"
[queryParams]="remissionNavigation.queryParams"
routerLinkActive="active"
>
<span class="side-menu-group-item-icon">
<shared-icon icon="assignment-return"></shared-icon>
</span>
<span class="side-menu-group-item-label">Remission</span>
</a>
}
@if (packageInspectionNavigation$ | async; as packageInspectionNavigation) {
<a
class="side-menu-group-item"
(click)="closeSideMenu(); fetchAndOpenPackages()"
[routerLink]="packageInspectionNavigation.path"
[queryParams]="packageInspectionNavigation.queryParams"
routerLinkActive="active"
>
<span class="side-menu-group-item-icon">
<shared-icon icon="clipboard-check-outline"></shared-icon>
</span>
<span class="side-menu-group-item-label">Wareneingang</span>
</a>
}
<div class="side-menu-group-sub-item-wrapper">
<a
class="side-menu-group-item"
@@ -348,5 +319,20 @@
</div>
}
</div>
@if (packageInspectionNavigation$ | async; as packageInspectionNavigation) {
<a
class="side-menu-group-item"
(click)="closeSideMenu(); fetchAndOpenPackages()"
[routerLink]="packageInspectionNavigation.path"
[queryParams]="packageInspectionNavigation.queryParams"
routerLinkActive="active"
>
<span class="side-menu-group-item-icon">
<shared-icon icon="clipboard-check-outline"></shared-icon>
</span>
<span class="side-menu-group-item-label">Wareneingang</span>
</a>
}
</nav>
</div>

View File

@@ -191,14 +191,6 @@ export class ShellSideMenuComponent {
// this._pickUpShelfInNavigation.listRoute()
// );
remissionNavigation$ = this.getLastNavigationByProcessId(
this.#config.get('process.ids.remission'),
{
path: ['/filiale', 'remission'],
queryParams: {},
},
);
packageInspectionNavigation$ = this.getLastNavigationByProcessId(
this.#config.get('process.ids.packageInspection'),
{

View File

@@ -12,7 +12,7 @@ variables:
value: '4'
# Minor Version einstellen
- name: 'Minor'
value: '0'
value: '1'
- name: 'Patch'
value: "$[counter(format('{0}.{1}', variables['Major'], variables['Minor']),0)]"
- name: 'BuildUniqueID'

View File

@@ -44,5 +44,8 @@ export class DataAccessError<TCode extends string, TData = void> extends Error {
public readonly data: TData,
) {
super(message);
// Set the prototype explicitly to maintain the correct prototype chain
Object.setPrototypeOf(this, new.target.prototype);
this.name = this.constructor.name;
}
}

View File

@@ -9,3 +9,4 @@ export * from './get-receipt-items-from-return.helper';
export * from './get-package-numbers-from-return.helper';
export * from './get-retail-price-from-item.helper';
export * from './get-assortment-from-item.helper';
export * from './order-by-list-items.helper';

View File

@@ -0,0 +1,44 @@
import { RemissionItem } from '../stores';
/**
* Sorts the remission items in the response based on specific criteria:
* - Items with impediments are moved to the end of the list.
* - Within impediments, items are sorted by attempt count (ascending).
* - Manually added items are prioritized to appear first.
* - (Commented out) Items can be sorted by creation date in descending order.
* @param {RemissionItem[]} items - The response object containing remission items to be sorted
* @returns {void} The function modifies the response object in place
*/
export const orderByListItems = (items: RemissionItem[]): void => {
items.sort((a, b) => {
const aHasImpediment = !!a.impediment;
const bHasImpediment = !!b.impediment;
const aIsManuallyAdded = a.source === 'manually-added';
const bIsManuallyAdded = b.source === 'manually-added';
// First priority: move all items with impediment to the end of the list
if (!aHasImpediment && bHasImpediment) {
return -1;
}
if (aHasImpediment && !bHasImpediment) {
return 1;
}
// If both have impediments, sort by attempts (ascending)
if (aHasImpediment && bHasImpediment) {
const aAttempts = a.impediment?.attempts ?? 0;
const bAttempts = b.impediment?.attempts ?? 0;
return aAttempts - bAttempts;
}
// Second priority: manually-added items come first
if (aIsManuallyAdded && !bIsManuallyAdded) {
return -1;
}
if (!aIsManuallyAdded && bIsManuallyAdded) {
return 1;
}
return 0;
});
};

View File

@@ -0,0 +1,3 @@
import { ImpedimentDTO } from '@generated/swagger/inventory-api';
export type Impediment = ImpedimentDTO

View File

@@ -18,3 +18,6 @@ export * from './value-tuple-sting-and-integer';
export * from './create-remission';
export * from './remission-item-source';
export * from './receipt-complete-status';
export * from './remission-response-args-error-message';
export * from './impediment';
export * from './update-item';

View File

@@ -0,0 +1,11 @@
// #5331 - Messages kommen bis auf AlreadyRemoved aus dem Backend
export const RemissionResponseArgsErrorMessage = {
AlreadyCompleted: 'Remission wurde bereits abgeschlossen',
AlreadyRemitted: 'Artikel wurde bereits remittiert',
AlreadyRemoved: 'Artikel konnte nicht entfernt werden',
} as const;
export type RemissionResponseArgsErrorMessageKey =
keyof typeof RemissionResponseArgsErrorMessage;
export type RemissionResponseArgsErrorMessageValue =
(typeof RemissionResponseArgsErrorMessage)[RemissionResponseArgsErrorMessageKey];

View File

@@ -0,0 +1,7 @@
import { Impediment } from './impediment';
export interface UpdateItem {
inProgress: boolean;
itemId?: number;
impediment?: Impediment;
}

View File

@@ -1,11 +1,12 @@
<filter-input-menu-button
[filterInput]="filterDepartmentInput()"
[label]="selectedDepartments()"
[commitOnClose]="true"
[label]="selectedDepartment()"
[canApply]="true"
(closed)="rollbackFilterInput()"
>
</filter-input-menu-button>
@if (selectedDepartments()) {
@if (selectedDepartment()) {
<ui-toolbar class="ui-toolbar-rounded">
<span class="flex gap-1 isa-text-body-2-regular"
><span *uiSkeletonLoader="capacityFetching()" class="isa-text-body-2-bold"

View File

@@ -52,14 +52,17 @@ export class RemissionListDepartmentElementsComponent {
});
/**
* Computed signal for the selected departments from the filter input.
* If the input type is Checkbox and has selected values, it returns a comma-separated string.
* Otherwise, it returns undefined.
* Computed signal to get the selected department from the filter input.
* Returns the committed value if department is selected, otherwise a default label.
* @returns {string} The selected departments or a default label.
*/
selectedDepartments = computed(() => {
selectedDepartment = computed(() => {
const input = this.filterDepartmentInput();
if (input?.type === InputType.Checkbox && input?.selected?.length > 0) {
return input?.selected?.filter((selected) => !!selected).join(', ');
if (input && input.type === InputType.Checkbox) {
const committedValue = this.#filterService.queryParams()[input.key];
if (input.selected.length > 0 && committedValue) {
return committedValue;
}
}
return 'Abteilung auswählen';
});
@@ -71,9 +74,7 @@ export class RemissionListDepartmentElementsComponent {
*/
capacityResource = createRemissionCapacityResource(() => {
return {
departments: this.selectedDepartments()
?.split(',')
.map((d) => d.trim()),
departments: [this.selectedDepartment()],
};
});
@@ -144,4 +145,9 @@ export class RemissionListDepartmentElementsComponent {
})
: 0;
});
rollbackFilterInput() {
const inputKey = this.filterDepartmentInput()?.key;
this.#filterService.rollbackInput([inputKey!]);
}
}

View File

@@ -5,8 +5,8 @@
uiTextButton
color="strong"
(click)="deleteItemFromList()"
[disabled]="inProgress()"
[pending]="inProgress()"
[disabled]="removeOrUpdateItem().inProgress"
[pending]="removeOrUpdateItem().inProgress"
data-what="button"
data-which="remove-remission-item"
>
@@ -17,11 +17,12 @@
@if (displayChangeQuantityButton()) {
<button
class="self-end"
[class.highlight]="highlight()"
type="button"
uiTextButton
color="strong"
(click)="openRemissionQuantityDialog()"
[disabled]="inProgress()"
[disabled]="removeOrUpdateItem().inProgress"
data-what="button"
data-which="change-remission-quantity"
>

View File

@@ -5,6 +5,7 @@ import {
inject,
input,
model,
signal,
} from '@angular/core';
import { FormsModule, Validators } from '@angular/forms';
import { logger } from '@isa/core/logging';
@@ -14,6 +15,7 @@ import {
RemissionListType,
RemissionReturnReceiptService,
RemissionStore,
UpdateItem,
} from '@isa/remission/data-access';
import { TextButtonComponent } from '@isa/ui/buttons';
import { injectFeedbackDialog, injectNumberInputDialog } from '@isa/ui/dialog';
@@ -80,11 +82,12 @@ export class RemissionListItemActionsComponent {
stockToRemit = input.required<number>();
/**
* ModelSignal indicating whether remission items are currently being processed.
* Used to prevent multiple submissions or actions.
* @default false
* Model to track if a delete operation is in progress.
* And the item being deleted or updated.
*/
inProgress = model<boolean>();
removeOrUpdateItem = model<UpdateItem>({
inProgress: false,
});
/**
* Signal indicating whether remission has started.
@@ -114,6 +117,12 @@ export class RemissionListItemActionsComponent {
() => this.item()?.source === RemissionItemSource.ManuallyAdded,
);
/**
* Signal to highlight the change remission quantity button when dialog is open.
* Used to improve accessibility and focus management.
*/
highlight = signal(false);
/**
* Opens a dialog to change the remission quantity for the current item.
* Prompts the user to enter a new quantity and updates the store with the new value
@@ -121,6 +130,7 @@ export class RemissionListItemActionsComponent {
* If the item is not found, it updates the impediment with a comment.
*/
async openRemissionQuantityDialog(): Promise<void> {
this.highlight.set(true);
const dialogRef = this.#dialog({
title: 'Remi-Menge ändern',
displayClose: true,
@@ -150,6 +160,7 @@ export class RemissionListItemActionsComponent {
});
const result = await firstValueFrom(dialogRef.closed);
this.highlight.set(false);
// Dialog Close
if (!result) {
@@ -168,28 +179,37 @@ export class RemissionListItemActionsComponent {
} else if (itemId) {
// Produkt nicht gefunden CTA
try {
this.inProgress.set(true);
this.removeOrUpdateItem.set({ inProgress: true });
let itemToUpdate: RemissionItem | undefined;
if (this.remissionListType() === RemissionListType.Pflicht) {
await this.#remissionReturnReceiptService.updateReturnItemImpediment({
itemId,
comment: 'Produkt nicht gefunden',
});
itemToUpdate =
await this.#remissionReturnReceiptService.updateReturnItemImpediment(
{
itemId,
comment: 'Produkt nicht gefunden',
},
);
}
if (this.remissionListType() === RemissionListType.Abteilung) {
await this.#remissionReturnReceiptService.updateReturnSuggestionImpediment(
{
itemId,
comment: 'Produkt nicht gefunden',
},
);
itemToUpdate =
await this.#remissionReturnReceiptService.updateReturnSuggestionImpediment(
{
itemId,
comment: 'Produkt nicht gefunden',
},
);
}
this.removeOrUpdateItem.set({
inProgress: false,
itemId,
impediment: itemToUpdate?.impediment,
});
} catch (error) {
this.#logger.error('Failed to update impediment', error);
this.removeOrUpdateItem.set({ inProgress: false });
}
this.inProgress.set(false);
}
}
@@ -200,17 +220,17 @@ export class RemissionListItemActionsComponent {
*/
async deleteItemFromList() {
const itemId = this.item()?.id;
if (!itemId || this.inProgress()) {
if (!itemId || this.removeOrUpdateItem().inProgress) {
return;
}
this.inProgress.set(true);
this.removeOrUpdateItem.set({ inProgress: true });
try {
await this.#remissionReturnReceiptService.deleteReturnItem({ itemId });
this.removeOrUpdateItem.set({ inProgress: false, itemId });
} catch (error) {
this.#logger.error('Failed to delete return item', error);
this.removeOrUpdateItem.set({ inProgress: false });
}
this.inProgress.set(false);
}
}

View File

@@ -58,7 +58,7 @@
[selectedQuantityDiffersFromStockToRemit]="
selectedQuantityDiffersFromStockToRemit()
"
(inProgressChange)="inProgress.set($event)"
(removeOrUpdateItemChange)="removeOrUpdateItem.emit($event)"
></remi-feature-remission-list-item-actions>
</ui-item-row-data>
</ui-client-row>

View File

@@ -1,5 +1,11 @@
:host {
@apply w-full;
@apply w-full border border-solid border-transparent rounded-2xl;
&:has(
[data-what="button"][data-which="change-remission-quantity"].highlight
) {
@apply border border-solid border-isa-accent-blue;
}
}
.ui-client-row {

View File

@@ -176,19 +176,11 @@ describe('RemissionListItemComponent', () => {
expect(component.stockFetching()).toBe(true);
});
it('should have inProgress model with undefined default', () => {
it('should have removeOrUpdateItem output', () => {
fixture.componentRef.setInput('item', createMockReturnItem());
fixture.componentRef.setInput('stock', createMockStockInfo());
fixture.detectChanges();
expect(component.inProgress()).toBeUndefined();
});
it('should accept inProgress model value', () => {
fixture.componentRef.setInput('item', createMockReturnItem());
fixture.componentRef.setInput('stock', createMockStockInfo());
fixture.componentRef.setInput('inProgress', true);
fixture.detectChanges();
expect(component.inProgress()).toBe(true);
expect(component.removeOrUpdateItem).toBeDefined();
});
});

View File

@@ -4,7 +4,7 @@ import {
computed,
inject,
input,
model,
output,
} from '@angular/core';
import { FormsModule } from '@angular/forms';
import {
@@ -15,6 +15,7 @@ import {
ReturnItem,
ReturnSuggestion,
StockInfo,
UpdateItem,
} from '@isa/remission/data-access';
import {
ProductInfoComponent,
@@ -103,11 +104,10 @@ export class RemissionListItemComponent {
stockFetching = input<boolean>(false);
/**
* ModelSignal indicating whether remission items are currently being processed.
* Used to prevent multiple submissions or actions.
* @default false
* Output event emitter for when the item is deleted or updated.
* Emits an object containing the in-progress state and the item itself.
*/
inProgress = model<boolean>();
removeOrUpdateItem = output<UpdateItem>();
/**
* Optional product group value for display or filtering.

View File

@@ -23,7 +23,7 @@
{{ hits() }} Einträge
</span>
<div class="flex flex-col gap-4 w-full items-center justify-center mb-24">
<div class="flex flex-col gap-4 w-full items-center justify-center mb-36">
@for (item of items(); track item.id) {
@defer (on viewport) {
<remi-feature-remission-list-item
@@ -32,7 +32,7 @@
[stock]="getStockForItem(item)"
[stockFetching]="inStockFetching()"
[productGroupValue]="getProductGroupValueForItem(item)"
(inProgressChange)="onListItemActionInProgress($event)"
(removeOrUpdateItem)="onRemoveOrUpdateItem($event)"
></remi-feature-remission-list-item>
} @placeholder {
<div class="h-[7.75rem] w-full flex items-center justify-center">
@@ -54,9 +54,14 @@
></remi-feature-remission-list-empty-state>
</div>
<utils-scroll-top-button
class="flex flex-col self-end fixed bottom-6 mr-6"
[class.scroll-top-button-spacing-bottom]="remissionStarted()"
></utils-scroll-top-button>
@if (remissionStarted()) {
<ui-stateful-button
class="fixed right-6 bottom-6"
class="flex flex-col self-end fixed bottom-6 mr-6"
(clicked)="remitItems()"
(action)="remitItems()"
[(state)]="remitItemsState"
@@ -70,7 +75,7 @@
size="large"
color="brand"
[pending]="remitItemsInProgress()"
[disabled]="!hasSelectedItems() || listItemActionInProgress()"
[disabled]="!hasSelectedItems() || removeItemInProgress()"
>
</ui-stateful-button>
}

View File

@@ -0,0 +1,3 @@
.scroll-top-button-spacing-bottom {
@apply bottom-[5.5rem];
}

View File

@@ -6,6 +6,7 @@ import {
effect,
untracked,
signal,
linkedSignal,
} from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import {
@@ -16,7 +17,10 @@ import {
FilterService,
SearchTrigger,
} from '@isa/shared/filter';
import { injectRestoreScrollPosition } from '@isa/utils/scroll-position';
import {
injectRestoreScrollPosition,
ScrollTopButtonComponent,
} from '@isa/utils/scroll-position';
import { RemissionStartCardComponent } from './remission-start-card/remission-start-card.component';
import { RemissionListSelectComponent } from './remission-list-select/remission-list-select.component';
import {
@@ -40,16 +44,20 @@ import {
calculateAvailableStock,
RemissionReturnReceiptService,
getStockToRemit,
RemissionListType,
RemissionResponseArgsErrorMessage,
UpdateItem,
orderByListItems,
} from '@isa/remission/data-access';
import { injectDialog } from '@isa/ui/dialog';
import { injectDialog, injectFeedbackErrorDialog } from '@isa/ui/dialog';
import { SearchItemToRemitDialogComponent } from '@isa/remission/shared/search-item-to-remit-dialog';
import { RemissionListType } from '@isa/remission/data-access';
import { RemissionReturnCardComponent } from './remission-return-card/remission-return-card.component';
import { logger } from '@isa/core/logging';
import { RemissionProcessedHintComponent } from './remission-processed-hint/remission-processed-hint.component';
import { RemissionListDepartmentElementsComponent } from './remission-list-department-elements/remission-list-department-elements.component';
import { injectTabId } from '@isa/core/tabs';
import { RemissionListEmptyStateComponent } from './remission-list-empty-state/remission-list-empty-state.component';
import { firstValueFrom } from 'rxjs';
function querySettingsFactory() {
return inject(ActivatedRoute).snapshot.data['querySettings'];
@@ -92,6 +100,7 @@ function querySettingsFactory() {
RemissionListDepartmentElementsComponent,
RemissionProcessedHintComponent,
RemissionListEmptyStateComponent,
ScrollTopButtonComponent,
],
host: {
'[class]':
@@ -118,6 +127,7 @@ export class RemissionListComponent {
activatedTabId = injectTabId();
searchItemToRemitDialog = injectDialog(SearchItemToRemitDialogComponent);
errorDialog = injectFeedbackErrorDialog();
/**
* FilterService instance for managing filter state and queries.
@@ -167,7 +177,7 @@ export class RemissionListComponent {
* Signal indicating whether a remission list item deletion is in progress.
* Used to disable actions while deletion is happening.
*/
listItemActionInProgress = signal(false);
removeItemInProgress = signal(false);
/**
* Computed signal for the current search term from the filter service.
@@ -256,7 +266,7 @@ export class RemissionListComponent {
* Computed signal for the remission items to display.
* @returns Array of ReturnItem or ReturnSuggestion.
*/
items = computed(() => {
items = linkedSignal(() => {
const value = this.listResponseValue();
return value?.result ? value.result : [];
});
@@ -361,15 +371,29 @@ export class RemissionListComponent {
}
/**
* Handles the deletion of a remission list item.
* Updates the in-progress state and reloads the list and receipt upon completion.
*
* @param inProgress - Whether the deletion is currently in progress
* Handles the removal or update of an item from the remission list.
* Updates the local items signal and the remission store accordingly.
* Items with impediments are automatically moved to the end of the list and sorted by attempt count.
* @param param0 - Object containing inProgress state, itemId, and optional impediment.
*/
onListItemActionInProgress(inProgress: boolean) {
this.listItemActionInProgress.set(inProgress);
if (!inProgress) {
this.reloadListAndReturnData();
onRemoveOrUpdateItem({ inProgress, itemId, impediment }: UpdateItem) {
this.removeItemInProgress.set(inProgress);
if (!inProgress && itemId) {
if (!impediment || (impediment.attempts && impediment.attempts >= 4)) {
this.items.set(this.items().filter((item) => item.id !== itemId)); // Filter Item if no impediment or attempts >= 4 (#5361)
} else {
// Update Item
this.items.update((items) => {
const updatedItems = items.map((item) =>
item.id === itemId ? { ...item, impediment } : item,
);
orderByListItems(updatedItems);
return updatedItems;
});
}
// Always Unselect Item
this.#store.removeItem(itemId);
}
}
@@ -391,36 +415,52 @@ export class RemissionListComponent {
});
/**
* Effect that handles the case when there are no items in the remission list after a search.
* If the search was triggered by the user, it opens a dialog to search for items to remit.
* If remission has already started, it adds the found items to the remission store and remits them.
* If not, it navigates to the default remission list.
* Effect that handles scenarios where a search yields no results.
* If the search was user-initiated and returned no hits, it opens a dialog
* to allow the user to add a new item to remit.
* If only one hit is found and a remission is started, it selects that item automatically.
* This effect runs whenever the remission or stock resource status changes,
* or when the search term changes.
* It ensures that the user is prompted appropriately based on their actions and the current state of the remission process.
* It also checks if the remission is started or if the list type is 'Abteilung' to determine navigation behavior.
* @see {@link
* https://angular.dev/guide/effects} for more information on Angular effects.
* @remarks This effect uses `untracked` to avoid unnecessary re-evaluations
* when accessing certain signals.
*/
emptySearchResultEffect = effect(() => {
const status = this.remissionResource.status();
const stockStatus = this.inStockResource.status();
const searchTerm: string | undefined = this.searchTerm();
if (status !== 'resolved') {
return;
}
const hasItems = !!this.remissionResource.value()?.result?.length;
if (hasItems || !searchTerm || !this.hasValidSearchTerm()) {
if (status !== 'resolved' || stockStatus !== 'resolved') {
return;
}
untracked(() => {
if (!this.searchTriggeredByUser()) {
const hits = this.hits();
// #5338 - Select item automatically if only one hit after search
if (
!!hits ||
!searchTerm ||
!this.hasValidSearchTerm() ||
!this.searchTriggeredByUser()
) {
if (hits === 1 && this.remissionStarted()) {
this.#store.clearSelectedItems();
this.preselectRemissionItem(this.items()[0]);
}
return;
}
this.searchItemToRemitDialog({
data: {
searchTerm,
isDepartment: this.isDepartment(),
},
}).closed.subscribe(async (result) => {
this.#store.clearSelectedItems();
if (result) {
if (this.remissionStarted()) {
for (const item of result) {
@@ -432,9 +472,8 @@ export class RemissionListComponent {
} else if (this.isDepartment()) {
return await this.navigateToDefaultRemissionList();
}
this.reloadListAndReturnData();
}
this.reloadListAndReturnData();
});
});
});
@@ -493,17 +532,10 @@ export class RemissionListComponent {
});
}
}
this.remitItemsState.set('success');
this.reloadListAndReturnData();
} catch (error) {
this.#logger.error('Failed to remit items', error);
this.remitItemsError.set(
error instanceof Error
? error.message
: 'Artikel konnten nicht remittiert werden',
);
this.remitItemsState.set('error');
await this.handleRemitItemsError(error);
}
this.#store.clearSelectedItems();
@@ -520,6 +552,62 @@ export class RemissionListComponent {
this.#store.reloadReturn();
}
/**
* Pre-Selects a remission item if it has available stock and can be remitted.
* Updates the remission store with the selected item.
* @param item - The ReturnItem or ReturnSuggestion to select.
* @returns void
*/
preselectRemissionItem(item: RemissionItem) {
if (!!item && item.id) {
const inStock = this.getAvailableStockForItem(item);
const stockToRemit = getStockToRemit({
remissionItem: item,
remissionListType: this.selectedRemissionListType(),
availableStock: inStock,
});
if (inStock > 0 && stockToRemit > 0) {
this.#store.selectRemissionItem(item.id, item);
}
}
}
/**
* Handles errors that occur during the remission of items.
* Logs the error, displays an error dialog, and reloads the list and return data.
* If the error indicates that the remission is already completed, it clears the remission state.
* Sets the stateful button to 'error' to indicate the failure.
* @param error - The error object caught during the remission process.
* @returns A promise that resolves when the error handling is complete.
*/
async handleRemitItemsError(error: any) {
this.#logger.error('Failed to remit items', error);
const errorMessage =
error?.error?.message ??
error?.message ??
'Artikel konnten nicht remittiert werden';
this.remitItemsError.set(errorMessage);
await firstValueFrom(
this.errorDialog({
data: {
errorMessage,
},
}).closed,
);
if (errorMessage === RemissionResponseArgsErrorMessage.AlreadyCompleted) {
this.#store.clearState();
}
this.reloadListAndReturnData();
this.remitItemsState.set('error'); // Stateful-Button auf Error setzen
}
/**
* Navigates to the default remission list based on the current activated tab ID.
* This method is used to redirect the user to the remission list after completing or starting a remission.

View File

@@ -1,6 +1,7 @@
import { inject, resource } from '@angular/core';
import { ListResponseArgs, ResponseArgsError } from '@isa/common/data-access';
import {
orderByListItems,
QueryTokenInput,
RemissionItem,
RemissionListType,
@@ -9,7 +10,6 @@ import {
RemissionSupplierService,
} from '@isa/remission/data-access';
import { SearchTrigger } from '@isa/shared/filter';
import { parseISO, compareDesc } from 'date-fns';
import { isEan } from '@isa/utils/ean-validation';
/**
@@ -144,7 +144,7 @@ export const createRemissionListResource = (
const hasOrderBy = !!queryToken?.orderBy && queryToken.orderBy.length > 0;
if (!hasOrderBy && res && res.result && Array.isArray(res.result)) {
sortResponseResult(res);
orderByListItems(res.result);
}
return res;
@@ -152,55 +152,6 @@ export const createRemissionListResource = (
});
};
/**
* Sorts the remission items in the response based on specific criteria:
* - Items with impediments are moved to the end of the list.
* - Manually added items are prioritized to appear first.
* - (Commented out) Items can be sorted by creation date in descending order.
* @param {ListResponseArgs<RemissionItem>} resopnse - The response object containing remission items to be sorted
* @returns {void} The function modifies the response object in place
*/
const sortResponseResult = (
resopnse: ListResponseArgs<RemissionItem>,
): void => {
resopnse.result.sort((a, b) => {
const aHasImpediment = !!a.impediment;
const bHasImpediment = !!b.impediment;
const aIsManuallyAdded = a.source === 'manually-added';
const bIsManuallyAdded = b.source === 'manually-added';
// First priority: move all items with impediment to the end of the list
if (!aHasImpediment && bHasImpediment) {
return -1;
}
if (aHasImpediment && !bHasImpediment) {
return 1;
}
// Second priority: manually-added items come first
if (aIsManuallyAdded && !bIsManuallyAdded) {
return -1;
}
if (!aIsManuallyAdded && bIsManuallyAdded) {
return 1;
}
// #5295 Fix - Sortierung über Created (Pflichtremission) wird wie auch die Sortierung über die SORT Nummer (Abteilungsremission) bereits über das Backend erledigt
// Third priority: sort by created date (latest first)
// if (a.created && b.created) {
// const dateA = parseISO(a.created);
// const dateB = parseISO(b.created);
// return compareDesc(dateA, dateB); // Descending order (latest first)
// }
// // Handle cases where created date might be missing
// if (a.created && !b.created) return -1;
// if (!a.created && b.created) return 1;
return 0;
});
};
// #5128 #5234 Bei Exact Search soll er über Alle Listen nur mit dem Input ohne aktive Filter / orderBy suchen
/**
* Checks if the query token is an exact search based on the search trigger.

View File

@@ -9,6 +9,7 @@ import {
} from '@angular/core';
import {
ReceiptItem,
RemissionResponseArgsErrorMessage,
RemissionReturnReceiptService,
} from '@isa/remission/data-access';
import { ProductFormatComponent } from '@isa/shared/product-foramt';
@@ -20,6 +21,8 @@ import { IconButtonComponent } from '@isa/ui/buttons';
import { provideIcons } from '@ng-icons/core';
import { isaActionClose } from '@isa/icons';
import { logger } from '@isa/core/logging';
import { injectFeedbackErrorDialog } from '@isa/ui/dialog';
import { firstValueFrom } from 'rxjs';
/**
* Component for displaying a single receipt item within the remission return receipt details.
@@ -55,6 +58,8 @@ export class RemissionReturnReceiptDetailsItemComponent {
}));
#returnReceiptService = inject(RemissionReturnReceiptService);
errorDialog = injectFeedbackErrorDialog();
/**
* Required input for the receipt item to display.
* Contains product information and quantity details.
@@ -85,7 +90,7 @@ export class RemissionReturnReceiptDetailsItemComponent {
removing = signal(false);
removed = output<ReceiptItem>();
reloadReturn = output<void>();
async remove() {
if (this.removing()) {
@@ -98,10 +103,25 @@ export class RemissionReturnReceiptDetailsItemComponent {
returnId: this.returnId(),
receiptItemId: this.item().id,
});
this.removed.emit(this.item());
} catch (error) {
this.#logger.error('Failed to remove item', error);
await this.handleRemoveItemError(error);
}
this.reloadReturn.emit();
this.removing.set(false);
}
async handleRemoveItemError(error: any) {
this.#logger.error('Failed to remove item', error);
const errorMessage =
error?.error?.message ?? RemissionResponseArgsErrorMessage.AlreadyRemoved;
await firstValueFrom(
this.errorDialog({
data: {
errorMessage,
},
}).closed,
);
}
}

View File

@@ -55,7 +55,7 @@
[removeable]="canRemoveItems()"
[receiptId]="receiptId()"
[returnId]="returnId()"
(removed)="returnResource.reload()"
(reloadReturn)="returnResource.reload()"
></remi-remission-return-receipt-details-item>
@if (!last) {
<hr class="border-isa-neutral-300" />

View File

@@ -15,7 +15,7 @@ import {
DropdownButtonComponent,
DropdownOptionComponent,
} from '@isa/ui/input-controls';
import { QuantityAndReason } from './select-remi-quantity-and-reason.component';
import { QuantityAndReason } from './select-remi-quantity-and-reason-dialog.component';
import { ReturnValue } from '@isa/common/data-access';
import { provideIcons } from '@ng-icons/core';
import { isaActionChevronDown, isaActionChevronUp } from '@isa/icons';

View File

@@ -1,18 +1,14 @@
@if (item()) {
<remi-select-remi-quantity-and-reason></remi-select-remi-quantity-and-reason>
} @else {
<button
class="absolute top-4 right-[1.33rem]"
type="button"
uiTextButton
size="small"
color="subtle"
(click)="close(undefined)"
tabindex="-1"
data-what="button"
data-which="close-dialog"
>
Schließen
</button>
<remi-search-item-to-remit-list></remi-search-item-to-remit-list>
}
<button
class="absolute top-4 right-[1.33rem]"
type="button"
uiTextButton
size="small"
color="subtle"
(click)="close(undefined)"
tabindex="-1"
data-what="button"
data-which="close-dialog"
>
Schließen
</button>
<remi-search-item-to-remit-list></remi-search-item-to-remit-list>

View File

@@ -1,33 +1,23 @@
import {
ChangeDetectionStrategy,
Component,
effect,
isSignal,
linkedSignal,
signal,
Signal,
} from '@angular/core';
import { DialogContentDirective, NumberInputValidation } from '@isa/ui/dialog';
import { Item } from '@isa/catalogue/data-access';
import { TextButtonComponent } from '@isa/ui/buttons';
import { provideIcons } from '@ng-icons/core';
import { isaActionSearch } from '@isa/icons';
import { SearchItemToRemitListComponent } from './search-item-to-remit-list.component';
import { SelectRemiQuantityAndReasonComponent } from './select-remi-quantity-and-reason.component';
import { Validators } from '@angular/forms';
import { ReturnSuggestion, ReturnItem } from '@isa/remission/data-access';
import { ReturnItem } from '@isa/remission/data-access';
export type SearchItemToRemitDialogData = {
searchTerm: string | Signal<string>;
isDepartment: boolean;
};
export type SearchItemToRemitDialogResult =
SearchItemToRemitDialogData extends { isDepartment: infer D }
? D extends true
? ReturnSuggestion
: ReturnItem
: never;
// #5273, #4768 Fix - Nur ReturnItems sind zugelassen und dürfen zur Pflichtremission hinzugefügt werden
export type SearchItemToRemitDialogResult = ReturnItem;
@Component({
selector: 'remi-search-item-to-remit-dialog',
@@ -35,11 +25,7 @@ export type SearchItemToRemitDialogResult =
styleUrls: ['./search-item-to-remit-dialog.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [
TextButtonComponent,
SearchItemToRemitListComponent,
SelectRemiQuantityAndReasonComponent,
],
imports: [TextButtonComponent, SearchItemToRemitListComponent],
providers: [provideIcons({ isaActionSearch })],
})
export class SearchItemToRemitDialogComponent extends DialogContentDirective<
@@ -51,35 +37,4 @@ export class SearchItemToRemitDialogComponent extends DialogContentDirective<
? this.data.searchTerm()
: this.data.searchTerm,
);
item = signal<Item | undefined>(undefined);
itemEffect = effect(() => {
const item = this.item();
this.dialogRef.updateSize(item ? '36rem' : 'auto');
if (item) {
this.dialog.title.set(`Dieser Artikel steht nicht auf der Remi Liste`);
} else {
this.dialog.title.set(undefined);
}
});
quantityValidators: NumberInputValidation[] = [
{
errorKey: 'required',
inputValidator: Validators.required,
errorText: 'Bitte geben Sie eine Menge an.',
},
{
errorKey: 'min',
inputValidator: Validators.min(1),
errorText: 'Die Menge muss mindestens 1 sein.',
},
{
errorKey: 'max',
inputValidator: Validators.max(1000),
errorText: 'Die Menge darf höchstens 1000 sein.',
},
];
}

View File

@@ -14,7 +14,7 @@
name="isaActionSearch"
color="brand"
(click)="triggerSearch()"
[pending]="searchResource.isLoading()"
[pending]="searchResource.isLoading() || inStockResource.isLoading()"
data-what="button"
data-which="search-submit"
></ui-icon-button>
@@ -34,24 +34,23 @@
<ng-icon size="1.5rem" name="isaOtherInfo"></ng-icon>
</button>
</p>
<div class="overflow-y-auto">
<div #list class="overflow-y-auto overflow-x-hidden">
@if (searchResource.value()?.result; as items) {
@for (item of items; track item.id) {
@for (item of availableSearchResults(); track item.id) {
@defer {
@let inStock = getAvailableStockForItem(item);
@if (inStock > 0) {
<remi-search-item-to-remit
[item]="item"
[inStock]="inStock"
data-what="list-item"
data-which="search-result"
[attr.data-item-id]="item.id"
></remi-search-item-to-remit>
}
<remi-search-item-to-remit
[item]="item"
[inStock]="getAvailableStockForItem(item)"
data-what="list-item"
data-which="search-result"
[attr.data-item-id]="item.id"
></remi-search-item-to-remit>
}
}
}
@if (!hasItems() && !searchResource.isLoading()) {
@if (
!hasItems() && !searchResource.isLoading() && !inStockResource.isLoading()
) {
<ui-empty-state
class="w-full justify-self-center"
title="Keine Suchergebnisse"
@@ -59,4 +58,8 @@
>
</ui-empty-state>
}
<utils-scroll-top-button
class="flex flex-col self-end absolute bottom-6 right-6"
[target]="list"
></utils-scroll-top-button>
</div>

View File

@@ -32,6 +32,8 @@ import { TooltipDirective } from '@isa/ui/tooltip';
import { createInStockResource } from './instock.resource';
import { calculateAvailableStock } from '@isa/remission/data-access';
import { EmptyStateComponent } from '@isa/ui/empty-state';
import { ScrollTopButtonComponent } from '@isa/utils/scroll-position';
@Component({
selector: 'remi-search-item-to-remit-list',
templateUrl: './search-item-to-remit-list.component.html',
@@ -48,6 +50,7 @@ import { EmptyStateComponent } from '@isa/ui/empty-state';
TooltipDirective,
NgIcon,
EmptyStateComponent,
ScrollTopButtonComponent,
],
providers: [provideIcons({ isaActionSearch, isaOtherInfo })],
})
@@ -57,6 +60,14 @@ export class SearchItemToRemitListComponent implements OnInit {
searchParams = signal<SearchByTermInput | undefined>(undefined);
availableSearchResults = computed(() => {
return (
this.searchResource.value()?.result?.filter((item) => {
return this.getAvailableStockForItem(item) > 0;
}) ?? []
);
});
inStockResource = createInStockResource(() => {
return {
itemIds:
@@ -69,7 +80,7 @@ export class SearchItemToRemitListComponent implements OnInit {
inStockResponseValue = computed(() => this.inStockResource.value());
hasItems = computed(() => {
return (this.searchResource.value()?.result?.length ?? 0) > 0;
return (this.availableSearchResults()?.length ?? 0) > 0;
});
stockInfoMap = computed(() => {

View File

@@ -18,7 +18,7 @@
type="button"
uiTextButton
color="strong"
(click)="host.item.set(item())"
(click)="openQuantityAndReasonDialog()"
>
Remimenge auswählen
</button>

View File

@@ -10,6 +10,9 @@ import { ProductInfoComponent } from '@isa/remission/shared/product';
import { TextButtonComponent } from '@isa/ui/buttons';
import { Breakpoint, breakpoint } from '@isa/ui/layout';
import { SearchItemToRemitDialogComponent } from './search-item-to-remit-dialog.component';
import { injectDialog } from '@isa/ui/dialog';
import { SelectRemiQuantityAndReasonDialogComponent } from './select-remi-quantity-and-reason-dialog.component';
import { firstValueFrom } from 'rxjs';
@Component({
selector: 'remi-search-item-to-remit',
@@ -20,6 +23,9 @@ import { SearchItemToRemitDialogComponent } from './search-item-to-remit-dialog.
})
export class SearchItemToRemitComponent {
host = inject(SearchItemToRemitDialogComponent);
quantityAndReasonDialog = injectDialog(
SelectRemiQuantityAndReasonDialogComponent,
);
item = input.required<Item>();
inStock = input.required<number>();
@@ -29,4 +35,22 @@ export class SearchItemToRemitComponent {
productInfoOrientation = computed(() => {
return this.desktopBreakpoint() ? 'vertical' : 'horizontal';
});
async openQuantityAndReasonDialog() {
if (this.item()) {
const dialogRef = this.quantityAndReasonDialog({
title: 'Dieser Artikel steht nicht auf der Remi Liste',
data: {
item: this.item(),
inStock: this.inStock(),
},
width: '36rem',
});
const dialogResult = await firstValueFrom(dialogRef.closed);
if (dialogResult) {
this.host.close(dialogResult);
}
}
}
}

View File

@@ -1,84 +1,94 @@
<p class="text-isa-neutral-600 isa-text-body-1-regular">
Wie viele Exemplare können remittiert werden?
</p>
<div class="flex flex-col gap-4">
@for (
quantityAndReason of quantitiesAndResons();
track $index;
let i = $index
) {
<div class="flex items-center gap-1">
<remi-quantity-and-reason-item
[position]="$index + 1"
[quantityAndReason]="quantityAndReason"
(quantityAndReasonChange)="setQuantityAndReason($index, $event)"
class="flex-1"
data-what="component"
data-which="quantity-reason-item"
[attr.data-position]="$index + 1"
></remi-quantity-and-reason-item>
@if (i > 0) {
<ui-icon-button
type="button"
(click)="removeQuantityReasonItem($index)"
data-what="button"
data-which="remove-quantity"
[attr.data-position]="$index + 1"
name="isaActionClose"
color="neutral"
></ui-icon-button>
}
</div>
}
</div>
<div>
<button
type="button"
class="flex items-center gap-2 -ml-5"
uiTextButton
color="strong"
(click)="addQuantityReasonItem()"
data-what="button"
data-which="add-quantity"
>
<ng-icon name="isaActionPlus" size="1.5rem"></ng-icon>
<div>Menge hinzufügen</div>
</button>
</div>
<div class="text-isa-accent-red isa-text-body-1-regular">
<span>
@if (canReturnErrors(); as errors) {
@for (error of errors; track $index) {
{{ error }}
}
}
</span>
</div>
<div class="grid grid-cols-2 items-center gap-2">
<button
type="button"
color="secondary"
size="large"
uiButton
(click)="host.item.set(undefined)"
data-what="button"
data-which="back"
>
Zurück
</button>
<button
type="button"
color="primary"
size="large"
uiButton
[pending]="canAddToRemiListResource.isLoading()"
[disabled]="canAddToRemiListResource.isLoading() || canReturn() === false"
(click)="addToRemiList()"
data-what="button"
data-which="save-remission"
>
Speichern
</button>
</div>
<remi-product-info
[item]="{
product: data.item.product,
retailPrice: data.item.catalogAvailability.price,
}"
></remi-product-info>
<div class="text-isa-neutral-900 flex flex-row items-center justify-end gap-8">
<span class="isa-text-body-2-regular">Aktueller Bestand</span>
<span class="isa-text-body-2-bold">{{ data.inStock }}x</span>
</div>
<p class="text-isa-neutral-600 isa-text-body-1-regular">
Wie viele Exemplare können remittiert werden?
</p>
<div class="flex flex-col gap-4">
@for (
quantityAndReason of quantitiesAndResons();
track $index;
let i = $index
) {
<div class="flex items-center gap-1">
<remi-quantity-and-reason-item
[position]="$index + 1"
[quantityAndReason]="quantityAndReason"
(quantityAndReasonChange)="setQuantityAndReason($index, $event)"
class="flex-1"
data-what="component"
data-which="quantity-reason-item"
[attr.data-position]="$index + 1"
></remi-quantity-and-reason-item>
@if (i > 0) {
<ui-icon-button
type="button"
(click)="removeQuantityReasonItem($index)"
data-what="button"
data-which="remove-quantity"
[attr.data-position]="$index + 1"
name="isaActionClose"
color="neutral"
></ui-icon-button>
}
</div>
}
</div>
<div>
<button
type="button"
class="flex items-center gap-2 -ml-5"
uiTextButton
color="strong"
(click)="addQuantityReasonItem()"
data-what="button"
data-which="add-quantity"
>
<ng-icon name="isaActionPlus" size="1.5rem"></ng-icon>
<div>Menge hinzufügen</div>
</button>
</div>
<div class="text-isa-accent-red isa-text-body-1-regular">
<span>
@if (canReturnErrors(); as errors) {
@for (error of errors; track $index) {
{{ error }}
}
}
</span>
</div>
<div class="grid grid-cols-2 items-center gap-2">
<button
type="button"
color="secondary"
size="large"
uiButton
(click)="close(undefined)"
data-what="button"
data-which="back"
>
Zurück
</button>
<button
type="button"
color="primary"
size="large"
uiButton
[pending]="canAddToRemiListResource.isLoading()"
[disabled]="canAddToRemiListResource.isLoading() || canReturn() === false"
(click)="addToRemiList()"
data-what="button"
data-which="save-remission"
>
Speichern
</button>
</div>

View File

@@ -0,0 +1,3 @@
:host {
@apply grid grid-flow-row gap-6 h-full;
}

View File

@@ -1,184 +1,196 @@
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
model,
resource,
} from '@angular/core';
import { SearchItemToRemitDialogComponent } from './search-item-to-remit-dialog.component';
import { QuantityAndReasonItemComponent } from './quantity-and-reason-item.component';
import {
ButtonComponent,
TextButtonComponent,
IconButtonComponent,
} from '@isa/ui/buttons';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { isaActionPlus, isaActionClose } from '@isa/icons';
import {
RemissionSearchService,
RemissionStore,
ReturnItem,
ReturnSuggestion,
} from '@isa/remission/data-access';
import { injectFeedbackDialog } from '@isa/ui/dialog';
import { BatchResponseArgs } from '@isa/common/data-access';
export interface QuantityAndReason {
quantity: number;
reason: string;
}
@Component({
selector: 'remi-select-remi-quantity-and-reason',
templateUrl: './select-remi-quantity-and-reason.component.html',
styleUrls: ['./select-remi-quantity-and-reason.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [
QuantityAndReasonItemComponent,
TextButtonComponent,
NgIcon,
ButtonComponent,
IconButtonComponent,
],
providers: [provideIcons({ isaActionPlus, isaActionClose })],
})
export class SelectRemiQuantityAndReasonComponent {
#remiService = inject(RemissionSearchService);
#remiStore = inject(RemissionStore);
#feedbackDialog = injectFeedbackDialog();
host = inject(SearchItemToRemitDialogComponent);
initialItem: QuantityAndReason = { quantity: 0, reason: '' };
quantitiesAndResons = model<QuantityAndReason[]>([this.initialItem]);
addQuantityReasonItem(): void {
this.quantitiesAndResons.update((items) => [...items, this.initialItem]);
}
removeQuantityReasonItem(position: number): void {
const currentItems = this.quantitiesAndResons();
if (currentItems.length > 1) {
this.quantitiesAndResons.update((items) =>
items.filter((_, index) => index !== position),
);
}
}
setQuantityAndReason(position: number, qar: QuantityAndReason): void {
this.quantitiesAndResons.update((items) => {
const newItems = [...items];
newItems[position] = qar;
return newItems;
});
}
params = computed(() => {
const items = this.quantitiesAndResons();
const item = this.host.item();
if (!item) {
return [];
}
return items.map((qar) => ({
item,
quantity: qar.quantity,
reason: qar.reason,
}));
});
canAddToRemiListResource = resource({
params: this.params,
loader: async ({ params, abortSignal }) => {
if (
!this.host.item() ||
params.some((p) => !p.reason) ||
params.some((p) => !p.quantity)
) {
return undefined;
}
const maxQuantityErrors = params.filter((p) => !(p.quantity <= 999));
if (maxQuantityErrors.length > 0) {
const errRes: BatchResponseArgs<ReturnItem> = {
completed: false,
error: true,
total: maxQuantityErrors.length,
invalidProperties: {
quantity: 'Die Menge darf maximal 999 sein.',
},
};
return errRes;
}
return this.#remiService.canAddItemToRemiList(params, abortSignal);
},
});
canReturn = computed(() => {
const results = this.canAddToRemiListResource.value();
if (!results) {
return false;
}
if (results.failed && results.failed.length > 0) {
return false;
}
if (
results.successful &&
results.successful.length === this.quantitiesAndResons().length
) {
return true;
}
return false;
});
canReturnErrors = computed(() => {
const results = this.canAddToRemiListResource.value();
if (results?.invalidProperties) {
return Object.values(results.invalidProperties);
}
if (!results?.failed) {
return [];
}
return results.failed.map((item) =>
item.invalidProperties
? Object.values(item.invalidProperties).join(', ')
: [],
) as string[];
});
async addToRemiList() {
const canAddValue = this.canAddToRemiListResource.value();
if (!canAddValue) {
return;
}
if (canAddValue.failed?.length) {
return;
}
// #5273, #4768 Fix - Items dürfen nur zur Pflichtremission hinzugefügt werden
const result: Array<ReturnItem> = await this.#remiService.addToList(
this.params(),
);
this.#feedbackDialog({
data: {
message: this.#remiStore.remissionStarted()
? 'Wurde zum Warenbegleitschein hinzugefügt'
: 'Wurde zur Remi Liste hinzugefügt',
},
});
this.host.close(result);
}
}
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
model,
resource,
} from '@angular/core';
import { QuantityAndReasonItemComponent } from './quantity-and-reason-item.component';
import {
ButtonComponent,
TextButtonComponent,
IconButtonComponent,
} from '@isa/ui/buttons';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { isaActionPlus, isaActionClose } from '@isa/icons';
import {
RemissionSearchService,
RemissionStore,
ReturnItem,
} from '@isa/remission/data-access';
import { DialogContentDirective, injectFeedbackDialog } from '@isa/ui/dialog';
import { BatchResponseArgs } from '@isa/common/data-access';
import { Item } from '@isa/catalogue/data-access';
import { ProductInfoComponent } from '@isa/remission/shared/product';
export type SelectRemiQuantityAndReasonDialogData = {
item: Item;
inStock: number;
};
export type SelectRemiQuantityAndReasonDialogResult =
| undefined
| Array<ReturnItem>;
export interface QuantityAndReason {
quantity: number;
reason: string;
}
@Component({
selector: 'remi-select-remi-quantity-and-reason-dialog',
templateUrl: './select-remi-quantity-and-reason-dialog.component.html',
styleUrls: ['./select-remi-quantity-and-reason-dialog.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [
QuantityAndReasonItemComponent,
TextButtonComponent,
NgIcon,
ButtonComponent,
IconButtonComponent,
ProductInfoComponent,
],
providers: [provideIcons({ isaActionPlus, isaActionClose })],
})
export class SelectRemiQuantityAndReasonDialogComponent extends DialogContentDirective<
SelectRemiQuantityAndReasonDialogData,
SelectRemiQuantityAndReasonDialogResult
> {
#remiService = inject(RemissionSearchService);
#remiStore = inject(RemissionStore);
#feedbackDialog = injectFeedbackDialog();
initialItem: QuantityAndReason = { quantity: 0, reason: '' };
quantitiesAndResons = model<QuantityAndReason[]>([this.initialItem]);
addQuantityReasonItem(): void {
this.quantitiesAndResons.update((items) => [...items, this.initialItem]);
}
removeQuantityReasonItem(position: number): void {
const currentItems = this.quantitiesAndResons();
if (currentItems.length > 1) {
this.quantitiesAndResons.update((items) =>
items.filter((_, index) => index !== position),
);
}
}
setQuantityAndReason(position: number, qar: QuantityAndReason): void {
this.quantitiesAndResons.update((items) => {
const newItems = [...items];
newItems[position] = qar;
return newItems;
});
}
params = computed(() => {
const items = this.quantitiesAndResons();
const item = this.data.item;
if (!item) {
return [];
}
return items.map((qar) => ({
item,
quantity: qar.quantity,
reason: qar.reason,
}));
});
canAddToRemiListResource = resource({
params: this.params,
loader: async ({ params, abortSignal }) => {
if (
!this.data.item ||
params.some((p) => !p.reason) ||
params.some((p) => !p.quantity)
) {
return undefined;
}
const maxQuantityErrors = params.filter((p) => !(p.quantity <= 999));
if (maxQuantityErrors.length > 0) {
const errRes: BatchResponseArgs<ReturnItem> = {
completed: false,
error: true,
total: maxQuantityErrors.length,
invalidProperties: {
quantity: 'Die Menge darf maximal 999 sein.',
},
};
return errRes;
}
return this.#remiService.canAddItemToRemiList(params, abortSignal);
},
});
canReturn = computed(() => {
const results = this.canAddToRemiListResource.value();
if (!results) {
return false;
}
if (results.failed && results.failed.length > 0) {
return false;
}
if (
results.successful &&
results.successful.length === this.quantitiesAndResons().length
) {
return true;
}
return false;
});
canReturnErrors = computed(() => {
const results = this.canAddToRemiListResource.value();
if (results?.invalidProperties) {
return Object.values(results.invalidProperties);
}
if (!results?.failed) {
return [];
}
return results.failed.map((item) =>
item.invalidProperties
? Object.values(item.invalidProperties).join(', ')
: [],
) as string[];
});
async addToRemiList() {
const canAddValue = this.canAddToRemiListResource.value();
if (!canAddValue) {
return;
}
if (canAddValue.failed?.length) {
return;
}
// #5273, #4768 Fix - Items dürfen nur zur Pflichtremission hinzugefügt werden
const result: Array<ReturnItem> = await this.#remiService.addToList(
this.params(),
);
this.#feedbackDialog({
data: {
message: this.#remiStore.remissionStarted()
? 'Wurde zum Warenbegleitschein hinzugefügt'
: 'Wurde zur Remi Liste hinzugefügt',
},
});
this.close(result);
}
}

View File

@@ -1,3 +0,0 @@
:host {
@apply grid grid-flow-row gap-6;
}

View File

@@ -42,5 +42,6 @@
[filterInput]="input"
(applied)="applied.emit()"
(reseted)="reseted.emit()"
[canApply]="canApply()"
></filter-input-menu>
</ng-template>

View File

@@ -68,6 +68,13 @@ export class FilterInputMenuButtonComponent {
*/
reseted = output<void>();
/**
* Indicates whether the filter can be applied.
* Defaults to false.
* @default false
*/
canApply = input<boolean>(false);
/**
* Emits an event when the input menu is applied.
*/

View File

@@ -4,6 +4,7 @@
></filter-input-renderer>
<filter-actions
[inputKey]="filterInput().key"
[canApply]="false"
[canApply]="canApply()"
(applied)="applied.emit()"
(reseted)="reseted.emit()"
></filter-actions>

View File

@@ -1,4 +1,9 @@
import { ChangeDetectionStrategy, Component, input, output } from '@angular/core';
import {
ChangeDetectionStrategy,
Component,
input,
output,
} from '@angular/core';
import { FilterInput } from '../../core';
import { FilterActionsComponent } from '../../actions';
import { InputRendererComponent } from '../../inputs/input-renderer';
@@ -30,4 +35,11 @@ export class FilterInputMenuComponent {
* Emits an event when the filter input is applied.
*/
applied = output<void>();
/**
* Indicates whether the filter can be applied.
* Defaults to false.
* @default false
*/
canApply = input<boolean>(false);
}

View File

@@ -0,0 +1,17 @@
<div class="w-full flex flex-col gap-4 items-center justify-center">
<span
class="bg-isa-accent-red rounded-[6.25rem] flex flex-row items-center justify-center p-3"
>
<ng-icon
class="text-isa-white"
size="1.5rem"
name="isaActionClose"
></ng-icon>
</span>
<p
class="isa-text-body-1-bold text-isa-neutral-900"
data-what="error-message"
>
{{ data.errorMessage }}
</p>
</div>

View File

@@ -0,0 +1,56 @@
import { createComponentFactory, Spectator } from '@ngneat/spectator/jest';
import {
FeedbackErrorDialogComponent,
FeedbackErrorDialogData,
} from './feedback-error-dialog.component';
import { DialogRef, DIALOG_DATA } from '@angular/cdk/dialog';
import { NgIcon } from '@ng-icons/core';
import { DialogComponent } from '../dialog.component';
// Test suite for FeedbackErrorDialogComponent
describe('FeedbackErrorDialogComponent', () => {
let spectator: Spectator<FeedbackErrorDialogComponent>;
const mockData: FeedbackErrorDialogData = {
errorMessage: 'Something went wrong',
};
const createComponent = createComponentFactory({
component: FeedbackErrorDialogComponent,
imports: [NgIcon],
providers: [
{
provide: DialogRef,
useValue: { close: jest.fn() },
},
{
provide: DIALOG_DATA,
useValue: mockData,
},
{
provide: DialogComponent,
useValue: {},
},
],
});
beforeEach(() => {
spectator = createComponent();
jest.clearAllMocks();
});
it('should create', () => {
expect(spectator.component).toBeTruthy();
});
it('should display the error message passed in data', () => {
const messageElement = spectator.query('[data-what="error-message"]');
expect(messageElement).toHaveText('Something went wrong');
});
it('should render the close icon', () => {
// The icon should be present with isaActionClose
const iconElement = spectator.query('ng-icon');
expect(iconElement).toBeTruthy();
expect(iconElement).toHaveAttribute('name', 'isaActionClose');
});
});

View File

@@ -0,0 +1,30 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { DialogContentDirective } from '../dialog-content.directive';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { isaActionClose } from '@isa/icons';
/**
* Input data for the error message dialog
*/
export interface FeedbackErrorDialogData {
/** The Error message text to display in the dialog */
errorMessage: string;
}
/**
* A simple feedback dialog component that displays an error message and an error icon.
*/
@Component({
selector: 'ui-feedback-error-dialog',
templateUrl: './feedback-error-dialog.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [NgIcon],
providers: [provideIcons({ isaActionClose })],
host: {
'[class]': '["ui-feedback-error-dialog"]',
},
})
export class FeedbackErrorDialogComponent extends DialogContentDirective<
FeedbackErrorDialogData,
void
> {}

View File

@@ -7,6 +7,7 @@ import {
injectTextInputDialog,
injectNumberInputDialog,
injectConfirmationDialog,
injectFeedbackErrorDialog,
} from './injects';
import { MessageDialogComponent } from './message-dialog/message-dialog.component';
import { DialogComponent } from './dialog.component';
@@ -17,6 +18,7 @@ import { TextInputDialogComponent } from './text-input-dialog/text-input-dialog.
import { FeedbackDialogComponent } from './feedback-dialog/feedback-dialog.component';
import { NumberInputDialogComponent } from './number-input-dialog/number-input-dialog.component';
import { ConfirmationDialogComponent } from './confirmation-dialog/confirmation-dialog.component';
import { FeedbackErrorDialogComponent } from './feedback-error-dialog/feedback-error-dialog.component';
// Test component extending DialogContentDirective for testing
@Component({ template: '' })
@@ -290,4 +292,23 @@ describe('Dialog Injects', () => {
expect(injector.get(DIALOG_CONTENT)).toBe(ConfirmationDialogComponent);
});
});
describe('injectFeedbackErrorDialog', () => {
it('should create a dialog injector for FeedbackErrorDialogComponent', () => {
// Act
const openFeedbackErrorDialog = TestBed.runInInjectionContext(() =>
injectFeedbackErrorDialog(),
);
openFeedbackErrorDialog({
data: {
errorMessage: 'Test error message',
},
});
// Assert
const callOptions = mockDialogOpen.mock.calls[0][1];
const injector = callOptions.injector;
expect(injector.get(DIALOG_CONTENT)).toBe(FeedbackErrorDialogComponent);
});
});
});

View File

@@ -21,6 +21,10 @@ import {
ConfirmationDialogComponent,
ConfirmationDialogData,
} from './confirmation-dialog/confirmation-dialog.component';
import {
FeedbackErrorDialogComponent,
FeedbackErrorDialogData,
} from './feedback-error-dialog/feedback-error-dialog.component';
export interface InjectDialogOptions {
/** Optional title override for the dialog */
@@ -173,3 +177,17 @@ export const injectFeedbackDialog = (
classList: ['gap-0'],
...options,
});
/**
* Convenience function that returns a pre-configured FeedbackErrorDialog injector
* @returns A function to open a feedback error dialog
*/
export const injectFeedbackErrorDialog = (
options?: OpenDialogOptions<FeedbackErrorDialogData>,
) =>
injectDialog(FeedbackErrorDialogComponent, {
disableClose: false,
minWidth: '20rem',
classList: ['gap-0'],
...options,
});

View File

@@ -1,3 +1,4 @@
export * from './lib/inject-restore-scroll-position';
export * from './lib/provide-scroll-position-restoration';
export * from './lib/store-scroll-position';
export * from './lib/scroll-top-button.component';

View File

@@ -0,0 +1,130 @@
import { TestBed } from '@angular/core/testing';
import { ComponentFixture } from '@angular/core/testing';
import { ScrollTopButtonComponent } from './scroll-top-button.component';
describe('ScrollTopButtonComponent (happy path)', () => {
let fixture: ComponentFixture<ScrollTopButtonComponent>;
let component: ScrollTopButtonComponent;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ScrollTopButtonComponent],
}).compileComponents();
fixture = TestBed.createComponent(ScrollTopButtonComponent);
component = fixture.componentInstance;
// Polyfill / Reset matchMedia für jedes Test-Setup
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: jest.fn().mockImplementation((query: string) => ({
matches: false, // Default: keine reduzierte Animation
media: query,
onchange: null,
addListener: jest.fn(), // deprecated, aber Angular / libs könnten darauf zugreifen
removeListener: jest.fn(),
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
})),
});
});
it('should create', () => {
// Act
fixture.detectChanges();
// Assert
expect(component).toBeTruthy();
});
it('should call scrollTo with smooth when prefers-reduced-motion is false', () => {
// Arrange
const targetEl = document.createElement('div');
(targetEl as any).scrollTo = jest.fn();
fixture.componentRef.setInput('target', targetEl);
// matchMedia default (set in beforeEach) returns matches: false
// Act
component.scrollTop();
// Assert
expect((targetEl as any).scrollTo).toHaveBeenCalledWith({
top: 0,
behavior: 'smooth',
});
});
it('should call scrollTo with auto when prefers-reduced-motion is true', () => {
// Arrange
(window.matchMedia as jest.Mock).mockImplementationOnce(
(query: string) => ({
matches: true, // reduzierte Bewegungen bevorzugt
media: query,
onchange: null,
addListener: jest.fn(),
removeListener: jest.fn(),
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
}),
);
const targetEl = document.createElement('div');
(targetEl as any).scrollTo = jest.fn();
fixture.componentRef.setInput('target', targetEl);
// Act
component.scrollTop();
// Assert
expect((targetEl as any).scrollTo).toHaveBeenCalledWith({
top: 0,
behavior: 'auto',
});
});
it('should render button when target element scrolled down', () => {
// Arrange
jest.useFakeTimers();
const targetEl = document.createElement('div');
(targetEl as any).scrollTo = jest.fn();
targetEl.scrollTop = 150; // > 0 so truthy
fixture.componentRef.setInput('target', targetEl);
// Act
fixture.detectChanges();
targetEl.dispatchEvent(new Event('scroll'));
jest.advanceTimersByTime(20); // allow debounceTime(10) to elapse
fixture.detectChanges();
// Assert
const button = fixture.nativeElement.querySelector(
'[data-what="scroll-top-button"]',
);
expect(button).not.toBeNull();
jest.useRealTimers();
});
it('should not render button when target element at top (scrollTop = 0)', () => {
// Arrange
jest.useFakeTimers();
const targetEl = document.createElement('div');
(targetEl as any).scrollTo = jest.fn();
targetEl.scrollTop = 0; // top position
fixture.componentRef.setInput('target', targetEl);
// Act
fixture.detectChanges();
targetEl.dispatchEvent(new Event('scroll'));
jest.advanceTimersByTime(20);
fixture.detectChanges();
// Assert
const button = fixture.nativeElement.querySelector(
'[data-what="scroll-top-button"]',
);
expect(button).toBeNull();
jest.useRealTimers();
});
});

View File

@@ -0,0 +1,75 @@
import {
ChangeDetectionStrategy,
Component,
computed,
input,
ViewEncapsulation,
} from '@angular/core';
import { provideIcons } from '@ng-icons/core';
import { isaSortByUpMedium } from '@isa/icons';
import { IconButtonComponent } from '@isa/ui/buttons';
import { toObservable, toSignal } from '@angular/core/rxjs-interop';
import { debounceTime, fromEvent, switchMap } from 'rxjs';
@Component({
selector: 'utils-scroll-top-button',
imports: [IconButtonComponent],
providers: [provideIcons({ isaSortByUpMedium })],
template: `
@if (display()) {
<button
uiIconButton
aria-label="Scroll to top"
type="button"
color="tertiary"
size="large"
data-what="scroll-top-button"
name="isaSortByUpMedium"
(click)="scrollTop()"
></button>
}
`,
host: {
'[class]': '["utils-scroll-top-button"]',
},
encapsulation: ViewEncapsulation.None,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ScrollTopButtonComponent {
/** The scroll target, either `window` or a specific element. */
target = input<Window | HTMLElement>(window);
/** Whether the target is an `HTMLElement`. */
isTargetElement = computed(() => this.target() instanceof HTMLElement);
/** The scroll event signal. */
scrollEvent = toSignal(
toObservable(this.target).pipe(
switchMap((target) => fromEvent(target, 'scroll').pipe(debounceTime(16))),
),
);
/** Whether to display the button. */
display = computed(() => {
this.scrollEvent();
const target = this.target();
if (target instanceof HTMLElement) {
return target.scrollTop;
}
return target.scrollY;
});
/** Scrolls to the top of the page. */
scrollTop() {
const prefersReducedMotion = window.matchMedia(
'(prefers-reduced-motion: reduce)',
).matches; // Anforderung im Ticket
this.target().scrollTo({
top: 0,
behavior: prefersReducedMotion ? 'auto' : 'smooth',
});
}
}