Merged PR 1965: feat(remission-list): improve item update handling and UI feedback

feat(remission-list): improve item update handling and UI feedback

Enhance the remission list item management by introducing a more robust
update mechanism that tracks both item removal and impediment updates.
Previously, the component only tracked deletion progress, but now it
handles both deletion and update scenarios, allowing for better state
management and user feedback.

Key changes:
- Replace simple inProgress boolean with UpdateItem interface containing
  inProgress state, itemId, and optional impediment
- Update local items signal directly when items are removed or updated,
  eliminating unnecessary API calls and improving performance
- Add visual highlight to "Remi Menge ändern" button when dialog is open
  using a border style for better accessibility
- Improve error handling by tracking specific item operations
- Ensure selected items are properly removed from store when deleted
  or updated

The new approach optimizes list reloads by only fetching data when
necessary and provides clearer visual feedback during item operations.

Unit Tests updated also

Ref: #5361
This commit is contained in:
Nino Righi
2025-10-06 08:41:47 +00:00
committed by Lorenz Hilpert
parent 40592b4477
commit 15a4718e58
11 changed files with 99 additions and 55 deletions

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

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

@@ -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">
@@ -73,7 +73,7 @@
size="large"
color="brand"
[pending]="remitItemsInProgress()"
[disabled]="!hasSelectedItems() || listItemActionInProgress()"
[disabled]="!hasSelectedItems() || removeItemInProgress()"
>
</ui-stateful-button>
}

View File

@@ -6,6 +6,7 @@ import {
effect,
untracked,
signal,
linkedSignal,
} from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import {
@@ -45,6 +46,7 @@ import {
getStockToRemit,
RemissionListType,
RemissionResponseArgsErrorMessage,
UpdateItem,
} from '@isa/remission/data-access';
import { injectDialog, injectFeedbackErrorDialog } from '@isa/ui/dialog';
import { SearchItemToRemitDialogComponent } from '@isa/remission/shared/search-item-to-remit-dialog';
@@ -174,7 +176,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.
@@ -263,7 +265,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 : [];
});
@@ -368,15 +370,26 @@ 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.
* @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) {
this.items.set(this.items().filter((item) => item.id !== itemId)); // Remove Item
} else {
// Update Item
this.items.update((items) =>
items.map((item) =>
item.id === itemId ? { ...item, impediment } : item,
),
);
}
// Always Unselect Item
this.#store.removeItem(itemId);
}
}
@@ -420,8 +433,6 @@ export class RemissionListComponent {
return;
}
this.#store.clearSelectedItems();
untracked(() => {
const hits = this.hits();
@@ -433,6 +444,7 @@ export class RemissionListComponent {
!this.searchTriggeredByUser()
) {
if (hits === 1 && this.remissionStarted()) {
this.#store.clearSelectedItems();
this.preselectRemissionItem(this.items()[0]);
}
@@ -444,6 +456,7 @@ export class RemissionListComponent {
searchTerm,
},
}).closed.subscribe(async (result) => {
this.#store.clearSelectedItems();
if (result) {
if (this.remissionStarted()) {
for (const item of result) {