mirror of
https://dev.azure.com/hugendubel/ISA/_git/ISA-Frontend
synced 2025-12-31 09:37:15 +01:00
Compare commits
26 Commits
fix/4768-5
...
hotfix-538
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c56f394c5 | ||
|
|
a086111ab5 | ||
|
|
15a4718e58 | ||
|
|
40592b4477 | ||
|
|
d430f544f0 | ||
|
|
62e586cfda | ||
|
|
304f8a64e5 | ||
|
|
c672ae4012 | ||
|
|
fd693a4beb | ||
|
|
2c70339f23 | ||
|
|
59f0cc7d43 | ||
|
|
0ca58fe1bf | ||
|
|
8cf80a60a0 | ||
|
|
cffa7721bc | ||
|
|
066ab5d5be | ||
|
|
3bbf79a3c3 | ||
|
|
357485e32f | ||
|
|
39984342a6 | ||
|
|
c52f18e979 | ||
|
|
e58ec93087 | ||
|
|
4e6204817d | ||
|
|
c41355bcdf | ||
|
|
fa8e601660 | ||
|
|
708ec01704 | ||
|
|
332699ca74 | ||
|
|
2cb1f9ec99 |
@@ -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: () =>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'),
|
||||
{
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
}
|
||||
|
||||
.ui-tooltip-panel {
|
||||
@apply pointer-events-auto;
|
||||
|
||||
.triangle {
|
||||
width: 30px;
|
||||
polygon {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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}`;
|
||||
}, '');
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
};
|
||||
3
libs/remission/data-access/src/lib/models/impediment.ts
Normal file
3
libs/remission/data-access/src/lib/models/impediment.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { ImpedimentDTO } from '@generated/swagger/inventory-api';
|
||||
|
||||
export type Impediment = ImpedimentDTO
|
||||
@@ -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';
|
||||
|
||||
@@ -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];
|
||||
7
libs/remission/data-access/src/lib/models/update-item.ts
Normal file
7
libs/remission/data-access/src/lib/models/update-item.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { Impediment } from './impediment';
|
||||
|
||||
export interface UpdateItem {
|
||||
inProgress: boolean;
|
||||
itemId?: number;
|
||||
impediment?: Impediment;
|
||||
}
|
||||
@@ -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<
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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!]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
.scroll-top-button-spacing-bottom {
|
||||
@apply bottom-[5.5rem];
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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!) !== '';
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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]');
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
:host {
|
||||
@apply block h-full;
|
||||
@apply block h-full mt-6;
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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.',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -0,0 +1,3 @@
|
||||
:host {
|
||||
@apply grid grid-flow-row gap-6 h-full;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
:host {
|
||||
@apply grid grid-flow-row gap-6;
|
||||
}
|
||||
@@ -42,5 +42,6 @@
|
||||
[filterInput]="input"
|
||||
(applied)="applied.emit()"
|
||||
(reseted)="reseted.emit()"
|
||||
[canApply]="canApply()"
|
||||
></filter-input-menu>
|
||||
</ng-template>
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
></filter-input-renderer>
|
||||
<filter-actions
|
||||
[inputKey]="filterInput().key"
|
||||
[canApply]="false"
|
||||
[canApply]="canApply()"
|
||||
(applied)="applied.emit()"
|
||||
(reseted)="reseted.emit()"
|
||||
></filter-actions>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
> {}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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>';
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
export const EmptyStateAppearance = {
|
||||
NoResults: 'noResults',
|
||||
NoArticles: 'noArticles',
|
||||
AllDone: 'allDone',
|
||||
SelectAction: 'selectAction',
|
||||
} as const;
|
||||
|
||||
export type EmptyStateAppearance =
|
||||
|
||||
@@ -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() {
|
||||
|
||||
41
libs/ui/input-controls/src/lib/dropdown/dropdown.service.ts
Normal file
41
libs/ui/input-controls/src/lib/dropdown/dropdown.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
57
package-lock.json
generated
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user