Merged PR 1919: feat(remission): add impediment management and UI enhancements for remission...

feat(remission): add impediment management and UI enhancements for remission list

Implement comprehensive impediment handling for return items and suggestions
with enhanced user interface components and improved data access layer.

Key additions:
- Add impediment update schema and validation for return items
- Implement RemissionReturnReceiptService with full CRUD operations
- Create RemissionListItemComponent with actions and selection capabilities
- Add ProductInfoComponent with responsive layout and labeling
- Enhance UI Dialog system with improved injection patterns and testing
- Add comprehensive test coverage for all new components and services
- Implement proper data attributes for E2E testing support

Technical improvements:
- Follow SOLID principles with clear separation of concerns
- Use OnPush change detection strategy for optimal performance
- Implement proper TypeScript typing with Zod schema validation
- Add comprehensive JSDoc documentation for all public APIs
- Use modern Angular signals and computed properties for state management

Refs: #5275, #5038
This commit is contained in:
Nino Righi
2025-08-14 14:05:01 +00:00
committed by Andreas Schickinger
parent 0740273dbc
commit 514715589b
29 changed files with 832 additions and 116 deletions

View File

@@ -5,8 +5,8 @@
uiTextButton
color="strong"
(click)="deleteItemFromList()"
[disabled]="deleteRemissionListItemInProgress()"
[pending]="deleteRemissionListItemInProgress()"
[disabled]="inProgress()"
[pending]="inProgress()"
data-what="button"
data-which="remove-remission-item"
>
@@ -21,7 +21,7 @@
uiTextButton
color="strong"
(click)="openRemissionQuantityDialog()"
[disabled]="deleteRemissionListItemInProgress()"
[disabled]="inProgress()"
data-what="button"
data-which="change-remission-quantity"
>

View File

@@ -11,12 +11,14 @@ import { logger } from '@isa/core/logging';
import {
RemissionItem,
RemissionItemSource,
RemissionListType,
RemissionReturnReceiptService,
RemissionStore,
} from '@isa/remission/data-access';
import { TextButtonComponent } from '@isa/ui/buttons';
import { injectFeedbackDialog, injectNumberInputDialog } from '@isa/ui/dialog';
import { firstValueFrom } from 'rxjs';
import { injectRemissionListType } from '../injects/inject-remission-list-type';
@Component({
selector: 'remi-feature-remission-list-item-actions',
@@ -52,6 +54,12 @@ export class RemissionListItemActionsComponent {
*/
#store = inject(RemissionStore);
/**
* Signal indicating whether remission has started.
* Used to determine if the item can be selected or not.
*/
remissionListType = injectRemissionListType();
/**
* Service for handling remission return receipts.
* @private
@@ -75,9 +83,8 @@ export class RemissionListItemActionsComponent {
* ModelSignal indicating whether remission items are currently being processed.
* Used to prevent multiple submissions or actions.
* @default false
*
*/
deleteRemissionListItemInProgress = model<boolean>();
inProgress = model<boolean>();
/**
* Signal indicating whether remission has started.
@@ -109,13 +116,14 @@ export class RemissionListItemActionsComponent {
/**
* Opens a dialog to change the remission quantity for the current item.
* Prompts the user to enter a new quantity and validates the input.
* If the input is valid, updates the remission quantity in the store.
* Displays a feedback dialog upon successful update.
* Prompts the user to enter a new quantity and updates the store with the new value
* if valid.
* If the item is not found, it updates the impediment with a comment.
*/
async openRemissionQuantityDialog(): Promise<void> {
const dialogRef = this.#dialog({
title: 'Remi-Menge ändern',
displayClose: true,
data: {
message: 'Wie viele Exemplare können remittiert werden?',
subMessage: this.selectedQuantityDiffersFromStockToRemit()
@@ -125,6 +133,7 @@ export class RemissionListItemActionsComponent {
? `${this.stockToRemit()}x`
: undefined,
inputLabel: 'Remi-Menge',
closeText: 'Produkt nicht gefunden',
inputValidation: [
{
errorKey: 'required',
@@ -141,29 +150,60 @@ export class RemissionListItemActionsComponent {
});
const result = await firstValueFrom(dialogRef.closed);
// Dialog Close
if (!result) {
return;
}
const itemId = this.item()?.id;
const quantity = result?.inputValue;
if (itemId && quantity !== undefined && quantity > 0) {
// Speichern CTA
this.#store.updateRemissionQuantity(itemId, this.item(), quantity);
this.#feedbackDialog({
data: { message: 'Remi-Menge wurde geändert' },
});
} else if (itemId) {
// Produkt nicht gefunden CTA
try {
this.inProgress.set(true);
if (this.remissionListType() === RemissionListType.Pflicht) {
await this.#remissionReturnReceiptService.updateReturnItemImpediment({
itemId,
comment: 'Produkt nicht gefunden',
});
}
if (this.remissionListType() === RemissionListType.Abteilung) {
await this.#remissionReturnReceiptService.updateReturnSuggestionImpediment(
{
itemId,
comment: 'Produkt nicht gefunden',
},
);
}
} catch (error) {
this.#logger.error('Failed to update impediment', error);
}
this.inProgress.set(false);
}
}
/**
* Deletes the current item from the remission list.
* Only proceeds if the item has an ID and deletion is not already in progress.
* Sets the deleteRemissionListItemInProgress signal to true during deletion.
* Logs an error if the deletion fails.
* Only proceeds if the item has an ID and no other deletion is in progress.
* Calls the service to delete the item and handles any errors.
*/
async deleteItemFromList() {
const itemId = this.item()?.id;
if (!itemId || this.deleteRemissionListItemInProgress()) {
if (!itemId || this.inProgress()) {
return;
}
this.deleteRemissionListItemInProgress.set(true);
this.inProgress.set(true);
try {
await this.#remissionReturnReceiptService.deleteReturnItem({ itemId });
@@ -171,6 +211,6 @@ export class RemissionListItemActionsComponent {
this.#logger.error('Failed to delete return item', error);
}
this.deleteRemissionListItemInProgress.set(false);
this.inProgress.set(false);
}
}

View File

@@ -7,7 +7,6 @@ import {
} from '@angular/core';
import { FormsModule } from '@angular/forms';
import { RemissionItem, RemissionStore } from '@isa/remission/data-access';
import { TextButtonComponent } from '@isa/ui/buttons';
import { CheckboxComponent } from '@isa/ui/input-controls';
@Component({
@@ -15,7 +14,7 @@ import { CheckboxComponent } from '@isa/ui/input-controls';
templateUrl: './remission-list-item-select.component.html',
styleUrl: './remission-list-item-select.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [FormsModule, TextButtonComponent, CheckboxComponent],
imports: [FormsModule, CheckboxComponent],
})
export class RemissionListItemSelectComponent {
/**

View File

@@ -33,6 +33,15 @@
></remi-product-stock-info>
</ui-item-row-data>
@if (displayImpediment()) {
<ui-item-row-data
class="w-fit"
[class.row-start-second]="desktopBreakpoint()"
>
<ui-label [type]="Labeltype.Notice">{{ impediment() }}</ui-label>
</ui-item-row-data>
}
<ui-item-row-data class="justify-end desktop:justify-between col-end-last">
@if (desktopBreakpoint()) {
<remi-feature-remission-list-item-select
@@ -49,9 +58,7 @@
[selectedQuantityDiffersFromStockToRemit]="
selectedQuantityDiffersFromStockToRemit()
"
(deleteRemissionListItemInProgressChange)="
deleteRemissionListItemInProgress.set($event)
"
(inProgressChange)="inProgress.set($event)"
></remi-feature-remission-list-item-actions>
</ui-item-row-data>
</ui-client-row>

View File

@@ -10,6 +10,10 @@
@apply isa-desktop:col-span-2 desktop-large:col-span-1;
}
.row-start-second {
grid-row-start: 2;
}
.col-end-last {
grid-column-end: -1;
}

View File

@@ -17,6 +17,7 @@ import {
import { MockComponent } from 'ng-mocks';
import { RemissionListItemSelectComponent } from './remission-list-item-select.component';
import { RemissionListItemActionsComponent } from './remission-list-item-actions.component';
import { LabelComponent } from '@isa/ui/label';
import { signal } from '@angular/core';
// --- Setup dynamic mocking for injectRemissionListType ---
@@ -25,6 +26,15 @@ jest.mock('../injects/inject-remission-list-type', () => ({
injectRemissionListType: () => () => remissionListTypeValue,
}));
// Mock the breakpoint function
jest.mock('@isa/ui/layout', () => ({
breakpoint: jest.fn(() => jest.fn(() => true)), // Default to desktop
Breakpoint: {
DekstopL: 'DekstopL',
DekstopXL: 'DekstopXL',
},
}));
// Mock the calculation functions to have predictable behavior
jest.mock('@isa/remission/data-access', () => ({
...jest.requireActual('@isa/remission/data-access'),
@@ -85,6 +95,7 @@ describe('RemissionListItemComponent', () => {
MockComponent(ProductShelfMetaInfoComponent),
MockComponent(RemissionListItemSelectComponent),
MockComponent(RemissionListItemActionsComponent),
MockComponent(LabelComponent),
],
providers: [
provideHttpClient(),
@@ -150,23 +161,72 @@ describe('RemissionListItemComponent', () => {
expect(component.productGroupValue()).toBe(testValue);
});
it('should have deleteRemissionListItemInProgress model with undefined default', () => {
it('should have stockFetching input with false default', () => {
fixture.componentRef.setInput('item', createMockReturnItem());
fixture.componentRef.setInput('stock', createMockStockInfo());
fixture.detectChanges();
expect(component.deleteRemissionListItemInProgress()).toBeUndefined();
expect(component.stockFetching()).toBe(false);
});
it('should accept deleteRemissionListItemInProgress model value', () => {
it('should accept stockFetching input value', () => {
fixture.componentRef.setInput('item', createMockReturnItem());
fixture.componentRef.setInput('stock', createMockStockInfo());
fixture.componentRef.setInput('deleteRemissionListItemInProgress', true);
fixture.componentRef.setInput('stockFetching', true);
fixture.detectChanges();
expect(component.deleteRemissionListItemInProgress()).toBe(true);
expect(component.stockFetching()).toBe(true);
});
it('should have inProgress model with undefined default', () => {
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);
});
});
describe('computed properties', () => {
describe('desktopBreakpoint', () => {
it('should be defined and accessible', () => {
fixture.componentRef.setInput('item', createMockReturnItem());
fixture.componentRef.setInput('stock', createMockStockInfo());
fixture.detectChanges();
expect(component.desktopBreakpoint).toBeDefined();
expect(typeof component.desktopBreakpoint()).toBe('boolean');
});
});
describe('remissionListType', () => {
it('should return injected remission list type', () => {
setRemissionListType(RemissionListType.Abteilung);
fixture.componentRef.setInput('item', createMockReturnItem());
fixture.componentRef.setInput('stock', createMockStockInfo());
fixture.detectChanges();
expect(component.remissionListType()).toBe(RemissionListType.Abteilung);
});
it('should update when remission list type changes', () => {
setRemissionListType(RemissionListType.Pflicht);
fixture.componentRef.setInput('item', createMockReturnItem());
fixture.componentRef.setInput('stock', createMockStockInfo());
fixture.detectChanges();
expect(component.remissionListType()).toBe(RemissionListType.Pflicht);
setRemissionListType(RemissionListType.Abteilung);
expect(component.remissionListType()).toBe(RemissionListType.Abteilung);
});
});
describe('availableStock', () => {
it('should calculate available stock correctly', () => {
const {
@@ -489,6 +549,135 @@ describe('RemissionListItemComponent', () => {
const orientation = component.remiProductInfoOrientation();
expect(['horizontal', 'vertical']).toContain(orientation);
});
it('should depend on desktop breakpoint', () => {
fixture.componentRef.setInput('item', createMockReturnItem());
fixture.componentRef.setInput('stock', createMockStockInfo());
fixture.detectChanges();
// The function should compute based on the breakpoint
const orientation = component.remiProductInfoOrientation();
expect(typeof orientation).toBe('string');
expect(['horizontal', 'vertical']).toContain(orientation);
});
});
describe('displayImpediment', () => {
it('should return truthy when item has impediment', () => {
const mockItem = createMockReturnItem({
impediment: {
comment: 'Test impediment',
attempts: 2,
},
});
fixture.componentRef.setInput('item', mockItem);
fixture.componentRef.setInput('stock', createMockStockInfo());
fixture.detectChanges();
expect(component.displayImpediment()).toBeTruthy();
});
it('should return truthy when item is descendant of enabled impediment', () => {
const mockItem = createMockReturnItem({
descendantOf: {
enabled: true,
},
});
fixture.componentRef.setInput('item', mockItem);
fixture.componentRef.setInput('stock', createMockStockInfo());
fixture.detectChanges();
expect(component.displayImpediment()).toBeTruthy();
});
it('should return falsy when item has no impediment and is not descendant of enabled impediment', () => {
const mockItem = createMockReturnItem({
impediment: undefined,
descendantOf: {
enabled: false,
},
});
fixture.componentRef.setInput('item', mockItem);
fixture.componentRef.setInput('stock', createMockStockInfo());
fixture.detectChanges();
expect(component.displayImpediment()).toBeFalsy();
});
it('should return falsy when item has no impediment and no descendantOf property', () => {
const mockItem = createMockReturnItem({
impediment: undefined,
});
fixture.componentRef.setInput('item', mockItem);
fixture.componentRef.setInput('stock', createMockStockInfo());
fixture.detectChanges();
expect(component.displayImpediment()).toBeFalsy();
});
});
describe('impediment', () => {
it('should return impediment comment when available', () => {
const mockItem = createMockReturnItem({
impediment: {
comment: 'Custom impediment message',
attempts: 3,
},
});
fixture.componentRef.setInput('item', mockItem);
fixture.componentRef.setInput('stock', createMockStockInfo());
fixture.detectChanges();
expect(component.impediment()).toBe('Custom impediment message (3)');
});
it('should return default "Restmenge" when no comment provided', () => {
const mockItem = createMockReturnItem({
impediment: {
attempts: 2,
},
});
fixture.componentRef.setInput('item', mockItem);
fixture.componentRef.setInput('stock', createMockStockInfo());
fixture.detectChanges();
expect(component.impediment()).toBe('Restmenge (2)');
});
it('should return only comment when no attempts provided', () => {
const mockItem = createMockReturnItem({
impediment: {
comment: 'Custom message',
},
});
fixture.componentRef.setInput('item', mockItem);
fixture.componentRef.setInput('stock', createMockStockInfo());
fixture.detectChanges();
expect(component.impediment()).toBe('Custom message');
});
it('should return default "Restmenge" when impediment is empty object', () => {
const mockItem = createMockReturnItem({
impediment: {},
});
fixture.componentRef.setInput('item', mockItem);
fixture.componentRef.setInput('stock', createMockStockInfo());
fixture.detectChanges();
expect(component.impediment()).toBe('Restmenge');
});
it('should return "Restmenge" when impediment is undefined', () => {
const mockItem = createMockReturnItem({
impediment: undefined,
});
fixture.componentRef.setInput('item', mockItem);
fixture.componentRef.setInput('stock', createMockStockInfo());
fixture.detectChanges();
expect(component.impediment()).toBe('Restmenge');
});
});
});

View File

@@ -21,12 +21,12 @@ import {
ProductShelfMetaInfoComponent,
ProductStockInfoComponent,
} from '@isa/remission/shared/product';
import { TextButtonComponent } from '@isa/ui/buttons';
import { ClientRowImports, ItemRowDataImports } from '@isa/ui/item-rows';
import { Breakpoint, breakpoint } from '@isa/ui/layout';
import { injectRemissionListType } from '../injects/inject-remission-list-type';
import { RemissionListItemSelectComponent } from './remission-list-item-select.component';
import { RemissionListItemActionsComponent } from './remission-list-item-actions.component';
import { LabelComponent, Labeltype } from '@isa/ui/label';
/**
* Component representing a single item in the remission list.
@@ -52,14 +52,20 @@ import { RemissionListItemActionsComponent } from './remission-list-item-actions
ProductInfoComponent,
ProductStockInfoComponent,
ProductShelfMetaInfoComponent,
TextButtonComponent,
ClientRowImports,
ItemRowDataImports,
RemissionListItemSelectComponent,
RemissionListItemActionsComponent,
LabelComponent,
],
})
export class RemissionListItemComponent {
/**
* Type of label to display for the item.
* Defaults to 'tag', can be changed to 'notice' or other types as needed.
*/
Labeltype = Labeltype;
/**
* Store for managing selected remission quantities.
* @private
@@ -100,9 +106,8 @@ export class RemissionListItemComponent {
* ModelSignal indicating whether remission items are currently being processed.
* Used to prevent multiple submissions or actions.
* @default false
*
*/
deleteRemissionListItemInProgress = model<boolean>();
inProgress = model<boolean>();
/**
* Optional product group value for display or filtering.
@@ -193,4 +198,25 @@ export class RemissionListItemComponent {
remainingQuantityInStock: this.remainingQuantityInStock(),
});
});
/**
* Computes whether to display the impediment for the item.
* Displays if the item is a descendant of an enabled impediment or if it has its own impediment.
*/
displayImpediment = computed(
() =>
(this.item() as ReturnItem)?.descendantOf?.enabled ||
this.item()?.impediment,
);
/**
* Computes the impediment comment and attempts for display.
* If no impediment comment is provided, defaults to 'Restmenge'.
* Appends the number of attempts if available.
*/
impediment = computed(() => {
const comment = this.item()?.impediment?.comment ?? 'Restmenge';
const attempts = this.item()?.impediment?.attempts;
return `${comment}${attempts ? ` (${attempts})` : ''}`;
});
}

View File

@@ -31,9 +31,7 @@
[stock]="getStockForItem(item)"
[stockFetching]="inStockFetching()"
[productGroupValue]="getProductGroupValueForItem(item)"
(deleteRemissionListItemInProgressChange)="
onDeleteRemissionListItem($event)
"
(inProgressChange)="onListItemActionInProgress($event)"
></remi-feature-remission-list-item>
} @placeholder {
<div class="h-[7.75rem] w-full flex items-center justify-center">
@@ -64,7 +62,7 @@
size="large"
color="brand"
[pending]="remitItemsInProgress()"
[disabled]="!hasSelectedItems() || deleteRemissionListItemInProgress()"
[disabled]="!hasSelectedItems() || listItemActionInProgress()"
>
</ui-stateful-button>
}

View File

@@ -150,12 +150,10 @@ export class RemissionListComponent {
});
/**
* Signal indicating whether remission items are currently being processed.
* Used to prevent multiple submissions or actions.
* @default false
*
* Signal indicating whether a remission list item deletion is in progress.
* Used to disable actions while deletion is happening.
*/
deleteRemissionListItemInProgress = signal(false);
listItemActionInProgress = signal(false);
/**
* Resource signal for fetching the remission list based on current filters.
@@ -330,8 +328,8 @@ export class RemissionListComponent {
*
* @param inProgress - Whether the deletion is currently in progress
*/
onDeleteRemissionListItem(inProgress: boolean) {
this.deleteRemissionListItemInProgress.set(inProgress);
onListItemActionInProgress(inProgress: boolean) {
this.listItemActionInProgress.set(inProgress);
if (!inProgress) {
this.reloadListAndReturnData();
}

View File

@@ -149,10 +149,20 @@ 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: manually-added items come first
// 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;
}
@@ -160,7 +170,7 @@ const sortResponseResult = (
return 1;
}
// Second priority: sort by created date (latest first)
// Third priority: sort by created date (latest first)
if (a.created && b.created) {
const dateA = parseISO(a.created);
const dateB = parseISO(b.created);