mirror of
https://dev.azure.com/hugendubel/ISA/_git/ISA-Frontend
synced 2025-12-28 22:42:11 +01:00
Compare commits
13 Commits
chore/pack
...
4.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a086111ab5 | ||
|
|
15a4718e58 | ||
|
|
40592b4477 | ||
|
|
d430f544f0 | ||
|
|
62e586cfda | ||
|
|
304f8a64e5 | ||
|
|
c672ae4012 | ||
|
|
fd693a4beb | ||
|
|
2c70339f23 | ||
|
|
59f0cc7d43 | ||
|
|
0ca58fe1bf | ||
|
|
8cf80a60a0 | ||
|
|
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'),
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,3 +9,4 @@ export * from './get-receipt-items-from-return.helper';
|
||||
export * from './get-package-numbers-from-return.helper';
|
||||
export * from './get-retail-price-from-item.helper';
|
||||
export * from './get-assortment-from-item.helper';
|
||||
export * from './order-by-list-items.helper';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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!]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -176,19 +176,11 @@ describe('RemissionListItemComponent', () => {
|
||||
expect(component.stockFetching()).toBe(true);
|
||||
});
|
||||
|
||||
it('should have inProgress model with undefined default', () => {
|
||||
it('should have removeOrUpdateItem output', () => {
|
||||
fixture.componentRef.setInput('item', createMockReturnItem());
|
||||
fixture.componentRef.setInput('stock', createMockStockInfo());
|
||||
fixture.detectChanges();
|
||||
expect(component.inProgress()).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should accept inProgress model value', () => {
|
||||
fixture.componentRef.setInput('item', createMockReturnItem());
|
||||
fixture.componentRef.setInput('stock', createMockStockInfo());
|
||||
fixture.componentRef.setInput('inProgress', true);
|
||||
fixture.detectChanges();
|
||||
expect(component.inProgress()).toBe(true);
|
||||
expect(component.removeOrUpdateItem).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
computed,
|
||||
inject,
|
||||
input,
|
||||
model,
|
||||
output,
|
||||
} from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import {
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
ReturnItem,
|
||||
ReturnSuggestion,
|
||||
StockInfo,
|
||||
UpdateItem,
|
||||
} from '@isa/remission/data-access';
|
||||
import {
|
||||
ProductInfoComponent,
|
||||
@@ -103,11 +104,10 @@ export class RemissionListItemComponent {
|
||||
stockFetching = input<boolean>(false);
|
||||
|
||||
/**
|
||||
* ModelSignal indicating whether remission items are currently being processed.
|
||||
* Used to prevent multiple submissions or actions.
|
||||
* @default false
|
||||
* Output event emitter for when the item is deleted or updated.
|
||||
* Emits an object containing the in-progress state and the item itself.
|
||||
*/
|
||||
inProgress = model<boolean>();
|
||||
removeOrUpdateItem = output<UpdateItem>();
|
||||
|
||||
/**
|
||||
* Optional product group value for display or filtering.
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
{{ hits() }} Einträge
|
||||
</span>
|
||||
|
||||
<div class="flex flex-col gap-4 w-full items-center justify-center mb-24">
|
||||
<div class="flex flex-col gap-4 w-full items-center justify-center mb-36">
|
||||
@for (item of items(); track item.id) {
|
||||
@defer (on viewport) {
|
||||
<remi-feature-remission-list-item
|
||||
@@ -32,7 +32,7 @@
|
||||
[stock]="getStockForItem(item)"
|
||||
[stockFetching]="inStockFetching()"
|
||||
[productGroupValue]="getProductGroupValueForItem(item)"
|
||||
(inProgressChange)="onListItemActionInProgress($event)"
|
||||
(removeOrUpdateItem)="onRemoveOrUpdateItem($event)"
|
||||
></remi-feature-remission-list-item>
|
||||
} @placeholder {
|
||||
<div class="h-[7.75rem] w-full flex items-center justify-center">
|
||||
@@ -54,9 +54,14 @@
|
||||
></remi-feature-remission-list-empty-state>
|
||||
</div>
|
||||
|
||||
<utils-scroll-top-button
|
||||
class="flex flex-col self-end fixed bottom-6 mr-6"
|
||||
[class.scroll-top-button-spacing-bottom]="remissionStarted()"
|
||||
></utils-scroll-top-button>
|
||||
|
||||
@if (remissionStarted()) {
|
||||
<ui-stateful-button
|
||||
class="fixed right-6 bottom-6"
|
||||
class="flex flex-col self-end fixed bottom-6 mr-6"
|
||||
(clicked)="remitItems()"
|
||||
(action)="remitItems()"
|
||||
[(state)]="remitItemsState"
|
||||
@@ -70,7 +75,7 @@
|
||||
size="large"
|
||||
color="brand"
|
||||
[pending]="remitItemsInProgress()"
|
||||
[disabled]="!hasSelectedItems() || listItemActionInProgress()"
|
||||
[disabled]="!hasSelectedItems() || removeItemInProgress()"
|
||||
>
|
||||
</ui-stateful-button>
|
||||
}
|
||||
|
||||
@@ -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,16 +44,20 @@ import {
|
||||
calculateAvailableStock,
|
||||
RemissionReturnReceiptService,
|
||||
getStockToRemit,
|
||||
RemissionListType,
|
||||
RemissionResponseArgsErrorMessage,
|
||||
UpdateItem,
|
||||
orderByListItems,
|
||||
} from '@isa/remission/data-access';
|
||||
import { injectDialog } from '@isa/ui/dialog';
|
||||
import { injectDialog, injectFeedbackErrorDialog } from '@isa/ui/dialog';
|
||||
import { SearchItemToRemitDialogComponent } from '@isa/remission/shared/search-item-to-remit-dialog';
|
||||
import { RemissionListType } from '@isa/remission/data-access';
|
||||
import { RemissionReturnCardComponent } from './remission-return-card/remission-return-card.component';
|
||||
import { logger } from '@isa/core/logging';
|
||||
import { RemissionProcessedHintComponent } from './remission-processed-hint/remission-processed-hint.component';
|
||||
import { RemissionListDepartmentElementsComponent } from './remission-list-department-elements/remission-list-department-elements.component';
|
||||
import { injectTabId } from '@isa/core/tabs';
|
||||
import { RemissionListEmptyStateComponent } from './remission-list-empty-state/remission-list-empty-state.component';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
|
||||
function querySettingsFactory() {
|
||||
return inject(ActivatedRoute).snapshot.data['querySettings'];
|
||||
@@ -92,6 +100,7 @@ function querySettingsFactory() {
|
||||
RemissionListDepartmentElementsComponent,
|
||||
RemissionProcessedHintComponent,
|
||||
RemissionListEmptyStateComponent,
|
||||
ScrollTopButtonComponent,
|
||||
],
|
||||
host: {
|
||||
'[class]':
|
||||
@@ -118,6 +127,7 @@ export class RemissionListComponent {
|
||||
activatedTabId = injectTabId();
|
||||
|
||||
searchItemToRemitDialog = injectDialog(SearchItemToRemitDialogComponent);
|
||||
errorDialog = injectFeedbackErrorDialog();
|
||||
|
||||
/**
|
||||
* FilterService instance for managing filter state and queries.
|
||||
@@ -167,7 +177,7 @@ export class RemissionListComponent {
|
||||
* Signal indicating whether a remission list item deletion is in progress.
|
||||
* Used to disable actions while deletion is happening.
|
||||
*/
|
||||
listItemActionInProgress = signal(false);
|
||||
removeItemInProgress = signal(false);
|
||||
|
||||
/**
|
||||
* Computed signal for the current search term from the filter service.
|
||||
@@ -256,7 +266,7 @@ export class RemissionListComponent {
|
||||
* Computed signal for the remission items to display.
|
||||
* @returns Array of ReturnItem or ReturnSuggestion.
|
||||
*/
|
||||
items = computed(() => {
|
||||
items = linkedSignal(() => {
|
||||
const value = this.listResponseValue();
|
||||
return value?.result ? value.result : [];
|
||||
});
|
||||
@@ -361,15 +371,29 @@ export class RemissionListComponent {
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the deletion of a remission list item.
|
||||
* Updates the in-progress state and reloads the list and receipt upon completion.
|
||||
*
|
||||
* @param inProgress - Whether the deletion is currently in progress
|
||||
* Handles the removal or update of an item from the remission list.
|
||||
* Updates the local items signal and the remission store accordingly.
|
||||
* Items with impediments are automatically moved to the end of the list and sorted by attempt count.
|
||||
* @param param0 - Object containing inProgress state, itemId, and optional impediment.
|
||||
*/
|
||||
onListItemActionInProgress(inProgress: boolean) {
|
||||
this.listItemActionInProgress.set(inProgress);
|
||||
if (!inProgress) {
|
||||
this.reloadListAndReturnData();
|
||||
onRemoveOrUpdateItem({ inProgress, itemId, impediment }: UpdateItem) {
|
||||
this.removeItemInProgress.set(inProgress);
|
||||
if (!inProgress && itemId) {
|
||||
if (!impediment || (impediment.attempts && impediment.attempts >= 4)) {
|
||||
this.items.set(this.items().filter((item) => item.id !== itemId)); // Filter Item if no impediment or attempts >= 4 (#5361)
|
||||
} else {
|
||||
// Update Item
|
||||
this.items.update((items) => {
|
||||
const updatedItems = items.map((item) =>
|
||||
item.id === itemId ? { ...item, impediment } : item,
|
||||
);
|
||||
orderByListItems(updatedItems);
|
||||
return updatedItems;
|
||||
});
|
||||
}
|
||||
|
||||
// Always Unselect Item
|
||||
this.#store.removeItem(itemId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -391,36 +415,52 @@ export class RemissionListComponent {
|
||||
});
|
||||
|
||||
/**
|
||||
* Effect that handles the case when there are no items in the remission list after a search.
|
||||
* If the search was triggered by the user, it opens a dialog to search for items to remit.
|
||||
* If remission has already started, it adds the found items to the remission store and remits them.
|
||||
* If not, it navigates to the default remission list.
|
||||
* Effect that handles scenarios where a search yields no results.
|
||||
* If the search was user-initiated and returned no hits, it opens a dialog
|
||||
* to allow the user to add a new item to remit.
|
||||
* If only one hit is found and a remission is started, it selects that item automatically.
|
||||
* This effect runs whenever the remission or stock resource status changes,
|
||||
* or when the search term changes.
|
||||
* It ensures that the user is prompted appropriately based on their actions and the current state of the remission process.
|
||||
* It also checks if the remission is started or if the list type is 'Abteilung' to determine navigation behavior.
|
||||
* @see {@link
|
||||
* https://angular.dev/guide/effects} for more information on Angular effects.
|
||||
* @remarks This effect uses `untracked` to avoid unnecessary re-evaluations
|
||||
* when accessing certain signals.
|
||||
*/
|
||||
emptySearchResultEffect = effect(() => {
|
||||
const status = this.remissionResource.status();
|
||||
const stockStatus = this.inStockResource.status();
|
||||
const searchTerm: string | undefined = this.searchTerm();
|
||||
|
||||
if (status !== 'resolved') {
|
||||
return;
|
||||
}
|
||||
|
||||
const hasItems = !!this.remissionResource.value()?.result?.length;
|
||||
|
||||
if (hasItems || !searchTerm || !this.hasValidSearchTerm()) {
|
||||
if (status !== 'resolved' || stockStatus !== 'resolved') {
|
||||
return;
|
||||
}
|
||||
|
||||
untracked(() => {
|
||||
if (!this.searchTriggeredByUser()) {
|
||||
const hits = this.hits();
|
||||
|
||||
// #5338 - Select item automatically if only one hit after search
|
||||
if (
|
||||
!!hits ||
|
||||
!searchTerm ||
|
||||
!this.hasValidSearchTerm() ||
|
||||
!this.searchTriggeredByUser()
|
||||
) {
|
||||
if (hits === 1 && this.remissionStarted()) {
|
||||
this.#store.clearSelectedItems();
|
||||
this.preselectRemissionItem(this.items()[0]);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.searchItemToRemitDialog({
|
||||
data: {
|
||||
searchTerm,
|
||||
isDepartment: this.isDepartment(),
|
||||
},
|
||||
}).closed.subscribe(async (result) => {
|
||||
this.#store.clearSelectedItems();
|
||||
if (result) {
|
||||
if (this.remissionStarted()) {
|
||||
for (const item of result) {
|
||||
@@ -432,9 +472,8 @@ export class RemissionListComponent {
|
||||
} else if (this.isDepartment()) {
|
||||
return await this.navigateToDefaultRemissionList();
|
||||
}
|
||||
|
||||
this.reloadListAndReturnData();
|
||||
}
|
||||
this.reloadListAndReturnData();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -493,17 +532,10 @@ export class RemissionListComponent {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
this.remitItemsState.set('success');
|
||||
this.reloadListAndReturnData();
|
||||
} catch (error) {
|
||||
this.#logger.error('Failed to remit items', error);
|
||||
this.remitItemsError.set(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Artikel konnten nicht remittiert werden',
|
||||
);
|
||||
this.remitItemsState.set('error');
|
||||
await this.handleRemitItemsError(error);
|
||||
}
|
||||
|
||||
this.#store.clearSelectedItems();
|
||||
@@ -520,6 +552,62 @@ export class RemissionListComponent {
|
||||
this.#store.reloadReturn();
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-Selects a remission item if it has available stock and can be remitted.
|
||||
* Updates the remission store with the selected item.
|
||||
* @param item - The ReturnItem or ReturnSuggestion to select.
|
||||
* @returns void
|
||||
*/
|
||||
preselectRemissionItem(item: RemissionItem) {
|
||||
if (!!item && item.id) {
|
||||
const inStock = this.getAvailableStockForItem(item);
|
||||
const stockToRemit = getStockToRemit({
|
||||
remissionItem: item,
|
||||
remissionListType: this.selectedRemissionListType(),
|
||||
availableStock: inStock,
|
||||
});
|
||||
|
||||
if (inStock > 0 && stockToRemit > 0) {
|
||||
this.#store.selectRemissionItem(item.id, item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles errors that occur during the remission of items.
|
||||
* Logs the error, displays an error dialog, and reloads the list and return data.
|
||||
* If the error indicates that the remission is already completed, it clears the remission state.
|
||||
* Sets the stateful button to 'error' to indicate the failure.
|
||||
* @param error - The error object caught during the remission process.
|
||||
* @returns A promise that resolves when the error handling is complete.
|
||||
*/
|
||||
async handleRemitItemsError(error: any) {
|
||||
this.#logger.error('Failed to remit items', error);
|
||||
|
||||
const errorMessage =
|
||||
error?.error?.message ??
|
||||
error?.message ??
|
||||
'Artikel konnten nicht remittiert werden';
|
||||
|
||||
this.remitItemsError.set(errorMessage);
|
||||
|
||||
await firstValueFrom(
|
||||
this.errorDialog({
|
||||
data: {
|
||||
errorMessage,
|
||||
},
|
||||
}).closed,
|
||||
);
|
||||
|
||||
if (errorMessage === RemissionResponseArgsErrorMessage.AlreadyCompleted) {
|
||||
this.#store.clearState();
|
||||
}
|
||||
|
||||
this.reloadListAndReturnData();
|
||||
|
||||
this.remitItemsState.set('error'); // Stateful-Button auf Error setzen
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigates to the default remission list based on the current activated tab ID.
|
||||
* This method is used to redirect the user to the remission list after completing or starting a remission.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { inject, resource } from '@angular/core';
|
||||
import { ListResponseArgs, ResponseArgsError } from '@isa/common/data-access';
|
||||
import {
|
||||
orderByListItems,
|
||||
QueryTokenInput,
|
||||
RemissionItem,
|
||||
RemissionListType,
|
||||
@@ -9,7 +10,6 @@ import {
|
||||
RemissionSupplierService,
|
||||
} from '@isa/remission/data-access';
|
||||
import { SearchTrigger } from '@isa/shared/filter';
|
||||
import { parseISO, compareDesc } from 'date-fns';
|
||||
import { isEan } from '@isa/utils/ean-validation';
|
||||
|
||||
/**
|
||||
@@ -144,7 +144,7 @@ export const createRemissionListResource = (
|
||||
const hasOrderBy = !!queryToken?.orderBy && queryToken.orderBy.length > 0;
|
||||
|
||||
if (!hasOrderBy && res && res.result && Array.isArray(res.result)) {
|
||||
sortResponseResult(res);
|
||||
orderByListItems(res.result);
|
||||
}
|
||||
|
||||
return res;
|
||||
@@ -152,55 +152,6 @@ export const createRemissionListResource = (
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Sorts the remission items in the response based on specific criteria:
|
||||
* - Items with impediments are moved to the end of the list.
|
||||
* - Manually added items are prioritized to appear first.
|
||||
* - (Commented out) Items can be sorted by creation date in descending order.
|
||||
* @param {ListResponseArgs<RemissionItem>} resopnse - The response object containing remission items to be sorted
|
||||
* @returns {void} The function modifies the response object in place
|
||||
*/
|
||||
const sortResponseResult = (
|
||||
resopnse: ListResponseArgs<RemissionItem>,
|
||||
): void => {
|
||||
resopnse.result.sort((a, b) => {
|
||||
const aHasImpediment = !!a.impediment;
|
||||
const bHasImpediment = !!b.impediment;
|
||||
const aIsManuallyAdded = a.source === 'manually-added';
|
||||
const bIsManuallyAdded = b.source === 'manually-added';
|
||||
|
||||
// First priority: move all items with impediment to the end of the list
|
||||
if (!aHasImpediment && bHasImpediment) {
|
||||
return -1;
|
||||
}
|
||||
if (aHasImpediment && !bHasImpediment) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Second priority: manually-added items come first
|
||||
if (aIsManuallyAdded && !bIsManuallyAdded) {
|
||||
return -1;
|
||||
}
|
||||
if (!aIsManuallyAdded && bIsManuallyAdded) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
// #5295 Fix - Sortierung über Created (Pflichtremission) wird wie auch die Sortierung über die SORT Nummer (Abteilungsremission) bereits über das Backend erledigt
|
||||
// Third priority: sort by created date (latest first)
|
||||
// if (a.created && b.created) {
|
||||
// const dateA = parseISO(a.created);
|
||||
// const dateB = parseISO(b.created);
|
||||
// return compareDesc(dateA, dateB); // Descending order (latest first)
|
||||
// }
|
||||
|
||||
// // Handle cases where created date might be missing
|
||||
// if (a.created && !b.created) return -1;
|
||||
// if (!a.created && b.created) return 1;
|
||||
|
||||
return 0;
|
||||
});
|
||||
};
|
||||
|
||||
// #5128 #5234 Bei Exact Search soll er über Alle Listen nur mit dem Input ohne aktive Filter / orderBy suchen
|
||||
/**
|
||||
* Checks if the query token is an exact search based on the search trigger.
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@
|
||||
[removeable]="canRemoveItems()"
|
||||
[receiptId]="receiptId()"
|
||||
[returnId]="returnId()"
|
||||
(removed)="returnResource.reload()"
|
||||
(reloadReturn)="returnResource.reload()"
|
||||
></remi-remission-return-receipt-details-item>
|
||||
@if (!last) {
|
||||
<hr class="border-isa-neutral-300" />
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -1,18 +1,14 @@
|
||||
@if (item()) {
|
||||
<remi-select-remi-quantity-and-reason></remi-select-remi-quantity-and-reason>
|
||||
} @else {
|
||||
<button
|
||||
class="absolute top-4 right-[1.33rem]"
|
||||
type="button"
|
||||
uiTextButton
|
||||
size="small"
|
||||
color="subtle"
|
||||
(click)="close(undefined)"
|
||||
tabindex="-1"
|
||||
data-what="button"
|
||||
data-which="close-dialog"
|
||||
>
|
||||
Schließen
|
||||
</button>
|
||||
<remi-search-item-to-remit-list></remi-search-item-to-remit-list>
|
||||
}
|
||||
<button
|
||||
class="absolute top-4 right-[1.33rem]"
|
||||
type="button"
|
||||
uiTextButton
|
||||
size="small"
|
||||
color="subtle"
|
||||
(click)="close(undefined)"
|
||||
tabindex="-1"
|
||||
data-what="button"
|
||||
data-which="close-dialog"
|
||||
>
|
||||
Schließen
|
||||
</button>
|
||||
<remi-search-item-to-remit-list></remi-search-item-to-remit-list>
|
||||
|
||||
@@ -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.',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
name="isaActionSearch"
|
||||
color="brand"
|
||||
(click)="triggerSearch()"
|
||||
[pending]="searchResource.isLoading()"
|
||||
[pending]="searchResource.isLoading() || inStockResource.isLoading()"
|
||||
data-what="button"
|
||||
data-which="search-submit"
|
||||
></ui-icon-button>
|
||||
@@ -34,24 +34,23 @@
|
||||
<ng-icon size="1.5rem" name="isaOtherInfo"></ng-icon>
|
||||
</button>
|
||||
</p>
|
||||
<div class="overflow-y-auto">
|
||||
<div #list class="overflow-y-auto overflow-x-hidden">
|
||||
@if (searchResource.value()?.result; as items) {
|
||||
@for (item of items; track item.id) {
|
||||
@for (item of availableSearchResults(); track item.id) {
|
||||
@defer {
|
||||
@let inStock = getAvailableStockForItem(item);
|
||||
@if (inStock > 0) {
|
||||
<remi-search-item-to-remit
|
||||
[item]="item"
|
||||
[inStock]="inStock"
|
||||
data-what="list-item"
|
||||
data-which="search-result"
|
||||
[attr.data-item-id]="item.id"
|
||||
></remi-search-item-to-remit>
|
||||
}
|
||||
<remi-search-item-to-remit
|
||||
[item]="item"
|
||||
[inStock]="getAvailableStockForItem(item)"
|
||||
data-what="list-item"
|
||||
data-which="search-result"
|
||||
[attr.data-item-id]="item.id"
|
||||
></remi-search-item-to-remit>
|
||||
}
|
||||
}
|
||||
}
|
||||
@if (!hasItems() && !searchResource.isLoading()) {
|
||||
@if (
|
||||
!hasItems() && !searchResource.isLoading() && !inStockResource.isLoading()
|
||||
) {
|
||||
<ui-empty-state
|
||||
class="w-full justify-self-center"
|
||||
title="Keine Suchergebnisse"
|
||||
@@ -59,4 +58,8 @@
|
||||
>
|
||||
</ui-empty-state>
|
||||
}
|
||||
<utils-scroll-top-button
|
||||
class="flex flex-col self-end absolute bottom-6 right-6"
|
||||
[target]="list"
|
||||
></utils-scroll-top-button>
|
||||
</div>
|
||||
|
||||
@@ -32,6 +32,8 @@ import { TooltipDirective } from '@isa/ui/tooltip';
|
||||
import { createInStockResource } from './instock.resource';
|
||||
import { calculateAvailableStock } from '@isa/remission/data-access';
|
||||
import { EmptyStateComponent } from '@isa/ui/empty-state';
|
||||
import { ScrollTopButtonComponent } from '@isa/utils/scroll-position';
|
||||
|
||||
@Component({
|
||||
selector: 'remi-search-item-to-remit-list',
|
||||
templateUrl: './search-item-to-remit-list.component.html',
|
||||
@@ -48,6 +50,7 @@ import { EmptyStateComponent } from '@isa/ui/empty-state';
|
||||
TooltipDirective,
|
||||
NgIcon,
|
||||
EmptyStateComponent,
|
||||
ScrollTopButtonComponent,
|
||||
],
|
||||
providers: [provideIcons({ isaActionSearch, isaOtherInfo })],
|
||||
})
|
||||
@@ -57,6 +60,14 @@ export class SearchItemToRemitListComponent implements OnInit {
|
||||
|
||||
searchParams = signal<SearchByTermInput | undefined>(undefined);
|
||||
|
||||
availableSearchResults = computed(() => {
|
||||
return (
|
||||
this.searchResource.value()?.result?.filter((item) => {
|
||||
return this.getAvailableStockForItem(item) > 0;
|
||||
}) ?? []
|
||||
);
|
||||
});
|
||||
|
||||
inStockResource = createInStockResource(() => {
|
||||
return {
|
||||
itemIds:
|
||||
@@ -69,7 +80,7 @@ export class SearchItemToRemitListComponent implements OnInit {
|
||||
inStockResponseValue = computed(() => this.inStockResource.value());
|
||||
|
||||
hasItems = computed(() => {
|
||||
return (this.searchResource.value()?.result?.length ?? 0) > 0;
|
||||
return (this.availableSearchResults()?.length ?? 0) > 0;
|
||||
});
|
||||
|
||||
stockInfoMap = computed(() => {
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
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,6 +23,9 @@ import { SearchItemToRemitDialogComponent } from './search-item-to-remit-dialog.
|
||||
})
|
||||
export class SearchItemToRemitComponent {
|
||||
host = inject(SearchItemToRemitDialogComponent);
|
||||
quantityAndReasonDialog = injectDialog(
|
||||
SelectRemiQuantityAndReasonDialogComponent,
|
||||
);
|
||||
|
||||
item = input.required<Item>();
|
||||
inStock = input.required<number>();
|
||||
@@ -29,4 +35,22 @@ export class SearchItemToRemitComponent {
|
||||
productInfoOrientation = computed(() => {
|
||||
return this.desktopBreakpoint() ? 'vertical' : 'horizontal';
|
||||
});
|
||||
|
||||
async openQuantityAndReasonDialog() {
|
||||
if (this.item()) {
|
||||
const dialogRef = this.quantityAndReasonDialog({
|
||||
title: 'Dieser Artikel steht nicht auf der Remi Liste',
|
||||
data: {
|
||||
item: this.item(),
|
||||
inStock: this.inStock(),
|
||||
},
|
||||
width: '36rem',
|
||||
});
|
||||
const dialogResult = await firstValueFrom(dialogRef.closed);
|
||||
|
||||
if (dialogResult) {
|
||||
this.host.close(dialogResult);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: 0, reason: '' };
|
||||
|
||||
quantitiesAndResons = model<QuantityAndReason[]>([this.initialItem]);
|
||||
|
||||
addQuantityReasonItem(): void {
|
||||
this.quantitiesAndResons.update((items) => [...items, this.initialItem]);
|
||||
}
|
||||
|
||||
removeQuantityReasonItem(position: number): void {
|
||||
const currentItems = this.quantitiesAndResons();
|
||||
if (currentItems.length > 1) {
|
||||
this.quantitiesAndResons.update((items) =>
|
||||
items.filter((_, index) => index !== position),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
setQuantityAndReason(position: number, qar: QuantityAndReason): void {
|
||||
this.quantitiesAndResons.update((items) => {
|
||||
const newItems = [...items];
|
||||
newItems[position] = qar;
|
||||
return newItems;
|
||||
});
|
||||
}
|
||||
|
||||
params = computed(() => {
|
||||
const items = this.quantitiesAndResons();
|
||||
const item = this.host.item();
|
||||
|
||||
if (!item) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return items.map((qar) => ({
|
||||
item,
|
||||
quantity: qar.quantity,
|
||||
reason: qar.reason,
|
||||
}));
|
||||
});
|
||||
|
||||
canAddToRemiListResource = resource({
|
||||
params: this.params,
|
||||
loader: async ({ params, abortSignal }) => {
|
||||
if (
|
||||
!this.host.item() ||
|
||||
params.some((p) => !p.reason) ||
|
||||
params.some((p) => !p.quantity)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const maxQuantityErrors = params.filter((p) => !(p.quantity <= 999));
|
||||
if (maxQuantityErrors.length > 0) {
|
||||
const errRes: BatchResponseArgs<ReturnItem> = {
|
||||
completed: false,
|
||||
error: true,
|
||||
total: maxQuantityErrors.length,
|
||||
invalidProperties: {
|
||||
quantity: 'Die Menge darf maximal 999 sein.',
|
||||
},
|
||||
};
|
||||
return errRes;
|
||||
}
|
||||
|
||||
return this.#remiService.canAddItemToRemiList(params, abortSignal);
|
||||
},
|
||||
});
|
||||
|
||||
canReturn = computed(() => {
|
||||
const results = this.canAddToRemiListResource.value();
|
||||
|
||||
if (!results) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (results.failed && results.failed.length > 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
results.successful &&
|
||||
results.successful.length === this.quantitiesAndResons().length
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
canReturnErrors = computed(() => {
|
||||
const results = this.canAddToRemiListResource.value();
|
||||
if (results?.invalidProperties) {
|
||||
return Object.values(results.invalidProperties);
|
||||
}
|
||||
|
||||
if (!results?.failed) {
|
||||
return [];
|
||||
}
|
||||
return results.failed.map((item) =>
|
||||
item.invalidProperties
|
||||
? Object.values(item.invalidProperties).join(', ')
|
||||
: [],
|
||||
) as string[];
|
||||
});
|
||||
|
||||
async addToRemiList() {
|
||||
const canAddValue = this.canAddToRemiListResource.value();
|
||||
|
||||
if (!canAddValue) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (canAddValue.failed?.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
// #5273, #4768 Fix - Items dürfen nur zur Pflichtremission hinzugefügt werden
|
||||
const result: Array<ReturnItem> = await this.#remiService.addToList(
|
||||
this.params(),
|
||||
);
|
||||
|
||||
this.#feedbackDialog({
|
||||
data: {
|
||||
message: this.#remiStore.remissionStarted()
|
||||
? 'Wurde zum Warenbegleitschein hinzugefügt'
|
||||
: 'Wurde zur Remi Liste hinzugefügt',
|
||||
},
|
||||
});
|
||||
|
||||
this.host.close(result);
|
||||
}
|
||||
}
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
inject,
|
||||
model,
|
||||
resource,
|
||||
} from '@angular/core';
|
||||
import { QuantityAndReasonItemComponent } from './quantity-and-reason-item.component';
|
||||
import {
|
||||
ButtonComponent,
|
||||
TextButtonComponent,
|
||||
IconButtonComponent,
|
||||
} from '@isa/ui/buttons';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import { isaActionPlus, isaActionClose } from '@isa/icons';
|
||||
import {
|
||||
RemissionSearchService,
|
||||
RemissionStore,
|
||||
ReturnItem,
|
||||
} from '@isa/remission/data-access';
|
||||
import { DialogContentDirective, injectFeedbackDialog } from '@isa/ui/dialog';
|
||||
import { BatchResponseArgs } from '@isa/common/data-access';
|
||||
import { Item } from '@isa/catalogue/data-access';
|
||||
import { ProductInfoComponent } from '@isa/remission/shared/product';
|
||||
|
||||
export type SelectRemiQuantityAndReasonDialogData = {
|
||||
item: Item;
|
||||
inStock: number;
|
||||
};
|
||||
|
||||
export type SelectRemiQuantityAndReasonDialogResult =
|
||||
| undefined
|
||||
| Array<ReturnItem>;
|
||||
|
||||
export interface QuantityAndReason {
|
||||
quantity: number;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'remi-select-remi-quantity-and-reason-dialog',
|
||||
templateUrl: './select-remi-quantity-and-reason-dialog.component.html',
|
||||
styleUrls: ['./select-remi-quantity-and-reason-dialog.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
standalone: true,
|
||||
imports: [
|
||||
QuantityAndReasonItemComponent,
|
||||
TextButtonComponent,
|
||||
NgIcon,
|
||||
ButtonComponent,
|
||||
IconButtonComponent,
|
||||
ProductInfoComponent,
|
||||
],
|
||||
providers: [provideIcons({ isaActionPlus, isaActionClose })],
|
||||
})
|
||||
export class SelectRemiQuantityAndReasonDialogComponent extends DialogContentDirective<
|
||||
SelectRemiQuantityAndReasonDialogData,
|
||||
SelectRemiQuantityAndReasonDialogResult
|
||||
> {
|
||||
#remiService = inject(RemissionSearchService);
|
||||
#remiStore = inject(RemissionStore);
|
||||
#feedbackDialog = injectFeedbackDialog();
|
||||
|
||||
initialItem: QuantityAndReason = { quantity: 0, reason: '' };
|
||||
|
||||
quantitiesAndResons = model<QuantityAndReason[]>([this.initialItem]);
|
||||
|
||||
addQuantityReasonItem(): void {
|
||||
this.quantitiesAndResons.update((items) => [...items, this.initialItem]);
|
||||
}
|
||||
|
||||
removeQuantityReasonItem(position: number): void {
|
||||
const currentItems = this.quantitiesAndResons();
|
||||
if (currentItems.length > 1) {
|
||||
this.quantitiesAndResons.update((items) =>
|
||||
items.filter((_, index) => index !== position),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
setQuantityAndReason(position: number, qar: QuantityAndReason): void {
|
||||
this.quantitiesAndResons.update((items) => {
|
||||
const newItems = [...items];
|
||||
newItems[position] = qar;
|
||||
return newItems;
|
||||
});
|
||||
}
|
||||
|
||||
params = computed(() => {
|
||||
const items = this.quantitiesAndResons();
|
||||
const item = this.data.item;
|
||||
|
||||
if (!item) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return items.map((qar) => ({
|
||||
item,
|
||||
quantity: qar.quantity,
|
||||
reason: qar.reason,
|
||||
}));
|
||||
});
|
||||
|
||||
canAddToRemiListResource = resource({
|
||||
params: this.params,
|
||||
loader: async ({ params, abortSignal }) => {
|
||||
if (
|
||||
!this.data.item ||
|
||||
params.some((p) => !p.reason) ||
|
||||
params.some((p) => !p.quantity)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const maxQuantityErrors = params.filter((p) => !(p.quantity <= 999));
|
||||
if (maxQuantityErrors.length > 0) {
|
||||
const errRes: BatchResponseArgs<ReturnItem> = {
|
||||
completed: false,
|
||||
error: true,
|
||||
total: maxQuantityErrors.length,
|
||||
invalidProperties: {
|
||||
quantity: 'Die Menge darf maximal 999 sein.',
|
||||
},
|
||||
};
|
||||
return errRes;
|
||||
}
|
||||
|
||||
return this.#remiService.canAddItemToRemiList(params, abortSignal);
|
||||
},
|
||||
});
|
||||
|
||||
canReturn = computed(() => {
|
||||
const results = this.canAddToRemiListResource.value();
|
||||
|
||||
if (!results) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (results.failed && results.failed.length > 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
results.successful &&
|
||||
results.successful.length === this.quantitiesAndResons().length
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
canReturnErrors = computed(() => {
|
||||
const results = this.canAddToRemiListResource.value();
|
||||
if (results?.invalidProperties) {
|
||||
return Object.values(results.invalidProperties);
|
||||
}
|
||||
|
||||
if (!results?.failed) {
|
||||
return [];
|
||||
}
|
||||
return results.failed.map((item) =>
|
||||
item.invalidProperties
|
||||
? Object.values(item.invalidProperties).join(', ')
|
||||
: [],
|
||||
) as string[];
|
||||
});
|
||||
|
||||
async addToRemiList() {
|
||||
const canAddValue = this.canAddToRemiListResource.value();
|
||||
|
||||
if (!canAddValue) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (canAddValue.failed?.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
// #5273, #4768 Fix - Items dürfen nur zur Pflichtremission hinzugefügt werden
|
||||
const result: Array<ReturnItem> = await this.#remiService.addToList(
|
||||
this.params(),
|
||||
);
|
||||
|
||||
this.#feedbackDialog({
|
||||
data: {
|
||||
message: this.#remiStore.remissionStarted()
|
||||
? 'Wurde zum Warenbegleitschein hinzugefügt'
|
||||
: 'Wurde zur Remi Liste hinzugefügt',
|
||||
},
|
||||
});
|
||||
|
||||
this.close(result);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user