Compare commits

...

26 Commits

Author SHA1 Message Date
Nino Righi
4c56f394c5 Merged PR 1972: hotfix(remission-list-item, remission-list-empty-state): improve empty state...
hotfix(remission-list-item, remission-list-empty-state): improve empty state logic and cleanup selected items on destroy

Refactor empty state display conditions in remission-list-empty-state component
to correctly handle search term validation. Move hasValidSearchTerm check to
parent condition to prevent displaying empty states during active searches.

Add ngOnDestroy lifecycle hook to remission-list-item component to properly
clean up selected quantities from the store when items are removed from the list.
This prevents memory leaks and ensures the store state remains synchronized with
the displayed items.

Changes:
- Move hasValidSearchTerm check in displayEmptyState computed signal to improve
  empty state display logic
- Implement OnDestroy interface in RemissionListItemComponent
- Add removeItem call in ngOnDestroy to clean up store state
- Add corresponding unit tests for the cleanup behavior

Ref: #5387
2025-10-17 12:09:55 +00:00
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 Righi
cffa7721bc Merged PR 1941: fix(oms-data-access): adjust tolino return eligibility logic for display damage
fix(oms-data-access): adjust tolino return eligibility logic for display damage

Update tolino return eligibility to check for display damage and refine
date range conditions. Returns are now only eligible if the receipt is
between 6-24 months old, the item was received damaged, and the display
is not damaged.

Ref: #5286
2025-09-04 15:12:44 +00:00
Nino Righi
066ab5d5be Merged PR 1940: feat(old-ui-tooltip): add pointer-events-auto to tooltip panel
feat(old-ui-tooltip): add pointer-events-auto to tooltip panel

Enable mouse interactions with tooltip content by adding pointer-events-auto
class to .ui-tooltip-panel. This allows users to interact with clickable
elements inside tooltips while maintaining proper tooltip positioning.

Ref: #5244
2025-09-04 14:11:49 +00:00
Nino Righi
3bbf79a3c3 Merged PR 1939: feat(remission-list, empty-state): add comprehensive empty state handling wit...
feat(remission-list, empty-state): add comprehensive empty state handling with new appearance types

Add dedicated empty state component for remission list with smart prioritization logic:
- Department selection required state (highest priority)
- All done state when list is processed and empty
- No search results state for filtered content

Enhance ui-empty-state component with new appearance types:
- AllDone: Trophy cup icon with animated steam effects
- SelectAction: Hand pointer with dropdown interface element
- Improved visual hierarchy and spacing for all states

Update remission list to use new empty state component with proper state detection
including search term validation, department filter checking, and reload detection.

Ref: #5317, #5290
2025-09-04 14:11:19 +00:00
Nino Righi
357485e32f Merged PR 1938: #5294 Small Adjustments
#5294 Small Adjustments
2025-09-04 14:10:55 +00:00
Nino Righi
39984342a6 Merged PR 1937: fix(ui-input-controls-dropdown): prevent multiple dropdowns from being open s...
fix(ui-input-controls-dropdown): prevent multiple dropdowns from being open simultaneously

Add DropdownService to manage global dropdown state and ensure only one
dropdown is open at any time. When a new dropdown opens, any previously
opened dropdown is automatically closed, improving user experience and
preventing UI conflicts.

Ref: #5298
2025-09-03 13:19:10 +00:00
Nino Righi
c52f18e979 Merged PR 1936: fix(remission): filter search results by stock availability and display stock...
fix(remission): filter search results by stock availability and display stock info

- Add stock resource integration to search item component
- Filter search results to only show items with available stock (> 0)
- Display current stock information in search result items
- Implement calculateAvailableStock utility for accurate stock calculation
- Add inStock input parameter to SearchItemToRemitComponent
- Create reusable instock.resource for stock data fetching

The search now only displays items that are actually available for remission,
improving user experience by preventing selection of out-of-stock items.

Ref: #5318
2025-09-03 13:18:47 +00:00
Nino Righi
e58ec93087 Merged PR 1935: fix(remission-list, remission-data-access): add impediment comment and remain...
fix(remission-list, remission-data-access): add impediment comment and remaining quantity handling for return suggestions

Add support for impedimentComment and remainingQuantity fields when adding return suggestion items. When quantity is less than available stock, automatically set impedimentComment to 'Restmenge' and calculate remainingQuantity as the difference between available stock and remitted quantity.

Changes:
- Add impedimentComment and remainingQuantity to AddReturnSuggestionItemSchema
- Update RemissionReturnReceiptService to handle new fields in addReturnSuggestionItem method
- Enhance RemissionListComponent to calculate and pass impediment data when remitting items
- Fix quantity calculation logic to properly handle partial remissions

Ref: #5322
2025-09-03 13:18:23 +00:00
Nino Righi
4e6204817d Merged PR 1934: feature(remission-list): temporarily disable remission-processed-hint component
feature(remission-list): temporarily disable remission-processed-hint component

Comment out remi-remission-processed-hint component in remission list template
and add TODO comments referencing the need to adjust code once ticket #5215
is implemented. This temporary fix prevents issues with the hint component
until the underlying changes are completed.

Ref: #5136
2025-09-03 13:16:35 +00:00
Nino Righi
c41355bcdf Merged PR 1933: fix(remission-data-access): replace hardcoded values with dynamic helper func...
fix(remission-data-access): replace hardcoded values with dynamic helper functions

Replace hardcoded assortment and retail price values in RemissionSearchService
with proper helper functions. Add getAssortmentFromItem and getRetailPriceFromItem
helpers to dynamically extract values from Item objects instead of using
static fallbacks.

Also fix potential undefined reference errors in remission list resource
by adding proper null checks for response merging operations.

Ref: #5321
2025-09-03 13:15:57 +00:00
Nino Righi
fa8e601660 Merged PR 1932: feat(remission): ensure package assignment before completing return receipts
feat(remission): ensure package assignment before completing return receipts

Add validation to check if a package is assigned to a return receipt before
allowing completion. When no package is assigned, automatically open the
package assignment dialog to let users scan/input a package number.

- Add hasAssignedPackage input to complete component and pass from parent
- Integrate RemissionStartService.assignPackage() in completion flow
- Add assignPackageOnly flag to conditionally hide step counter in dialog
- Update dialog data structure to support direct package assignment mode
- Enhance test coverage for new assignment scenarios

This ensures all completed return receipts have proper package tracking
and improves the user workflow by guiding them through required steps.

Ref: #5289
2025-09-03 13:15:32 +00:00
Nino Righi
708ec01704 Merged PR 1931: fix(remission-quantity-and-reason-item)
fix(remission-quantity-and-reason-item)
Ref: #5292
2025-09-02 15:20:44 +00:00
Nino Righi
332699ca74 Merged PR 1930: fix(remission-quantity-and-reason-item): correct quantity input binding and d...
fix(remission-quantity-and-reason-item): correct quantity input binding and default value

Fix quantity input field binding to use computed quantity signal instead of
direct quantityAndReason().quantity, ensuring proper display of undefined
values as empty field. Update initial quantity default from 1 to 0 to
prevent pre-filled values when creating new quantity/reason items.

Also improves placeholder text color contrast by changing from neutral-200
to neutral-500 for better accessibility.

Ref: #5292
2025-09-02 15:20:14 +00:00
Nino
2cb1f9ec99 chore(azure-pipelines): Version bump 4.1 2025-08-14 17:09:13 +02:00
88 changed files with 2236 additions and 1039 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

@@ -17,6 +17,8 @@
}
.ui-tooltip-panel {
@apply pointer-events-auto;
.triangle {
width: 30px;
polygon {

View File

@@ -17,6 +17,7 @@ import { provideRouter } from '@angular/router';
type ProductInfoInputs = {
item: ProductInfoItem;
orientation: ProductInfoOrientation;
innerGridClass: string;
};
const meta: Meta<ProductInfoInputs> = {
@@ -53,6 +54,7 @@ const meta: Meta<ProductInfoInputs> = {
tag: 'Prio 2',
},
orientation: 'horizontal',
innerGridClass: 'grid-cols-[minmax(20rem,1fr),auto]',
},
argTypes: {
item: {
@@ -69,6 +71,16 @@ const meta: Meta<ProductInfoInputs> = {
},
},
},
innerGridClass: {
control: 'text',
description:
'Custom CSS classes for the inner grid layout. (Applies on vertical layout only)',
table: {
defaultValue: {
summary: 'grid-cols-[minmax(20rem,1fr),auto]',
},
},
},
},
render: (args) => ({
props: args,

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

@@ -51,14 +51,24 @@ export const isTolinoEligibleForReturn = (
};
}
// #5286 Anpassung des Tolino-Rückgabeflows (+ siehe Kommentare)
const displayDamaged =
answers[ReturnProcessQuestionKey.DisplayDamaged] === YesNoAnswer.Yes;
const receivedDamaged = itemDamaged === ReturnReasonAnswer.ReceivedDamaged;
const receiptOlderThan6Months = date
? differenceInCalendarMonths(new Date(), parseISO(date)) >= 6
: undefined;
const receiptOlderThan24Months = date
? differenceInCalendarMonths(new Date(), parseISO(date)) >= 24
: undefined;
if (
itemDamaged === ReturnReasonAnswer.ReceivedDamaged &&
receiptOlderThan6Months
) {
const isEligible =
receiptOlderThan6Months &&
!receiptOlderThan24Months &&
receivedDamaged &&
!displayDamaged;
if (!isEligible) {
return {
state: EligibleForReturnState.NotEligible,
reason: 'Keine Retoure möglich',

View File

@@ -0,0 +1,21 @@
import { Item } from '@isa/catalogue/data-access';
/**
* Helper function to extract the assortment string from an Item object.
* The assortment is constructed by concatenating the value and the last character of the key
* for each feature in the item's features array.
* @param {Item} item - The item object from which to extract the assortment
* @returns {string} The constructed assortment string
*/
export const getAssortmentFromItem = (item: Item): string => {
if (!item.features || item.features.length === 0) {
return '';
}
return item.features.reduce((acc, feature) => {
const value = feature.value ?? '';
const key = feature.key ?? '';
const lastChar = key.slice(-1); // gibt '' zurück, wenn key leer ist
return acc + `${value}|${lastChar}`;
}, '');
};

View File

@@ -0,0 +1,24 @@
import { Item } from '@isa/catalogue/data-access';
import { Price } from '../models';
/**
* Helper function to extract the retail price from an Item object.
* The function first checks for store-specific availabilities and falls back to the catalog availability if none are found.
* @param {Item} item - The item object from which to extract the retail price
* @returns {Price | undefined} The retail price if available, otherwise undefined
*/
export const getRetailPriceFromItem = (item: Item): Price | undefined => {
let availability = item?.storeAvailabilities?.find((f) => !!f);
if (!availability) {
availability = item?.catalogAvailability;
}
if (!availability.price) {
return {
value: { value: 0, currency: 'EUR' },
};
}
return availability.price as Price;
};

View File

@@ -7,3 +7,6 @@ export * from './get-receipt-item-quantity-from-return.helper';
export * from './get-receipt-number-from-return.helper';
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

@@ -6,6 +6,8 @@ export const AddReturnSuggestionItemSchema = z.object({
returnSuggestionId: z.number(),
quantity: z.number().optional(),
inStock: z.number(),
impedimentComment: z.string().optional(),
remainingQuantity: z.number().optional(),
});
export type AddReturnSuggestionItem = z.infer<

View File

@@ -5,7 +5,6 @@ import {
ResponseArgsError,
takeUntilAborted,
} from '@isa/common/data-access';
import { subDays } from 'date-fns';
import { firstValueFrom } from 'rxjs';
import { RemissionStockService } from './remission-stock.service';
import { Return } from '../models/return';
@@ -770,6 +769,8 @@ export class RemissionReturnReceiptService {
* returnSuggestionId: 789,
* quantity: 10,
* inStock: 5,
* impedimentComment: 'Restmenge',
* remainingQuantity: 5
* });
*/
async addReturnSuggestionItem(
@@ -778,8 +779,15 @@ export class RemissionReturnReceiptService {
): Promise<ReceiptReturnSuggestionTuple | undefined> {
this.#logger.debug('Adding return suggestion item', () => ({ params }));
const { returnId, receiptId, returnSuggestionId, quantity, inStock } =
AddReturnSuggestionItemSchema.parse(params);
const {
returnId,
receiptId,
returnSuggestionId,
quantity,
inStock,
impedimentComment,
remainingQuantity,
} = AddReturnSuggestionItemSchema.parse(params);
this.#logger.info('Add return suggestion item from API', () => ({
returnId,
@@ -787,6 +795,8 @@ export class RemissionReturnReceiptService {
returnSuggestionId,
quantity,
inStock,
impedimentComment,
remainingQuantity,
}));
let req$ = this.#returnService.ReturnAddReturnSuggestion({
@@ -796,6 +806,8 @@ export class RemissionReturnReceiptService {
returnSuggestionId,
quantity,
inStock,
impedimentComment,
remainingQuantity,
},
});
@@ -921,6 +933,10 @@ export class RemissionReturnReceiptService {
returnSuggestionId: itemId,
quantity: addItem.quantity,
inStock: addItem.inStock,
impedimentComment: (addItem as AddReturnSuggestionItem)
.impedimentComment,
remainingQuantity: (addItem as AddReturnSuggestionItem)
.remainingQuantity,
});
}

View File

@@ -26,6 +26,7 @@ import {
import { logger } from '@isa/core/logging';
import { Item } from '@isa/catalogue/data-access';
import { RemissionStockService } from './remission-stock.service';
import { getAssortmentFromItem, getRetailPriceFromItem } from '../helpers';
/**
* Service responsible for remission search operations.
@@ -387,9 +388,9 @@ export class RemissionSearchService {
let req = this.#remiService.RemiCanAddReturnItem({
data: items.map((i) => ({
product: i.item.product,
assortment: 'Basissortiment|B',
assortment: getAssortmentFromItem(i.item),
predefinedReturnQuantity: i.quantity,
retailPrice: i.item.catalogAvailability.price,
retailPrice: getRetailPriceFromItem(i.item),
source: 'manually-added',
returnReason: i.reason,
stock: { id: stock.id },
@@ -427,9 +428,9 @@ export class RemissionSearchService {
...i.item.product,
catalogProductNumber: String(i.item.id),
},
assortment: 'Basissortiment|B',
assortment: getAssortmentFromItem(i.item),
predefinedReturnQuantity: i.quantity,
retailPrice: i.item.catalogAvailability.price,
retailPrice: getRetailPriceFromItem(i.item),
source: 'manually-added',
returnReason: i.reason,
stock: { id: stock.id },

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

@@ -0,0 +1,10 @@
@let emptyState = displayEmptyState();
@if (emptyState) {
<ui-empty-state
class="w-full justify-self-center"
[appearance]="emptyState.appearance"
[title]="emptyState.title"
[description]="emptyState.description"
>
</ui-empty-state>
}

View File

@@ -0,0 +1,90 @@
import {
ChangeDetectionStrategy,
Component,
input,
computed,
inject,
} from '@angular/core';
import { FilterService } from '@isa/shared/filter';
import { EmptyStateComponent, EmptyStateAppearance } from '@isa/ui/empty-state';
type EmptyState =
| {
title: string;
description: string;
appearance: EmptyStateAppearance;
}
| undefined;
@Component({
selector: 'remi-feature-remission-list-empty-state',
templateUrl: './remission-list-empty-state.component.html',
styleUrl: './remission-list-empty-state.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [EmptyStateComponent],
})
export class RemissionListEmptyStateComponent {
/**
* FilterService instance for managing filter state and queries.
* @private
*/
#filterService = inject(FilterService);
listFetching = input<boolean>();
isDepartment = input<boolean>();
isReloadSearch = input<boolean>();
hasValidSearchTerm = input<boolean>();
hits = input<number>();
/**
* Computed signal that determines the appropriate empty state to display
* based on the current state of the remission list, search term, and filters.
* @returns An EmptyState object with title, description, and appearance, or undefined if no empty state should be shown.
* The priority for empty states is as follows:
* 1. Department list with no department selected.
* 2. All done state when the list is fully processed and no items remain.
* 3. No results state when there are no items matching the current search and filters.
* If none of these conditions are met, returns undefined.
* @see EmptyStateAppearance for possible appearance values.
* @remarks This logic ensures that the most relevant empty state is shown to the user based on their current context.
*/
displayEmptyState = computed<EmptyState>(() => {
if (!this.listFetching() && !this.hasValidSearchTerm()) {
// Prio 1: Abteilungsremission - Es ist noch keine Abteilung ausgewählt
if (
this.isDepartment() &&
!this.#filterService.query()?.filter['abteilungen']
) {
return {
title: 'Abteilung auswählen',
description:
'Wählen Sie zuerst eine Abteilung, anschließend werden die entsprechenden Positionen angezeigt.',
appearance: EmptyStateAppearance.SelectAction,
};
}
// Prio 2: Liste abgearbeitet und keine Artikel mehr vorhanden
if (
this.hits() === 0 &&
this.isReloadSearch()
) {
return {
title: 'Alles erledigt',
description: 'Hier gibt es gerade nichts zu tun',
appearance: EmptyStateAppearance.AllDone,
};
}
// Prio 3: Keine Ergebnisse bei leerem Suchbegriff (nur Filter gesetzt)
if (this.hits() === 0) {
return {
title: 'Keine Suchergebnisse',
description:
'Bitte prüfen Sie die Schreibweise oder ändern Sie die Filtereinstellungen.',
appearance: EmptyStateAppearance.NoResults,
};
}
}
return undefined;
});
}

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

@@ -46,6 +46,7 @@ jest.mock('@isa/remission/data-access', () => ({
// Mock the RemissionStore
const mockRemissionStore = {
selectedQuantity: signal({}),
removeItem: jest.fn(),
};
describe('RemissionListItemComponent', () => {
@@ -112,6 +113,7 @@ describe('RemissionListItemComponent', () => {
// Reset mocks before each test
jest.clearAllMocks();
mockRemissionStore.selectedQuantity.set({});
mockRemissionStore.removeItem.mockClear();
// Reset the mocked functions to return default values
const {
@@ -176,19 +178,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();
});
});
@@ -720,4 +714,37 @@ describe('RemissionListItemComponent', () => {
});
});
});
describe('ngOnDestroy', () => {
it('should remove item from store when component is destroyed', () => {
// Arrange
const mockItem = createMockReturnItem({ id: 123 });
fixture.componentRef.setInput('item', mockItem);
fixture.componentRef.setInput('stock', createMockStockInfo());
fixture.detectChanges();
// Act
component.ngOnDestroy();
// Assert
expect(mockRemissionStore.removeItem).toHaveBeenCalledWith(123);
expect(mockRemissionStore.removeItem).toHaveBeenCalledTimes(1);
});
it('should not call removeItem when item has no id', () => {
// Arrange
const mockItem = createMockReturnItem({ id: undefined });
fixture.componentRef.setInput('item', mockItem);
fixture.componentRef.setInput('stock', createMockStockInfo());
fixture.detectChanges();
// Act
component.ngOnDestroy();
// Assert
expect(mockRemissionStore.removeItem).not.toHaveBeenCalled();
});
});
});

View File

@@ -4,7 +4,8 @@ import {
computed,
inject,
input,
model,
OnDestroy,
output,
} from '@angular/core';
import { FormsModule } from '@angular/forms';
import {
@@ -15,6 +16,7 @@ import {
ReturnItem,
ReturnSuggestion,
StockInfo,
UpdateItem,
} from '@isa/remission/data-access';
import {
ProductInfoComponent,
@@ -59,7 +61,7 @@ import { LabelComponent, Labeltype } from '@isa/ui/label';
LabelComponent,
],
})
export class RemissionListItemComponent {
export class RemissionListItemComponent implements OnDestroy {
/**
* Type of label to display for the item.
* Defaults to 'tag', can be changed to 'notice' or other types as needed.
@@ -103,11 +105,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.
@@ -219,4 +220,16 @@ export class RemissionListItemComponent {
const attempts = this.item()?.impediment?.attempts;
return `${comment}${attempts ? ` (${attempts})` : ''}`;
});
/**
* Cleans up the selected item from the store when the component is destroyed.
* Removes the item using its ID.
* @returns void
*/
ngOnDestroy(): void {
const itemId = this.item()?.id;
if (itemId) {
this.#store.removeItem(itemId);
}
}
}

View File

@@ -1,4 +1,5 @@
<remi-remission-processed-hint></remi-remission-processed-hint>
<!-- TODO: #5136 - Code innerhalb remi-remission-processed-hint anpassen sobald Ticket #5215 umgesetzt ist -->
<!-- <remi-remission-processed-hint></remi-remission-processed-hint> -->
@if (!remissionStarted()) {
<remi-feature-remission-start-card></remi-feature-remission-start-card>
@@ -22,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
@@ -31,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">
@@ -44,11 +45,23 @@
</div>
}
}
<remi-feature-remission-list-empty-state
[listFetching]="listFetching()"
[isDepartment]="isDepartment()"
[isReloadSearch]="searchTrigger() === 'reload'"
[hasValidSearchTerm]="hasValidSearchTerm()"
[hits]="hits()"
></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"
@@ -62,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,15 +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'];
@@ -90,6 +99,8 @@ function querySettingsFactory() {
StatefulButtonComponent,
RemissionListDepartmentElementsComponent,
RemissionProcessedHintComponent,
RemissionListEmptyStateComponent,
ScrollTopButtonComponent,
],
host: {
'[class]':
@@ -116,6 +127,7 @@ export class RemissionListComponent {
activatedTabId = injectTabId();
searchItemToRemitDialog = injectDialog(SearchItemToRemitDialogComponent);
errorDialog = injectFeedbackErrorDialog();
/**
* FilterService instance for managing filter state and queries.
@@ -165,7 +177,25 @@ 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.
* @returns The current search term string or undefined if not set.
*/
searchTerm = computed<string | undefined>(() => {
return this.#filterService.query()?.input['qs'] ?? '';
});
/**
* Computed signal indicating whether there is a valid search term.
* A valid search term is defined as a non-empty string.
* @returns True if there is a valid search term, false otherwise.
*/
hasValidSearchTerm = computed(() => {
const searchTerm = this.searchTerm();
return !!searchTerm && searchTerm.length > 0;
});
/**
* Resource signal for fetching the remission list based on current filters.
@@ -208,6 +238,12 @@ export class RemissionListComponent {
*/
listResponseValue = computed(() => this.remissionResource.value());
/**
* Computed signal indicating whether the remission list resource is currently fetching data.
* @returns True if fetching, false otherwise.
*/
listFetching = computed(() => this.remissionResource.status() === 'loading');
/**
* Computed signal for the current in-stock response.
* @returns Array of StockInfo or undefined.
@@ -230,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 : [];
});
@@ -335,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);
}
}
@@ -365,35 +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) {
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: this.#filterService.query()?.input['qs'] || '',
isDepartment: this.isDepartment(),
searchTerm,
},
}).closed.subscribe(async (result) => {
this.#store.clearSelectedItems();
if (result) {
if (this.remissionStarted()) {
for (const item of result) {
@@ -405,10 +472,8 @@ export class RemissionListComponent {
} else if (this.isDepartment()) {
return await this.navigateToDefaultRemissionList();
}
this.reloadListAndReturnData();
this.searchTrigger.set('reload');
}
this.reloadListAndReturnData();
});
});
});
@@ -442,13 +507,12 @@ export class RemissionListComponent {
const remissionItemIdNumber = Number(remissionItemId);
const quantity = quantities[remissionItemIdNumber];
const inStock = this.getAvailableStockForItem(item);
const stockToRemit =
quantity ??
getStockToRemit({
remissionItem: item,
remissionListType,
availableStock: inStock,
});
const stockToRemit = getStockToRemit({
remissionItem: item,
remissionListType,
availableStock: inStock,
});
const quantityToRemit = quantity ?? stockToRemit;
if (returnId && receiptId) {
await this.#remissionReturnReceiptService.remitItem({
@@ -456,24 +520,22 @@ export class RemissionListComponent {
addItem: {
returnId,
receiptId,
quantity: stockToRemit,
quantity: quantityToRemit,
inStock,
impedimentComment: stockToRemit > quantity ? 'Restmenge' : '',
remainingQuantity:
isNaN(quantity) || inStock - quantity <= 0
? undefined
: inStock - quantity,
},
type: remissionListType,
});
}
}
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();
@@ -485,10 +547,67 @@ export class RemissionListComponent {
* This method is used to refresh the displayed data after changes.
*/
reloadListAndReturnData() {
this.searchTrigger.set('reload');
this.remissionResource.reload();
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

@@ -11,6 +11,8 @@ import { RemissionReturnReceiptService } from '@isa/remission/data-access';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { subDays } from 'date-fns';
// TODO: #5136 - Code anpassen sobald Ticket #5215 umgesetzt ist
// HTML in remission-list.component.html ist auskommentiert
@Component({
selector: 'remi-remission-processed-hint',
templateUrl: './remission-processed-hint.component.html',

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';
/**
@@ -120,9 +120,17 @@ export const createRemissionListResource = (
...(res.result || []),
...(fetchDepartmentListResponse.result || []),
];
res.hits += fetchDepartmentListResponse.hits;
res.skip += fetchDepartmentListResponse.skip;
res.take += fetchDepartmentListResponse.take;
if (fetchDepartmentListResponse?.hits) {
res.hits += fetchDepartmentListResponse.hits;
}
if (fetchDepartmentListResponse?.skip) {
res.skip += fetchDepartmentListResponse?.skip;
}
if (fetchDepartmentListResponse?.take) {
res.take += fetchDepartmentListResponse?.take;
}
} else {
res = fetchDepartmentListResponse;
}
@@ -136,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;
@@ -144,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

@@ -41,7 +41,6 @@
appearance="noArticles"
>
<lib-remission-return-receipt-actions
class="mt-[1.5rem]"
[remissionReturn]="returnData()"
[displayDeleteAction]="false"
(reloadData)="returnResource.reload()"
@@ -56,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" />
@@ -68,6 +67,7 @@
<lib-remission-return-receipt-complete
[returnId]="returnId()"
[receiptId]="receiptId()"
[hasAssignedPackage]="hasAssignedPackage()"
[itemsLength]="items?.length"
(reloadData)="returnResource.reload()"
></lib-remission-return-receipt-complete>

View File

@@ -14,6 +14,7 @@ import { RemissionReturnReceiptDetailsItemComponent } from './remission-return-r
import { Location } from '@angular/common';
import { createReturnResource } from './resources/return.resource';
import {
getPackageNumbersFromReturn,
getReceiptItemsFromReturn,
getReceiptNumberFromReturn,
} from '@isa/remission/data-access';
@@ -105,4 +106,9 @@ export class RemissionReturnReceiptDetailsComponent {
const returnData = this.returnData();
return !!returnData && !returnData.completed;
});
hasAssignedPackage = computed(() => {
const returnData = this.returnData();
return getPackageNumbersFromReturn(returnData!) !== '';
});
}

View File

@@ -25,11 +25,11 @@
</div>
<div
class="grid"
[class.grid-cols-[minmax(20rem,1fr),auto]]="!horizontal"
[ngClass]="!horizontal ? innerGridClass() : ''"
[class.gap-6]="!horizontal"
[class.grid-flow-row]="horizontal"
[class.gap-2]="horizontal"
class="grid"
>
<div class="grid grid-flow-row gap-2">
<div class="isa-text-body-2-bold" data-what="product-contributors">

View File

@@ -1,6 +1,6 @@
import { CurrencyPipe } from '@angular/common';
import { CurrencyPipe, NgClass } from '@angular/common';
import { Component, input } from '@angular/core';
import { RemissionItem, ReturnItem } from '@isa/remission/data-access';
import { RemissionItem } from '@isa/remission/data-access';
import { ProductImageDirective } from '@isa/shared/product-image';
import { ProductRouterLinkDirective } from '@isa/shared/product-router-link';
import { ProductFormatComponent } from '@isa/shared/product-foramt';
@@ -23,6 +23,7 @@ export const RemissionItemTags = {
selector: 'remi-product-info',
templateUrl: 'product-info.component.html',
imports: [
NgClass,
ProductImageDirective,
ProductRouterLinkDirective,
CurrencyPipe,
@@ -50,4 +51,6 @@ export class ProductInfoComponent {
item = input.required<ProductInfoItem>();
orientation = input<ProductInfoOrientation>('horizontal');
innerGridClass = input<string>('grid-cols-[minmax(20rem,1fr),auto]');
}

View File

@@ -1,8 +1,10 @@
<span
class="w-full flex items-center justify-center text-isa-neutral-900 isa-text-body-2-bold"
>
2/2
</span>
@if (!assignPackageOnly()) {
<span
class="w-full flex items-center justify-center text-isa-neutral-900 isa-text-body-2-bold"
>
2/2
</span>
}
<div class="flex flex-col gap-4">
<h2 class="isa-text-subtitle-1-bold flex-shrink-0" data-what="title">
Wannennummer Scannen

View File

@@ -71,6 +71,9 @@ import { RequestStatus } from './remission-start-dialog.component';
],
})
export class AssignPackageNumberComponent {
/** Input flag indicating if the dialog is opened for package assignment only */
assignPackageOnly = input<boolean>(false);
/**
* Input signal containing the current request status for the assign package operation.
* Used to display loading states and handle server-side validation errors.

View File

@@ -1,10 +1,11 @@
@if (!assignPackageStepData()) {
@if (!assignPackageStepData() && !data?.assignPackage) {
<remi-create-return-receipt
(createReturnReceipt)="onCreateReturnReceipt($event)"
[createRemissionLoading]="createRemissionRequestStatus()"
></remi-create-return-receipt>
} @else {
<remi-assign-package-number
[assignPackageOnly]="!!data?.assignPackage"
(assignPackageNumber)="onAssignPackageNumber($event)"
[assignPackageLoading]="assignPackageRequestStatus()"
></remi-assign-package-number>

View File

@@ -59,6 +59,14 @@ export type RequestStatus = {
export type RemissionStartDialogData = {
/** The return group identifier for the remission process */
returnGroup: string | undefined;
/** #5289 - Flag indicating if the dialog is opened for package assignment only */
assignPackage?:
| {
returnId: number;
receiptId: number;
}
| undefined;
};
/**
@@ -220,17 +228,20 @@ export class RemissionStartDialogComponent extends DialogContentDirective<
packageNumber: string | undefined,
): Promise<void> {
this.assignPackageRequestStatus.set({ loading: true });
const data = this.assignPackageStepData();
const data = this.assignPackageStepData() ?? this.data?.assignPackage;
if (!data || !packageNumber) {
return this.onDialogClose(undefined);
}
const returnId = data.returnId;
const receiptId = data.receiptId;
try {
const response = await this.#remissionReturnReceiptService.assignPackage({
packageNumber,
returnId: data.returnId,
receiptId: data.receiptId,
returnId,
receiptId,
});
if (!response) {
@@ -238,8 +249,8 @@ export class RemissionStartDialogComponent extends DialogContentDirective<
}
this.onDialogClose({
returnId: data.returnId,
receiptId: data.receiptId,
returnId,
receiptId,
});
this.assignPackageRequestStatus.set({ loading: false });
} catch (error: any) {

View File

@@ -38,23 +38,120 @@ describe('RemissionStartService', () => {
service = TestBed.inject(RemissionStartService);
});
it('should start remission successfully when dialog returns result', async () => {
// Arrange
const returnGroup = 'test-return-group';
describe('startRemission', () => {
it('should start remission successfully when dialog returns result', async () => {
// Arrange
const returnGroup = 'test-return-group';
// Act
await service.startRemission(returnGroup);
// Act
await service.startRemission(returnGroup);
// Assert
expect(mockDialog).toHaveBeenCalledWith({
data: { returnGroup },
classList: ['gap-0'],
width: '30rem',
// Assert
expect(mockDialog).toHaveBeenCalledWith({
data: { returnGroup },
classList: ['gap-0'],
width: '30rem',
});
expect(mockRemissionStore.startRemission).toHaveBeenCalledWith({
returnId: 'test-return-id',
receiptId: 'test-receipt-id',
});
});
expect(mockRemissionStore.startRemission).toHaveBeenCalledWith({
returnId: 'test-return-id',
receiptId: 'test-receipt-id',
it('should handle undefined returnGroup', async () => {
// Arrange
const returnGroup = undefined;
// Act
await service.startRemission(returnGroup);
// Assert
expect(mockDialog).toHaveBeenCalledWith({
data: { returnGroup: undefined },
classList: ['gap-0'],
width: '30rem',
});
expect(mockRemissionStore.startRemission).toHaveBeenCalledWith({
returnId: 'test-return-id',
receiptId: 'test-receipt-id',
});
});
it('should not call startRemission when dialog returns falsy result', async () => {
// Arrange
const returnGroup = 'test-return-group';
mockDialogRef.closed = of(null);
// Act
await service.startRemission(returnGroup);
// Assert
expect(mockDialog).toHaveBeenCalledWith({
data: { returnGroup },
classList: ['gap-0'],
width: '30rem',
});
expect(mockRemissionStore.startRemission).not.toHaveBeenCalled();
});
});
describe('assignPackage', () => {
it('should open dialog with correct assignPackage data and return result', async () => {
// Arrange
const returnId = 12345;
const receiptId = 67890;
const expectedResult = {
returnId: 'test-return-id',
receiptId: 'test-receipt-id',
};
mockDialogRef.closed = of(expectedResult);
// Act
const result = await service.assignPackage({ returnId, receiptId });
// Assert
expect(mockDialog).toHaveBeenCalledWith({
data: {
returnGroup: undefined,
assignPackage: {
returnId,
receiptId,
},
},
classList: ['gap-0'],
width: '30rem',
});
expect(result).toEqual(expectedResult);
expect(mockRemissionStore.startRemission).not.toHaveBeenCalled();
});
it('should handle null result from dialog', async () => {
// Arrange
const returnId = 12345;
const receiptId = 67890;
mockDialogRef.closed = of(null);
// Act
const result = await service.assignPackage({ returnId, receiptId });
// Assert
expect(mockDialog).toHaveBeenCalledWith({
data: {
returnGroup: undefined,
assignPackage: {
returnId,
receiptId,
},
},
classList: ['gap-0'],
width: '30rem',
});
expect(result).toBeNull();
});
});
});

View File

@@ -26,4 +26,26 @@ export class RemissionStartService {
});
}
}
// #5289 - Bei WBS ohne Wannennummer, soll man nur die Wannennummer generieren können
async assignPackage({
returnId,
receiptId,
}: {
returnId: number;
receiptId: number;
}) {
const remissionStartDialogRef = this.#remissionStartDialog({
data: {
returnGroup: undefined,
assignPackage: {
returnId,
receiptId,
},
},
classList: ['gap-0'],
width: '30rem',
});
return await firstValueFrom(remissionStartDialogRef.closed);
}
}

View File

@@ -61,6 +61,7 @@ describe('RemissionReturnReceiptCompleteComponent', () => {
mockRemissionStartService = {
startRemission: vi.fn(),
assignPackage: vi.fn(),
};
mockRouter = {
@@ -108,6 +109,7 @@ describe('RemissionReturnReceiptCompleteComponent', () => {
fixture.componentRef.setInput('returnId', 123);
fixture.componentRef.setInput('receiptId', 456);
fixture.componentRef.setInput('itemsLength', 5);
fixture.componentRef.setInput('hasAssignedPackage', true);
fixture.detectChanges();
});
@@ -121,6 +123,7 @@ describe('RemissionReturnReceiptCompleteComponent', () => {
expect(component.returnId()).toBe(123);
expect(component.receiptId()).toBe(456);
expect(component.itemsLength()).toBe(5);
expect(component.hasAssignedPackage()).toBe(true);
expect(component.completingRemission()).toBe(false);
});
});
@@ -150,10 +153,9 @@ describe('RemissionReturnReceiptCompleteComponent', () => {
});
describe('completeRemission', () => {
it('should complete remission without return group', async () => {
it('should complete remission with package already assigned and no return group', async () => {
// Arrange
const mockReturn = { id: 123, returnGroup: null };
mockRemissionReturnReceiptService.completeReturnReceiptAndReturn.mockResolvedValue(
mockReturn,
);
@@ -166,10 +168,114 @@ describe('RemissionReturnReceiptCompleteComponent', () => {
// Assert
expect(component.completingRemission()).toBe(false);
expect(mockRemissionStartService.assignPackage).not.toHaveBeenCalled();
expect(mockInjectConfirmationDialog).not.toHaveBeenCalled();
expect(reloadDataSpy).toHaveBeenCalled();
});
it('should complete remission without package assigned and assign package successfully', async () => {
// Arrange
fixture.componentRef.setInput('hasAssignedPackage', false);
fixture.detectChanges();
const mockReturn = { id: 123, returnGroup: null };
mockRemissionStartService.assignPackage.mockResolvedValue(true);
mockRemissionReturnReceiptService.completeReturnReceiptAndReturn.mockResolvedValue(
mockReturn,
);
const reloadDataSpy = vi.fn();
component.reloadData.subscribe(reloadDataSpy);
// Act
await component.completeRemission();
// Assert
expect(mockRemissionStartService.assignPackage).toHaveBeenCalledWith({
returnId: 123,
receiptId: 456,
});
expect(component.completingRemission()).toBe(false);
expect(reloadDataSpy).toHaveBeenCalled();
});
it('should complete remission with return group and user confirms completion', async () => {
// Arrange
const mockReturn = { id: 123, returnGroup: 'RG001' };
mockRemissionReturnReceiptService.completeReturnReceiptAndReturn.mockResolvedValue(
mockReturn,
);
mockRemissionReturnReceiptService.completeReturnGroup.mockResolvedValue(
undefined,
);
// Mock dialog result with confirmed=true
mockDialogRef.closed = of({ confirmed: true });
const reloadDataSpy = vi.fn();
component.reloadData.subscribe(reloadDataSpy);
// Act
await component.completeRemission();
// Assert
expect(mockInjectConfirmationDialog).toHaveBeenCalledWith({
title: 'Wanne abgeschlossen',
width: '30rem',
data: {
message: expect.stringContaining('Legen Sie abschließend den'),
closeText: 'Neue Wanne',
confirmText: 'Beenden',
},
});
expect(
mockRemissionReturnReceiptService.completeReturnGroup,
).toHaveBeenCalledWith({
returnGroup: 'RG001',
});
expect(component.completingRemission()).toBe(false);
expect(reloadDataSpy).toHaveBeenCalled();
});
it('should complete remission with return group and user chooses new container', async () => {
// Arrange
const mockReturn = { id: 123, returnGroup: 'RG001' };
mockRemissionReturnReceiptService.completeReturnReceiptAndReturn.mockResolvedValue(
mockReturn,
);
mockRemissionStartService.startRemission.mockResolvedValue(undefined);
// Mock dialog result with confirmed=false (user chose "Neue Wanne")
mockDialogRef.closed = of({ confirmed: false });
const reloadDataSpy = vi.fn();
component.reloadData.subscribe(reloadDataSpy);
// Act
await component.completeRemission();
// Assert
expect(mockInjectConfirmationDialog).toHaveBeenCalledWith({
title: 'Wanne abgeschlossen',
width: '30rem',
data: {
message: expect.stringContaining('Legen Sie abschließend den'),
closeText: 'Neue Wanne',
confirmText: 'Beenden',
},
});
expect(mockRemissionStartService.startRemission).toHaveBeenCalledWith(
'RG001',
);
expect(mockRouter.navigate).toHaveBeenCalledWith([
'/',
'test-tab-id',
'remission',
]);
expect(component.completingRemission()).toBe(false);
expect(reloadDataSpy).toHaveBeenCalled();
});
it('should prevent multiple completion attempts', async () => {
// Arrange
component.completingRemission.set(true);

View File

@@ -89,6 +89,13 @@ export class RemissionReturnReceiptCompleteComponent {
*/
itemsLength = input.required<number>();
/**
* Required input indicating if there is at least one package assigned to the return.
* @input
* @required
*/
hasAssignedPackage = input.required<boolean>();
/**
* Output event that emits when the list needs to be reloaded.
* This is used to refresh the remission list after completing a return.
@@ -128,7 +135,22 @@ export class RemissionReturnReceiptCompleteComponent {
return;
}
this.completingRemission.set(true);
try {
// #5289 - Ensure a package is assigned before completing the remission
if (!this.hasAssignedPackage()) {
const res = await this.#remissionStartService.assignPackage({
returnId: this.returnId(),
receiptId: this.receiptId(),
});
if (!res) {
this.completingRemission.set(false);
return;
}
}
// Complete Remission Flow
const completedReturn = await this.completeSingleReturnReceipt();
const returnGroup = completedReturn?.returnGroup;
@@ -146,7 +168,7 @@ export class RemissionReturnReceiptCompleteComponent {
const dialogResult = await firstValueFrom(dialogRef.closed);
if (dialogResult?.confirmed) {
// Beenden - Remission abschließen Flow
// Beenden - Remission abschließen Flow - Return Group Abschließen
await this.#remissionReturnReceiptService.completeReturnGroup({
returnGroup,
});

View File

@@ -0,0 +1,39 @@
import { inject, resource } from '@angular/core';
import { RemissionStockService } from '@isa/remission/data-access';
export const createInStockResource = (
params: () => {
itemIds: number[];
},
) => {
const remissionStockService = inject(RemissionStockService);
return resource({
params,
loader: async ({ abortSignal, params }) => {
if (!params?.itemIds || params.itemIds.length === 0) {
return;
}
const assignedStock =
await remissionStockService.fetchAssignedStock(abortSignal);
if (!assignedStock || !assignedStock.id) {
throw new Error('No current stock available');
}
const itemIds = params.itemIds;
if (itemIds.some((id) => isNaN(id))) {
throw new Error('Invalid Catalog Product Number provided');
}
return await remissionStockService.fetchStock(
{
itemIds,
assignedStockId: assignedStock.id,
},
abortSignal,
);
},
});
};

View File

@@ -8,7 +8,7 @@
name="quantity"
placeholder="Menge eingeben"
type="number"
[ngModel]="quantityAndReason().quantity"
[ngModel]="quantity()"
(ngModelChange)="setQuantity($event)"
#model="ngModel"
[min]="1"
@@ -16,7 +16,7 @@
required
data-what="input"
data-which="quantity"
class="isa-text-body-2-bold placeholder:isa-text-body-2-regular placeholder:text-isa-neutral-200 text-isa-neutral-900 focus:outline-none w-[9rem] px-4 text-right"
class="isa-text-body-2-bold placeholder:isa-text-body-2-regular placeholder:text-isa-neutral-500 text-isa-neutral-900 focus:outline-none w-[9rem] px-4 text-right"
/>
<ui-dropdown
[ngModel]="quantityAndReason().reason"

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';
@@ -53,6 +53,11 @@ export class QuantityAndReasonItemComponent {
},
});
quantity = computed(() => {
const quantity = this.quantityAndReason().quantity;
return quantity !== undefined && quantity >= 1 ? quantity : undefined;
});
setQuantity(quantity: number): void {
this.quantityAndReason.update((qar) => ({
...qar,

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-1 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,3 +1,3 @@
:host {
@apply block h-full;
@apply block h-full mt-6;
}

View File

@@ -1,377 +0,0 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { SearchItemToRemitDialogComponent } from './search-item-to-remit-dialog.component';
import { DialogRef, DIALOG_DATA } from '@angular/cdk/dialog';
import { DialogComponent } from '@isa/ui/dialog';
import { MockComponents } from 'ng-mocks';
import { TextButtonComponent } from '@isa/ui/buttons';
import { SearchItemToRemitListComponent } from './search-item-to-remit-list.component';
import { SelectRemiQuantityAndReasonComponent } from './select-remi-quantity-and-reason.component';
import { signal } from '@angular/core';
import { Item } from '@isa/catalogue/data-access';
import { By } from '@angular/platform-browser';
describe('SearchItemToRemitDialogComponent', () => {
let component: SearchItemToRemitDialogComponent;
let fixture: ComponentFixture<SearchItemToRemitDialogComponent>;
let mockDialogRef: {
updateSize: ReturnType<typeof vi.fn>;
close: ReturnType<typeof vi.fn>;
};
let mockDialogComponent: {
title: ReturnType<typeof signal>;
};
const mockItem = {
id: 1,
product: {
id: 1,
name: 'Test Product',
},
catalogAvailability: {},
} as unknown as Item;
beforeEach(async () => {
mockDialogRef = {
updateSize: vi.fn(),
close: vi.fn(),
};
mockDialogComponent = {
title: signal(''),
};
const mockData = { searchTerm: 'test' };
await TestBed.configureTestingModule({
imports: [SearchItemToRemitDialogComponent],
providers: [
{ provide: DialogRef, useValue: mockDialogRef },
{ provide: DIALOG_DATA, useValue: mockData },
{ provide: DialogComponent, useValue: mockDialogComponent },
],
})
.overrideComponent(SearchItemToRemitDialogComponent, {
remove: {
imports: [
TextButtonComponent,
SearchItemToRemitListComponent,
SelectRemiQuantityAndReasonComponent,
],
},
add: {
imports: MockComponents(
TextButtonComponent,
SearchItemToRemitListComponent,
SelectRemiQuantityAndReasonComponent,
),
},
})
.compileComponents();
fixture = TestBed.createComponent(SearchItemToRemitDialogComponent);
component = fixture.componentInstance;
});
describe('Component Setup and Initialization', () => {
it('should create', () => {
expect(component).toBeTruthy();
});
it('should initialize searchTerm from string data', () => {
fixture.detectChanges();
expect(component.searchTerm()).toBe('test');
});
it('should initialize searchTerm from Signal data', async () => {
const searchTermSignal = signal('signal test');
const mockSignalData = { searchTerm: searchTermSignal };
TestBed.resetTestingModule();
await TestBed.configureTestingModule({
imports: [SearchItemToRemitDialogComponent],
providers: [
{ provide: DialogRef, useValue: mockDialogRef },
{ provide: DIALOG_DATA, useValue: mockSignalData },
{ provide: DialogComponent, useValue: mockDialogComponent },
],
})
.overrideComponent(SearchItemToRemitDialogComponent, {
remove: {
imports: [
TextButtonComponent,
SearchItemToRemitListComponent,
SelectRemiQuantityAndReasonComponent,
],
},
add: {
imports: MockComponents(
TextButtonComponent,
SearchItemToRemitListComponent,
SelectRemiQuantityAndReasonComponent,
),
},
})
.compileComponents();
fixture = TestBed.createComponent(SearchItemToRemitDialogComponent);
component = fixture.componentInstance;
fixture.detectChanges();
expect(component.searchTerm()).toBe('signal test');
// Test that it reacts to signal changes
searchTermSignal.set('updated signal test');
fixture.detectChanges();
expect(component.searchTerm()).toBe('updated signal test');
});
it('should initialize item signal as undefined', () => {
fixture.detectChanges();
expect(component.item()).toBeUndefined();
});
it('should extend DialogContentDirective', () => {
expect(component.dialogRef).toBeDefined();
expect(component.data).toBeDefined();
expect(component.close).toBeDefined();
});
});
describe('Signal and Effect Behavior', () => {
it('should update dialog size to auto when item is undefined', () => {
fixture.detectChanges();
expect(mockDialogRef.updateSize).toHaveBeenCalledWith('auto');
});
it('should update dialog size to 36rem when item is set', () => {
fixture.detectChanges();
mockDialogRef.updateSize.mockClear();
component.item.set(mockItem);
fixture.detectChanges();
expect(mockDialogRef.updateSize).toHaveBeenCalledWith('36rem');
});
it('should update searchTerm when linkedSignal source changes', () => {
const searchTermSignal = signal('initial');
const mockSignalData = { searchTerm: searchTermSignal };
TestBed.resetTestingModule();
TestBed.configureTestingModule({
imports: [SearchItemToRemitDialogComponent],
providers: [
{ provide: DialogRef, useValue: mockDialogRef },
{ provide: DIALOG_DATA, useValue: mockSignalData },
{ provide: DialogComponent, useValue: mockDialogComponent },
],
})
.overrideComponent(SearchItemToRemitDialogComponent, {
remove: {
imports: [
TextButtonComponent,
SearchItemToRemitListComponent,
SelectRemiQuantityAndReasonComponent,
],
},
add: {
imports: MockComponents(
TextButtonComponent,
SearchItemToRemitListComponent,
SelectRemiQuantityAndReasonComponent,
),
},
})
.compileComponents();
fixture = TestBed.createComponent(SearchItemToRemitDialogComponent);
component = fixture.componentInstance;
fixture.detectChanges();
expect(component.searchTerm()).toBe('initial');
searchTermSignal.set('updated');
fixture.detectChanges();
expect(component.searchTerm()).toBe('updated');
});
});
describe('Template Behavior', () => {
it('should show search list component when item is undefined', () => {
fixture.detectChanges();
const searchList = fixture.debugElement.query(
By.css('remi-search-item-to-remit-list'),
);
const selectQuantity = fixture.debugElement.query(
By.css('remi-select-remi-quantity-and-reason'),
);
expect(searchList).toBeTruthy();
expect(selectQuantity).toBeFalsy();
});
it('should show select quantity component when item is set', () => {
component.item.set(mockItem);
fixture.detectChanges();
const searchList = fixture.debugElement.query(
By.css('remi-search-item-to-remit-list'),
);
const selectQuantity = fixture.debugElement.query(
By.css('remi-select-remi-quantity-and-reason'),
);
expect(searchList).toBeFalsy();
expect(selectQuantity).toBeTruthy();
});
it('should show close button only when item is undefined', () => {
fixture.detectChanges();
let closeButton = fixture.debugElement.query(
By.css('button[uiTextButton]'),
);
expect(closeButton).toBeTruthy();
expect(closeButton.nativeElement.textContent.trim()).toBe('Schließen');
component.item.set(mockItem);
fixture.detectChanges();
closeButton = fixture.debugElement.query(By.css('button[uiTextButton]'));
expect(closeButton).toBeFalsy();
});
it('should call close with undefined when close button is clicked', () => {
fixture.detectChanges();
const closeButton = fixture.debugElement.query(
By.css('button[uiTextButton]'),
);
closeButton.nativeElement.click();
expect(mockDialogRef.close).toHaveBeenCalledWith(undefined);
});
it('should have correct button attributes', () => {
fixture.detectChanges();
const closeButton = fixture.debugElement.query(By.css('button'));
const buttonEl = closeButton.nativeElement;
expect(buttonEl.type).toBe('button');
expect(buttonEl.classList.contains('absolute')).toBe(true);
expect(buttonEl.classList.contains('top-1')).toBe(true);
expect(buttonEl.classList.contains('right-[1.33rem]')).toBe(true);
});
});
describe('DialogRef Integration', () => {
it('should call dialogRef.updateSize on initialization', () => {
fixture.detectChanges();
expect(mockDialogRef.updateSize).toHaveBeenCalledWith('auto');
});
it('should call dialogRef.updateSize when item changes', () => {
fixture.detectChanges();
mockDialogRef.updateSize.mockClear();
component.item.set(mockItem);
fixture.detectChanges();
expect(mockDialogRef.updateSize).toHaveBeenCalledWith('36rem');
component.item.set(undefined);
fixture.detectChanges();
expect(mockDialogRef.updateSize).toHaveBeenCalledWith('auto');
});
it('should inherit close method from DialogContentDirective', () => {
const closeSpy = vi.spyOn(component, 'close');
component.close(mockItem);
expect(closeSpy).toHaveBeenCalledWith(mockItem);
expect(mockDialogRef.close).toHaveBeenCalledWith(mockItem);
});
});
describe('Edge Cases and Error Handling', () => {
it('should handle empty searchTerm', async () => {
const emptyData = { searchTerm: '' };
TestBed.resetTestingModule();
await TestBed.configureTestingModule({
imports: [SearchItemToRemitDialogComponent],
providers: [
{ provide: DialogRef, useValue: mockDialogRef },
{ provide: DIALOG_DATA, useValue: emptyData },
{ provide: DialogComponent, useValue: mockDialogComponent },
],
})
.overrideComponent(SearchItemToRemitDialogComponent, {
remove: {
imports: [
TextButtonComponent,
SearchItemToRemitListComponent,
SelectRemiQuantityAndReasonComponent,
],
},
add: {
imports: MockComponents(
TextButtonComponent,
SearchItemToRemitListComponent,
SelectRemiQuantityAndReasonComponent,
),
},
})
.compileComponents();
fixture = TestBed.createComponent(SearchItemToRemitDialogComponent);
component = fixture.componentInstance;
fixture.detectChanges();
expect(component.searchTerm()).toBe('');
});
it('should handle multiple rapid item changes', () => {
fixture.detectChanges();
mockDialogRef.updateSize.mockClear();
// First change
component.item.set(mockItem);
fixture.detectChanges();
expect(mockDialogRef.updateSize).toHaveBeenCalledWith('36rem');
// Second change
component.item.set(undefined);
fixture.detectChanges();
expect(mockDialogRef.updateSize).toHaveBeenCalledWith('auto');
// Third change
component.item.set(mockItem);
fixture.detectChanges();
expect(mockDialogRef.updateSize).toHaveBeenCalledWith('36rem');
// Total calls
expect(mockDialogRef.updateSize).toHaveBeenCalledTimes(3);
});
it('should handle component destruction gracefully', () => {
fixture.detectChanges();
// Component destruction should not throw errors
expect(() => {
fixture.destroy();
}).not.toThrow();
});
it('should maintain data integrity', () => {
const originalData = { searchTerm: 'test' };
fixture.detectChanges();
// Data should remain unchanged
expect(component.data).toEqual(originalData);
expect(component.data.searchTerm).toBe('test');
});
});
});

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

@@ -3,7 +3,7 @@
cdkFocusRegionStart
[(ngModel)]="host.searchTerm"
type="text"
placeholder="Rechnungsnummer, E-Mail, Kundenkarte, Name..."
placeholder="EAN, Titel, ..."
(keydown.enter)="triggerSearch()"
data-what="input"
data-which="search-remission"
@@ -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>
@@ -24,13 +24,23 @@
>
Sie können Artikel die nicht auf der Remi Liste stehen direkt zum
Warenbegleitschein hinzufügen.
<button
class="relative top-[0.375rem] w-6 h-6 inline-flex items-center justify-center text-isa-accent-blue"
uiTooltip
[content]="'Es werden nur Artikel mit Bestand angezeigt'"
[triggerOn]="['click', 'hover']"
>
<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 {
<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"
@@ -38,4 +48,18 @@
}
}
}
@if (
!hasItems() && !searchResource.isLoading() && !inStockResource.isLoading()
) {
<ui-empty-state
class="w-full justify-self-center"
title="Keine Suchergebnisse"
description="Bitte prüfen Sie die Schreibweise."
>
</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

@@ -1,18 +1,19 @@
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
OnInit,
resource,
signal,
} from '@angular/core';
import { isaActionSearch } from '@isa/icons';
import { isaActionSearch, isaOtherInfo } from '@isa/icons';
import { IconButtonComponent } from '@isa/ui/buttons';
import {
UiSearchBarClearComponent,
UiSearchBarComponent,
} from '@isa/ui/search-bar';
import { provideIcons } from '@ng-icons/core';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { SearchItemToRemitComponent } from './search-item-to-remit.component';
import { FormsModule } from '@angular/forms';
import { DEFAULT_LIST_RESPONSE_ARGS_OF_ITEM } from './constants';
@@ -27,6 +28,11 @@ import {
} from '@isa/common/data-access';
import { SearchItemToRemitDialogComponent } from './search-item-to-remit-dialog.component';
import { CdkTrapFocus } from '@angular/cdk/a11y';
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',
@@ -41,8 +47,12 @@ import { CdkTrapFocus } from '@angular/cdk/a11y';
FormsModule,
SearchItemToRemitComponent,
CdkTrapFocus,
TooltipDirective,
NgIcon,
EmptyStateComponent,
ScrollTopButtonComponent,
],
providers: [provideIcons({ isaActionSearch })],
providers: [provideIcons({ isaActionSearch, isaOtherInfo })],
})
export class SearchItemToRemitListComponent implements OnInit {
host = inject(SearchItemToRemitDialogComponent);
@@ -50,6 +60,42 @@ 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:
this.searchResource
.value()
?.result?.map((item) => item?.id)
.filter((id) => !!id) ?? [],
};
});
inStockResponseValue = computed(() => this.inStockResource.value());
hasItems = computed(() => {
return (this.availableSearchResults()?.length ?? 0) > 0;
});
stockInfoMap = computed(() => {
const infos = this.inStockResponseValue() ?? [];
return new Map(infos.map((info) => [info.itemId, info]));
});
getAvailableStockForItem(item: Item): number {
const stockInfo = this.stockInfoMap().get(item.id);
return calculateAvailableStock({
stock: stockInfo?.inStock,
removedFromStock: stockInfo?.removedFromStock,
});
}
triggerSearch(): void {
this.searchParams.set({
searchTerm: this.host.searchTerm(),

View File

@@ -4,14 +4,21 @@
retailPrice: item().catalogAvailability.price,
}"
[orientation]="productInfoOrientation()"
[innerGridClass]="'grid-cols-[minmax(20rem,1fr),minmax(18rem,auto)]'"
></remi-product-info>
<div class="text-right">
<div class="flex flex-col items-end justify-center gap-6">
<div
class="text-isa-neutral-900 w-[18rem] flex flex-row items-center justify-between"
>
<span class="isa-text-body-2-regular">Aktueller Bestand</span>
<span class="isa-text-body-2-bold">{{ inStock() }}x</span>
</div>
<button
class="-mr-5"
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,12 +23,34 @@ 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>();
desktopBreakpoint = breakpoint([Breakpoint.DekstopL, Breakpoint.DekstopXL]);
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: 1, 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

@@ -3,3 +3,15 @@ export const NO_RESULTS =
export const NO_ARTICLES =
'<svg xmlns="http://www.w3.org/2000/svg" width="113" height="102" viewBox="0 0 113 102" fill="none"> <path d="M74.5 84L75 30.1846L61.4243 15H27.2699C22.7025 14.9998 19 18.5625 19 22.9574V82.0424C19 86.4373 22.7025 90 27.2699 90H66.7301C70.8894 90 73.9181 87.8467 74.5 84Z" stroke="#CED4DA" stroke-width="3" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/> <path d="M61 16V30H74" stroke="#CED4DA" stroke-width="3" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/> <path d="M54.25 61.5L39.75 47" stroke="#6C757D" stroke-width="3" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/> <path d="M39.75 61.5L54.25 47" stroke="#6C757D" stroke-width="3" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/></svg>';
export const ALL_DONE_CUP =
'<svg class="ui-empty-state-icon-all-done-cup" xmlns="http://www.w3.org/2000/svg" width="76" height="49" viewBox="0 0 76 49" fill="none"><g clip-path="url(#clip0_2623_8564)"><path d="M74.5156 4.53658H62.3438V1.45543C62.3438 0.652033 61.6787 0 60.8594 0H28.7969C28.7969 0 28.7954 0 28.7939 0H1.48438C0.665 0 0 0.652033 0 1.45543V14.0697C0 19.5712 1.50812 24.965 4.36109 29.669C6.28484 32.8418 8.78156 35.6421 11.7058 37.9387H1.48438C0.665 37.9387 0 38.5908 0 39.3942C0 44.6905 4.39523 49 9.79688 49H56.1094C61.511 49 65.9062 44.6905 65.9062 39.3942C65.9062 38.5908 65.2412 37.9387 64.4219 37.9387H50.638C53.3291 35.8254 55.6566 33.2843 57.5106 30.4185H61.701C69.586 30.4185 76 24.1281 76 16.3983V5.99201C76 5.18861 75.335 4.53658 74.5156 4.53658ZM62.3438 14.0697V13.2692H67.0938V16.3983C67.0938 19.3136 64.6742 21.6859 61.701 21.6859H61.3641C62.0112 19.2102 62.3438 16.6516 62.3438 14.0697ZM62.7757 40.8496C62.0959 43.8434 59.3661 46.0891 56.1094 46.0891H9.79688C6.54164 46.0891 3.81039 43.8434 3.13203 40.8496H62.7757ZM45.4189 37.9387H16.9248C8.30656 32.9815 2.96875 23.8705 2.96875 14.0697V2.91086H29.1605C29.1605 2.91086 29.162 2.91086 29.1635 2.91086H59.375V14.0697C59.375 23.872 54.0372 32.9815 45.4189 37.9387ZM73.0312 16.3983C73.0312 22.5243 67.9488 27.5076 61.701 27.5076H59.1746C59.6481 26.5587 60.0712 25.5865 60.4423 24.5968H61.701C66.3115 24.5968 70.0625 20.9189 70.0625 16.3983V11.8137C70.0625 11.0103 69.3975 10.3583 68.5781 10.3583H62.3438V7.44744H73.0312V16.3983Z" fill="#CED4DA"/><path d="M53.7341 12.6142C52.9148 12.6142 52.2498 13.2663 52.2498 14.0697C52.2498 19.4722 50.0143 24.7816 46.1178 28.6356C45.5404 29.2061 45.5448 30.1274 46.1267 30.6936C46.4162 30.9745 46.7932 31.1157 47.1717 31.1157C47.5532 31.1157 47.9362 30.9716 48.2256 30.6849C52.6683 26.2895 55.217 20.2334 55.217 14.0697C55.217 13.2663 54.552 12.6142 53.7326 12.6142H53.7341Z" fill="#CED4DA"/></g><defs><clipPath id="clip0_2623_8564"><rect width="76" height="49" fill="white"/></clipPath></defs></svg>';
export const ALL_DONE_FUME =
'<svg class="ui-empty-state-icon-all-done-fume" xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 28 28" fill="none"><g clip-path="url(#clip0_2623_8567)"><path d="M15.7379 9.94429L14.7906 8.77089C13.3988 7.04762 13.397 4.63971 14.7851 2.91464C15.41 2.13657 15.2788 1.00629 14.49 0.389937C13.7012 -0.226415 12.5553 -0.0970354 11.9305 0.681042C9.48208 3.72507 9.48572 7.97305 11.9396 11.0117L12.8869 12.1851C14.705 14.4367 14.7086 17.5831 12.8942 19.8383L11.9232 21.0458C11.2983 21.8239 11.4295 22.9542 12.2183 23.5705C12.5535 23.8329 12.9525 23.9605 13.3496 23.9605C13.887 23.9605 14.419 23.7269 14.7778 23.2812L15.7488 22.0737C18.6235 18.5013 18.6199 13.5148 15.7379 9.94609V9.94429Z" fill="#6C757D"/><path d="M25.8356 13.9856L24.8883 12.8122C23.4965 11.0889 23.4946 8.68103 24.8828 6.95597C25.5076 6.17789 25.3765 5.04761 24.5877 4.43126C23.7989 3.81491 22.653 3.94429 22.0281 4.72236C19.5797 7.76639 19.5834 12.0126 22.0372 15.053L22.9845 16.2264C24.8026 18.478 24.8045 21.6262 22.9918 23.8814L22.0209 25.0889C21.396 25.867 21.5272 26.9973 22.316 27.6136C22.6512 27.876 23.0501 28.0036 23.4473 28.0036C23.9847 28.0036 24.5166 27.77 24.8755 27.3243L25.8465 26.1168C28.7212 22.5445 28.7175 17.5579 25.8356 13.9892V13.9856Z" fill="#6C757D"/><path d="M5.64024 13.9856L4.69294 12.8122C3.30114 11.0889 3.29932 8.68103 4.68748 6.95597C5.31233 6.17789 5.18117 5.04761 4.39236 4.43126C3.60355 3.81491 2.45768 3.94429 1.83283 4.72236C-0.61558 7.76639 -0.611937 12.0144 1.84193 15.053L2.78923 16.2264C4.60732 18.478 4.60914 21.6262 2.79652 23.8796L1.82554 25.0871C1.20069 25.8652 1.33185 26.9955 2.12066 27.6119C2.45586 27.8742 2.85482 28.0018 3.25195 28.0018C3.78937 28.0018 4.32131 27.7682 4.68019 27.3225L5.65117 26.115C8.52587 22.5427 8.52222 17.5561 5.64024 13.9874V13.9856Z" fill="#6C757D"/></g><defs><clipPath id="clip0_2623_8567"><rect width="28" height="28" fill="white"/></clipPath></defs></svg>';
export const SELECT_ACTION_HAND =
'<svg class="ui-empty-state-icon-select-action-hand" xmlns="http://www.w3.org/2000/svg" width="44" height="62" viewBox="0 0 44 62" fill="none"><path d="M12.1722 44.1737C11.4694 44.1737 10.8989 43.6033 10.8989 42.9004V13.6147C10.8989 10.8058 13.1832 8.52148 15.9921 8.52148C18.801 8.52148 21.0853 10.8058 21.0853 13.6147V40.3538C21.0853 41.0567 20.5148 41.6271 19.812 41.6271C19.1091 41.6271 18.5387 41.0567 18.5387 40.3538V13.6147C18.5387 12.2089 17.3978 11.0681 15.9921 11.0681C14.5864 11.0681 13.4455 12.2089 13.4455 13.6147V42.9004C13.4455 43.6033 12.8751 44.1737 12.1722 44.1737Z" fill="#6C757D"/><path d="M27.4521 41.6269C26.7493 41.6269 26.1788 41.0565 26.1788 40.3536V28.894C26.1788 27.4882 25.038 26.3474 23.6322 26.3474C22.2265 26.3474 21.0856 27.4882 21.0856 28.894C21.0856 29.5968 20.5152 30.1672 19.8124 30.1672C19.1095 30.1672 18.5391 29.5968 18.5391 28.894C18.5391 26.0851 20.8234 23.8008 23.6322 23.8008C26.4411 23.8008 28.7254 26.0851 28.7254 28.894V40.3536C28.7254 41.0565 28.155 41.6269 27.4521 41.6269Z" fill="#6C757D"/><path d="M35.0918 41.6272C34.3889 41.6272 33.8185 41.0567 33.8185 40.3539V31.4408C33.8185 30.0351 32.6776 28.8942 31.2719 28.8942C29.8662 28.8942 28.7253 30.0351 28.7253 31.4408C28.7253 32.1437 28.1549 32.7141 27.452 32.7141C26.7491 32.7141 26.1787 32.1437 26.1787 31.4408C26.1787 28.6319 28.463 26.3477 31.2719 26.3477C34.0808 26.3477 36.3651 28.6319 36.3651 31.4408V40.3539C36.3651 41.0567 35.7946 41.6272 35.0918 41.6272Z" fill="#6C757D"/><path d="M32.5451 62.0002H17.8081C13.5221 62.0002 9.10127 60.1335 5.9766 57.0063C2.52853 53.5557 0.707716 48.6331 0.715356 42.776C0.717903 37.9324 4.76952 33.9877 9.74301 33.9877H12.1725C12.8753 33.9877 13.4457 34.5581 13.4457 35.261C13.4457 35.9639 12.8753 36.5343 12.1725 36.5343H9.74301C6.17269 36.5343 3.26449 39.3381 3.26194 42.7785C3.25685 48.0194 4.77716 52.1984 7.77704 55.2059C10.9068 58.3382 15.0094 59.4536 17.8081 59.4536H32.5451C37.4601 59.4536 41.4582 55.4554 41.4582 50.5405V33.9877C41.4582 32.582 40.3173 31.4411 38.9116 31.4411C37.5059 31.4411 36.365 32.582 36.365 33.9877C36.365 34.6906 35.7946 35.261 35.0917 35.261C34.3889 35.261 33.8184 34.6906 33.8184 33.9877C33.8184 31.1788 36.1027 28.8945 38.9116 28.8945C41.7205 28.8945 44.0048 31.1788 44.0048 33.9877V50.5405C44.0048 56.8586 38.8632 62.0002 32.5451 62.0002Z" fill="#6C757D"/><path d="M24.9053 22.0694C24.6226 22.0694 24.3348 21.9752 24.1005 21.7817C23.5556 21.336 23.4766 20.5364 23.9223 19.9914C25.3968 18.1833 26.1786 15.978 26.1786 13.6148C26.1786 7.997 21.61 3.42842 15.9922 3.42842C10.3744 3.42842 5.80586 7.997 5.80586 13.6148C5.80586 15.978 6.58767 18.1833 8.06469 19.9914C8.51034 20.5364 8.4314 21.336 7.88643 21.7817C7.344 22.2299 6.54183 22.1484 6.09618 21.6034C4.23971 19.3344 3.25928 16.5714 3.25928 13.6148C3.25928 6.59383 8.97127 0.881836 15.9922 0.881836C23.0132 0.881836 28.7251 6.59383 28.7251 13.6148C28.7251 16.5714 27.7447 19.3344 25.8908 21.6034C25.6387 21.9116 25.2745 22.0694 24.9053 22.0694Z" fill="#6C757D"/></svg>';
export const SELECT_ACTION_OBJECT_DROPDOWN =
'<svg class="ui-empty-state-icon-select-action-object-dropdown" xmlns="http://www.w3.org/2000/svg" width="149" height="44" viewBox="0 0 149 44" fill="none"><path d="M90.8125 42.1992H22.2244C11.1787 42.1992 2.22437 33.2449 2.22437 22.1992V22.1992C2.22437 11.1535 11.1787 2.19922 22.2244 2.19922H127.5C138.546 2.19922 147.5 11.1535 147.5 22.1992V22.1992C147.5 33.2449 138.546 42.1992 127.5 42.1992H98.875" stroke="#CED4DA" stroke-width="3"/><line x1="80.75" y1="22.4365" x2="26.25" y2="22.4365" stroke="#CED4DA" stroke-width="3" stroke-linecap="round"/><path d="M118.641 20.0596L125.005 26.4874L131.369 20.0596" stroke="#CED4DA" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/></svg>';

View File

@@ -1,5 +1,7 @@
@if (sanitizedSubIcon()) {
<div class="h-0" [innerHTML]="sanitizedSubIcon()"></div>
}
<div class="ui-empty-state-circle" [innerHTML]="sanitizedIcon()"></div>
<div></div>
<div class="ui-empty-state-title">{{ title() }}</div>
<div class="ui-empty-state-description">{{ description() }}</div>
<div class="ui-empty-state-actions">

View File

@@ -6,7 +6,6 @@
justify-content: center;
justify-items: center;
align-items: center;
gap: 0.75rem;
flex-shrink: 0;
}
@@ -20,20 +19,30 @@
@apply rounded-full bg-isa-neutral-100;
}
.ui-empty-state-icon-all-done-cup {
@apply h-[3.0625rem] w-full ml-3 mt-8;
}
.ui-empty-state-icon-all-done-fume {
@apply h-[1.75rem] w-full relative top-4;
}
.ui-empty-state-icon-select-action-object-dropdown {
@apply w-full scale-[1.15] h-10;
}
.ui-empty-state-icon-select-action-hand {
@apply w-full h-[3.81988rem] relative top-[3.70512rem] left-[1.7rem] z-[1];
}
.ui-empty-state-icon {
width: 7.0625rem;
height: 6.375rem;
flex-shrink: 0;
}
.ui-empty-state-spacer {
width: 0.75rem;
height: 0.75rem;
flex-shrink: 0;
}
.ui-empty-state-title {
@apply text-isa-black isa-text-subtitle-1-regular text-center;
@apply text-isa-black isa-text-subtitle-1-regular text-center mb-3 mt-11;
}
.ui-empty-state-description {
@@ -41,5 +50,5 @@
}
.ui-empty-state-actions {
@apply flex flex-row gap-2;
@apply flex flex-row gap-2 mt-11;
}

View File

@@ -1,9 +1,36 @@
import { createComponentFactory, Spectator } from '@ngneat/spectator/jest';
import { DomSanitizer } from '@angular/platform-browser';
import { EmptyStateComponent } from './empty-state.component';
import { EmptyStateAppearance } from './types';
import {
NO_RESULTS,
NO_ARTICLES,
ALL_DONE_CUP,
ALL_DONE_FUME,
SELECT_ACTION_HAND,
SELECT_ACTION_OBJECT_DROPDOWN,
} from './constants';
describe('EmptyStateComponent', () => {
let spectator: Spectator<EmptyStateComponent>;
const createComponent = createComponentFactory(EmptyStateComponent);
let mockSanitizer: jest.Mocked<DomSanitizer>;
const createComponent = createComponentFactory({
component: EmptyStateComponent,
providers: [
{
provide: DomSanitizer,
useValue: {
bypassSecurityTrustHtml: jest.fn(),
bypassSecurityTrustStyle: jest.fn(),
bypassSecurityTrustScript: jest.fn(),
bypassSecurityTrustUrl: jest.fn(),
bypassSecurityTrustResourceUrl: jest.fn(),
sanitize: jest.fn(),
},
},
],
});
beforeEach(() => {
spectator = createComponent({
@@ -12,6 +39,10 @@ describe('EmptyStateComponent', () => {
description: 'Test Description',
},
});
mockSanitizer = spectator.inject(DomSanitizer) as jest.Mocked<DomSanitizer>;
// Clear any calls made during component initialization
mockSanitizer.bypassSecurityTrustHtml.mockClear();
});
it('should create the component', () => {
@@ -26,4 +57,109 @@ describe('EmptyStateComponent', () => {
it('should apply the host class "ui-empty-state"', () => {
expect(spectator.element.classList.contains('ui-empty-state')).toBe(true);
});
it('should have default appearance as NoResults', () => {
expect(spectator.component.appearance()).toBe(
EmptyStateAppearance.NoResults,
);
});
it('should set appearance input correctly', () => {
spectator.setInput('appearance', EmptyStateAppearance.AllDone);
expect(spectator.component.appearance()).toBe(EmptyStateAppearance.AllDone);
});
describe('icon computed property', () => {
it('should return NO_RESULTS icon for NoResults appearance', () => {
spectator.setInput('appearance', EmptyStateAppearance.NoResults);
expect(spectator.component.icon()).toBe(NO_RESULTS);
});
it('should return NO_ARTICLES icon for NoArticles appearance', () => {
spectator.setInput('appearance', EmptyStateAppearance.NoArticles);
expect(spectator.component.icon()).toBe(NO_ARTICLES);
});
it('should return ALL_DONE_CUP icon for AllDone appearance', () => {
spectator.setInput('appearance', EmptyStateAppearance.AllDone);
expect(spectator.component.icon()).toBe(ALL_DONE_CUP);
});
it('should return SELECT_ACTION_OBJECT_DROPDOWN icon for SelectAction appearance', () => {
spectator.setInput('appearance', EmptyStateAppearance.SelectAction);
expect(spectator.component.icon()).toBe(SELECT_ACTION_OBJECT_DROPDOWN);
});
it('should return NO_RESULTS icon for default case', () => {
expect(spectator.component.icon()).toBe(NO_RESULTS);
});
});
describe('subIcon computed property', () => {
it('should return ALL_DONE_FUME for AllDone appearance', () => {
spectator.setInput('appearance', EmptyStateAppearance.AllDone);
expect(spectator.component.subIcon()).toBe(ALL_DONE_FUME);
});
it('should return SELECT_ACTION_HAND for SelectAction appearance', () => {
spectator.setInput('appearance', EmptyStateAppearance.SelectAction);
expect(spectator.component.subIcon()).toBe(SELECT_ACTION_HAND);
});
it('should return empty string for NoResults appearance', () => {
spectator.setInput('appearance', EmptyStateAppearance.NoResults);
expect(spectator.component.subIcon()).toBe('');
});
it('should return empty string for NoArticles appearance', () => {
spectator.setInput('appearance', EmptyStateAppearance.NoArticles);
expect(spectator.component.subIcon()).toBe('');
});
});
describe('sanitizedSubIcon computed property', () => {
it('should return sanitized subIcon when subIcon has content', () => {
// Arrange
const mockSafeHtml = { toString: () => 'sanitized-sub-icon' };
mockSanitizer.bypassSecurityTrustHtml.mockReturnValue(
mockSafeHtml as any,
);
spectator.setInput('appearance', EmptyStateAppearance.AllDone);
// Act
const result = spectator.component.sanitizedSubIcon();
// Assert
expect(mockSanitizer.bypassSecurityTrustHtml).toHaveBeenCalledWith(
ALL_DONE_FUME,
);
expect(result).toBe(mockSafeHtml);
});
it('should return undefined when subIcon is empty', () => {
// Arrange
spectator.setInput('appearance', EmptyStateAppearance.NoResults);
// Act
const result = spectator.component.sanitizedSubIcon();
// Assert
expect(result).toBeUndefined();
});
});
describe('component integration', () => {
it('should update computed properties when appearance changes', () => {
// Arrange - Start with NoResults
expect(spectator.component.icon()).toBe(NO_RESULTS);
expect(spectator.component.subIcon()).toBe('');
// Act - Change to AllDone
spectator.setInput('appearance', EmptyStateAppearance.AllDone);
// Assert - Properties should update
expect(spectator.component.icon()).toBe(ALL_DONE_CUP);
expect(spectator.component.subIcon()).toBe(ALL_DONE_FUME);
});
});
});

View File

@@ -7,7 +7,14 @@ import {
ViewEncapsulation,
} from '@angular/core';
import { DomSanitizer } from '@angular/platform-browser';
import { NO_RESULTS, NO_ARTICLES } from './constants';
import {
NO_RESULTS,
NO_ARTICLES,
ALL_DONE_CUP,
ALL_DONE_FUME,
SELECT_ACTION_HAND,
SELECT_ACTION_OBJECT_DROPDOWN,
} from './constants';
import { EmptyStateAppearance } from './types';
@Component({
@@ -33,13 +40,35 @@ export class EmptyStateComponent {
switch (appearance) {
case EmptyStateAppearance.NoArticles:
return NO_ARTICLES;
case EmptyStateAppearance.AllDone:
return ALL_DONE_CUP;
case EmptyStateAppearance.SelectAction:
return SELECT_ACTION_OBJECT_DROPDOWN;
case EmptyStateAppearance.NoResults:
default:
return NO_RESULTS;
}
});
subIcon = computed(() => {
const appearance = this.appearance();
switch (appearance) {
case EmptyStateAppearance.AllDone:
return ALL_DONE_FUME;
case EmptyStateAppearance.SelectAction:
return SELECT_ACTION_HAND;
default:
return '';
}
});
sanitizedIcon = computed(() => {
return this.#sanitizer.bypassSecurityTrustHtml(this.icon());
});
sanitizedSubIcon = computed(() => {
return this.subIcon().length > 0
? this.#sanitizer.bypassSecurityTrustHtml(this.subIcon())
: undefined;
});
}

View File

@@ -1,6 +1,8 @@
export const EmptyStateAppearance = {
NoResults: 'noResults',
NoArticles: 'noArticles',
AllDone: 'allDone',
SelectAction: 'selectAction',
} as const;
export type EmptyStateAppearance =

View File

@@ -20,6 +20,7 @@ import { ActiveDescendantKeyManager, Highlightable } from '@angular/cdk/a11y';
import { CdkConnectedOverlay, CdkOverlayOrigin } from '@angular/cdk/overlay';
import { isEqual } from 'lodash';
import { DropdownAppearance } from './dropdown.types';
import { DropdownService } from './dropdown.service';
@Component({
selector: 'ui-dropdown-option',
@@ -117,6 +118,8 @@ export class DropdownOptionComponent<T> implements Highlightable {
export class DropdownButtonComponent<T>
implements ControlValueAccessor, AfterViewInit
{
#dropdownService = inject(DropdownService);
readonly init = signal(false);
private elementRef = inject(ElementRef);
@@ -206,11 +209,13 @@ export class DropdownButtonComponent<T>
} else {
this.keyManger?.setFirstItemActive();
}
this.#dropdownService.open(this); // #5298 Fix
this.isOpen.set(true);
}
close() {
this.isOpen.set(false);
this.#dropdownService.close(this); // #5298 Fix
}
focusout() {

View File

@@ -0,0 +1,41 @@
import { Injectable, signal } from '@angular/core';
import { DropdownButtonComponent } from './dropdown.component';
/**
* Service zur Verwaltung des globalen Dropdown-Zustands.
*
* Stellt sicher, dass immer nur ein Dropdown gleichzeitig geöffnet ist.
* Wenn ein neues Dropdown geöffnet wird, wird ein zuvor geöffnetes automatisch geschlossen.
*/
@Injectable({ providedIn: 'root' })
export class DropdownService {
/**
* Signal, das die aktuell geöffnete Dropdown-Instanz hält.
* Ist `null`, wenn kein Dropdown geöffnet ist.
*/
private _openDropdown = signal<DropdownButtonComponent<any> | null>(null);
/**
* Öffnet ein Dropdown und schließt ein zuvor geöffnetes automatisch.
*
* @param dropdown - Die Dropdown-Komponente, die geöffnet werden soll
*/
open(dropdown: DropdownButtonComponent<any>) {
const current = this._openDropdown();
if (current && current !== dropdown) {
current.close();
}
this._openDropdown.set(dropdown);
}
/**
* Schliesst ein Dropdown, falls es aktuell als geöffnet registriert ist.
*
* @param dropdown - Die Dropdown-Komponente, die geschlossen werden soll
*/
close(dropdown: DropdownButtonComponent<any>) {
if (this._openDropdown() === dropdown) {
this._openDropdown.set(null);
}
}
}

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',
});
}
}

57
package-lock.json generated
View File

@@ -1095,6 +1095,18 @@
"linux"
]
},
"node_modules/@angular/build/node_modules/@types/node": {
"version": "24.8.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.8.1.tgz",
"integrity": "sha512-alv65KGRadQVfVcG69MuB4IzdYVpRwMG/mq8KWOaoOdyY617P5ivaDiMCGOFDWD2sAn5Q0mR3mRtUOgm99hL9Q==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"undici-types": "~7.14.0"
}
},
"node_modules/@angular/build/node_modules/convert-source-map": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
@@ -1324,7 +1336,6 @@
"version": "20.1.2",
"resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-20.1.2.tgz",
"integrity": "sha512-NMSDavN+CJYvSze6wq7DpbrUA/EqiAD7GQoeJtuOknzUpPlWQmFOoHzTMKW+S34XlNEw+YQT0trv3DKcrE+T/w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/core": "7.28.0",
@@ -11920,6 +11931,17 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/react": {
"version": "19.2.2",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz",
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.0.2"
}
},
"node_modules/@types/retry": {
"version": "0.12.2",
"resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.2.tgz",
@@ -14657,7 +14679,6 @@
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
"dev": true,
"license": "MIT",
"dependencies": {
"readdirp": "^4.0.1"
@@ -15132,7 +15153,6 @@
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz",
"integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==",
"dev": true,
"license": "MIT"
},
"node_modules/cookie": {
@@ -16350,7 +16370,7 @@
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz",
"integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"iconv-lite": "^0.6.2"
@@ -19062,7 +19082,7 @@
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
@@ -27562,6 +27582,17 @@
"dev": true,
"license": "MIT"
},
"node_modules/react-refresh": {
"version": "0.18.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",
"integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/read-cache": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
@@ -27601,7 +27632,6 @@
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
"integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 14.18.0"
@@ -27656,7 +27686,6 @@
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
"integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/regenerate": {
@@ -28638,7 +28667,7 @@
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/sass": {
@@ -29179,7 +29208,6 @@
"version": "7.7.2",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
"integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==",
"devOptional": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
@@ -31717,7 +31745,7 @@
"version": "5.8.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"dev": true,
"devOptional": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
@@ -31777,6 +31805,15 @@
"node": "*"
}
},
"node_modules/undici-types": {
"version": "7.14.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.14.0.tgz",
"integrity": "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true
},
"node_modules/unicode-canonical-property-names-ecmascript": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz",