mirror of
https://dev.azure.com/hugendubel/ISA/_git/ISA-Frontend
synced 2025-12-28 22:42:11 +01:00
Compare commits
4 Commits
feature/52
...
4.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a086111ab5 | ||
|
|
15a4718e58 | ||
|
|
40592b4477 | ||
|
|
d430f544f0 |
@@ -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
|
||||
@@ -19,3 +19,5 @@ 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';
|
||||
|
||||
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 {
|
||||
@@ -42,6 +46,8 @@ import {
|
||||
getStockToRemit,
|
||||
RemissionListType,
|
||||
RemissionResponseArgsErrorMessage,
|
||||
UpdateItem,
|
||||
orderByListItems,
|
||||
} from '@isa/remission/data-access';
|
||||
import { injectDialog, injectFeedbackErrorDialog } from '@isa/ui/dialog';
|
||||
import { SearchItemToRemitDialogComponent } from '@isa/remission/shared/search-item-to-remit-dialog';
|
||||
@@ -94,6 +100,7 @@ function querySettingsFactory() {
|
||||
RemissionListDepartmentElementsComponent,
|
||||
RemissionProcessedHintComponent,
|
||||
RemissionListEmptyStateComponent,
|
||||
ScrollTopButtonComponent,
|
||||
],
|
||||
host: {
|
||||
'[class]':
|
||||
@@ -170,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.
|
||||
@@ -259,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 : [];
|
||||
});
|
||||
@@ -364,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -416,8 +437,6 @@ export class RemissionListComponent {
|
||||
return;
|
||||
}
|
||||
|
||||
this.#store.clearSelectedItems();
|
||||
|
||||
untracked(() => {
|
||||
const hits = this.hits();
|
||||
|
||||
@@ -429,6 +448,7 @@ export class RemissionListComponent {
|
||||
!this.searchTriggeredByUser()
|
||||
) {
|
||||
if (hits === 1 && this.remissionStarted()) {
|
||||
this.#store.clearSelectedItems();
|
||||
this.preselectRemissionItem(this.items()[0]);
|
||||
}
|
||||
|
||||
@@ -440,6 +460,7 @@ export class RemissionListComponent {
|
||||
searchTerm,
|
||||
},
|
||||
}).closed.subscribe(async (result) => {
|
||||
this.#store.clearSelectedItems();
|
||||
if (result) {
|
||||
if (this.remissionStarted()) {
|
||||
for (const item of result) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
<ng-icon size="1.5rem" name="isaOtherInfo"></ng-icon>
|
||||
</button>
|
||||
</p>
|
||||
<div class="overflow-y-auto overflow-x-hidden">
|
||||
<div #list class="overflow-y-auto overflow-x-hidden">
|
||||
@if (searchResource.value()?.result; as items) {
|
||||
@for (item of availableSearchResults(); track item.id) {
|
||||
@defer {
|
||||
@@ -58,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 })],
|
||||
})
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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