Merge branch 'master' into develop

This commit is contained in:
Lorenz Hilpert
2025-10-16 14:56:46 +02:00
26 changed files with 421 additions and 124 deletions

View File

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

View File

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

View File

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

View File

@@ -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';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,6 +6,7 @@ import {
effect,
untracked,
signal,
linkedSignal,
} from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import {
@@ -16,7 +17,10 @@ import {
FilterService,
SearchTrigger,
} from '@isa/shared/filter';
import { injectRestoreScrollPosition } from '@isa/utils/scroll-position';
import {
injectRestoreScrollPosition,
ScrollTopButtonComponent,
} from '@isa/utils/scroll-position';
import { RemissionStartCardComponent } from './remission-start-card/remission-start-card.component';
import { RemissionListSelectComponent } from './remission-list-select/remission-list-select.component';
import {
@@ -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) {

View File

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

View File

@@ -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>

View File

@@ -32,6 +32,8 @@ import { TooltipDirective } from '@isa/ui/tooltip';
import { createInStockResource } from './instock.resource';
import { calculateAvailableStock } from '@isa/remission/data-access';
import { EmptyStateComponent } from '@isa/ui/empty-state';
import { ScrollTopButtonComponent } from '@isa/utils/scroll-position';
@Component({
selector: 'remi-search-item-to-remit-list',
templateUrl: './search-item-to-remit-list.component.html',
@@ -48,6 +50,7 @@ import { EmptyStateComponent } from '@isa/ui/empty-state';
TooltipDirective,
NgIcon,
EmptyStateComponent,
ScrollTopButtonComponent,
],
providers: [provideIcons({ isaActionSearch, isaOtherInfo })],
})