Merged PR 1851: Retoure // Mehrere Belege in der Retouren-Detailansicht anzeigen

Related work items: #5002, #5148
This commit is contained in:
Lorenz Hilpert
2025-06-06 15:34:33 +00:00
committed by Nino Righi
parent dd598d100c
commit 3eb6981e3a
72 changed files with 2238 additions and 1256 deletions

View File

@@ -27,6 +27,6 @@ If the user wants to generate something, use the following flow:
- wait for the user to finish the generator
- read the generator log file using the 'nx_read_generator_log' tool
- use the information provided in the log file to answer the user's question or continue with what they were doing
undefined

View File

@@ -30,6 +30,9 @@
}
],
"github.copilot.chat.codeGeneration.instructions": [
{
"file": ".vscode/llms/angular.txt"
},
{
"file": "docs/tech-stack.md"
},

View File

@@ -0,0 +1,49 @@
import { Component, Input } from '@angular/core';
import { ButtonComponent } from '@isa/ui/buttons';
import { ExpandableDirectives } from '@isa/ui/expandable';
import { argsToTemplate, type Meta, type StoryObj } from '@storybook/angular';
@Component({
selector: 'app-expandable-directives',
template: `
<div uiExpandable [(uiExpandable)]="isExpanded" class="border border-black">
<button uiButton uiExpandableTrigger>Toggle</button>
<div class="bg-red-200" *uiExpanded>Expanded Content</div>
<div class="bg-blue-200" *uiCollapsed>Collapsed Content</div>
</div>
`,
standalone: true,
imports: [ExpandableDirectives, ButtonComponent],
})
class ExpandableDirectivesComponent {
@Input()
isExpanded = false;
}
const meta: Meta<ExpandableDirectivesComponent> = {
title: 'ui/expandable/Expandable',
component: ExpandableDirectivesComponent,
argTypes: {
isExpanded: {
control: 'boolean',
description: 'Controls the expanded state of the section',
table: {
defaultValue: { summary: 'false' },
},
},
},
args: {
isExpanded: false,
},
render: (args) => ({
props: args,
template: `<app-expandable-directives ${argsToTemplate(args)}></app-expandable-directives>`,
}),
};
export default meta;
type Story = StoryObj<ExpandableDirectivesComponent>;
export const Default: Story = {
args: {},
};

View File

@@ -1,2 +1,3 @@
export * from './lib/errors';
export * from './lib/models';
export * from './lib/operators';

View File

@@ -0,0 +1 @@
export * from './take-until-aborted';

View File

@@ -0,0 +1,50 @@
import { Observable, fromEvent } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
/**
* Creates an Observable that emits when an AbortSignal is aborted.
*
* @param signal - The AbortSignal instance to listen to
* @returns An Observable that emits and completes when the signal is aborted
*/
export const fromAbortSignal = (signal: AbortSignal): Observable<void> => {
// If the signal is already aborted, return an Observable that immediately completes
if (signal.aborted) {
return new Observable<void>((subscriber) => {
subscriber.complete();
});
}
// Otherwise, create an Observable from the abort event
return new Observable<void>((subscriber) => {
const abortHandler = () => {
subscriber.next();
subscriber.complete();
};
// Listen for the 'abort' event
signal.addEventListener('abort', abortHandler);
// Clean up the event listener when the Observable is unsubscribed
return () => {
signal.removeEventListener('abort', abortHandler);
};
});
};
/**
* Operator that completes the source Observable when the provided AbortSignal is aborted.
* Similar to takeUntil, but works with AbortSignal instead of an Observable.
*
* @param signal - The AbortSignal instance that will trigger completion when aborted
* @returns An Observable that completes when the source completes or when the signal is aborted
*/
export const takeUntilAborted =
<T>(signal: AbortSignal) =>
(source: Observable<T>): Observable<T> => {
// Convert the AbortSignal to an Observable
const aborted$ = fromAbortSignal(signal);
// Use the standard takeUntil operator with our abort Observable
return source.pipe(takeUntil(aborted$));
};

View File

@@ -1,290 +0,0 @@
import { createComponentFactory, Spectator } from '@ngneat/spectator/jest';
import {
PrintDialogComponent,
PrinterDialogData,
} from './print-dialog.component';
import { ButtonComponent } from '@isa/ui/buttons';
import { ListboxDirective, ListboxItemDirective } from '@isa/ui/input-controls';
import { MockComponent, MockDirective } from 'ng-mocks';
import { Printer } from '../models';
import { DIALOG_DATA, DialogRef } from '@angular/cdk/dialog';
describe('PrintDialogComponent', () => {
let spectator: Spectator<PrintDialogComponent>;
let component: PrintDialogComponent;
// Mock printers for testing
const mockPrinters: Printer[] = [
{
key: 'printer1',
value: 'Printer 1',
selected: false,
enabled: true,
description: 'First test printer',
},
{
key: 'printer2',
value: 'Printer 2',
selected: true,
enabled: true,
description: 'Second test printer',
},
{
key: 'printer3',
value: 'Printer 3',
selected: false,
enabled: false,
description: 'Disabled test printer',
},
];
// Mock print function
const mockPrintFn = jest.fn().mockResolvedValue(undefined);
// Default dialog data
const defaultData: PrinterDialogData = {
printers: mockPrinters,
print: mockPrintFn,
};
// Mock DialogRef
const mockDialogRef = {
close: jest.fn(),
};
const createComponent = createComponentFactory({
component: PrintDialogComponent,
declarations: [
MockComponent(ButtonComponent),
MockDirective(ListboxDirective),
MockDirective(ListboxItemDirective),
],
providers: [
{ provide: DialogRef, useValue: mockDialogRef },
{ provide: DIALOG_DATA, useValue: defaultData },
],
detectChanges: false,
});
beforeEach(() => {
// Reset mocks
mockPrintFn.mockClear();
mockDialogRef.close.mockClear();
// Create component without providing data prop since we provide it via DIALOG_DATA
spectator = createComponent();
component = spectator.component;
spectator.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should initialize with the selected printer', () => {
// Assert
expect(component.printer()).toEqual(mockPrinters[1]); // The one with selected: true
});
it('should compute selected array correctly with a printer', () => {
// Arrange
const selectedPrinter = mockPrinters[0];
component.printer.set(selectedPrinter);
// Act
const result = component.selected();
// Assert
expect(result).toEqual([selectedPrinter]);
});
it('should compute selected array as empty when no printer is selected', () => {
// Arrange
component.printer.set(undefined);
// Act
const result = component.selected();
// Assert
expect(result).toEqual([]);
});
it('should compute canPrint as true when printer is selected and not printing', () => {
// Arrange
component.printer.set(mockPrinters[0]);
component.printing.set(false);
// Act
const result = component.canPrint();
// Assert
expect(result).toBe(true);
});
it('should compute canPrint as false when no printer is selected', () => {
// Arrange
component.printer.set(undefined);
component.printing.set(false);
// Act
const result = component.canPrint();
// Assert
expect(result).toBe(false);
});
it('should compute canPrint as false when printing is in progress', () => {
// Arrange
component.printer.set(mockPrinters[0]);
component.printing.set(true);
// Act
const result = component.canPrint();
// Assert
expect(result).toBe(false);
});
it('should compare printers by key', () => {
// Arrange
const printer1 = { ...mockPrinters[0] };
const printer2 = { ...mockPrinters[0] }; // Same key as printer1
const printer3 = { ...mockPrinters[1] }; // Different key
// Act & Assert
expect(component.compareWith(printer1, printer2)).toBe(true);
expect(component.compareWith(printer1, printer3)).toBe(false);
});
it('should select a printer and clear error', () => {
// Arrange
const initialError = new Error('Test error');
component.error.set(initialError);
const printer = mockPrinters[0];
// Act
component.select(printer);
// Assert
expect(component.printer()).toEqual(printer);
expect(component.error()).toBeUndefined();
});
it('should not print when canPrint is false', async () => {
// Arrange
component.printer.set(undefined); // Makes canPrint() false
const closeSpy = jest.spyOn(component, 'close');
// Act
await component.print();
// Assert
expect(mockPrintFn).not.toHaveBeenCalled();
expect(closeSpy).not.toHaveBeenCalled();
expect(component.printing()).toBe(false);
});
it('should print and close dialog when print succeeds', async () => {
// Arrange
const selectedPrinter = mockPrinters[0];
component.printer.set(selectedPrinter);
const closeSpy = jest.spyOn(component, 'close');
// Act
await component.print();
// Assert
// The printing flag stays true when success happens and dialog is closed
expect(component.printing()).toBe(true);
expect(mockPrintFn).toHaveBeenCalledWith(selectedPrinter);
expect(closeSpy).toHaveBeenCalledWith({ printer: selectedPrinter });
});
it('should handle print errors', async () => {
// Arrange
const selectedPrinter = mockPrinters[0];
component.printer.set(selectedPrinter);
const testError = new Error('Print failed');
mockPrintFn.mockRejectedValueOnce(testError);
const closeSpy = jest.spyOn(component, 'close');
// Act
await component.print();
// Assert
expect(component.printing()).toBe(false); // Reset to false after error
expect(mockPrintFn).toHaveBeenCalledWith(selectedPrinter);
expect(closeSpy).not.toHaveBeenCalled();
expect(component.error()).toBe(testError);
});
it('should format Error objects correctly', () => {
// Arrange
const errorMessage = 'Test error message';
const testError = new Error(errorMessage);
// Act
const result = component.formatError(testError);
// Assert
expect(result).toBe(errorMessage);
});
it('should format string errors correctly', () => {
// Arrange
const errorMessage = 'Test error message';
// Act
const result = component.formatError(errorMessage);
// Assert
expect(result).toBe(errorMessage);
});
it('should format unknown errors correctly', () => {
// Arrange
const unknownError = { something: 'wrong' };
// Act
const result = component.formatError(unknownError);
// Assert
expect(result).toBe('Unbekannter Fehler');
});
it('should show error message in template when error exists', () => {
// Arrange
const errorMessage = 'Display this error';
component.error.set(errorMessage);
spectator.detectChanges();
// Act
const errorElement = spectator.query('.text-isa-accent-red');
// Assert
expect(errorElement).toHaveText(errorMessage);
});
it('should display printers in the listbox', () => {
// Arrange & Act
spectator.detectChanges();
const listboxItems = spectator.queryAll('button[uiListboxItem]');
// Assert
expect(listboxItems.length).toBe(mockPrinters.length);
expect(listboxItems[0]).toHaveText(mockPrinters[0].value);
expect(listboxItems[1]).toHaveText(mockPrinters[1].value);
expect(listboxItems[2]).toHaveText(mockPrinters[2].value);
});
it('should call close with undefined printer when cancel button is clicked', () => {
// Arrange
const closeSpy = jest.spyOn(component, 'close');
// Act
spectator.click('button[color="secondary"]');
// Assert
expect(closeSpy).toHaveBeenCalledWith({ printer: undefined });
});
});

View File

@@ -11,6 +11,7 @@
* - Schemas: Validation schemas for ensuring data integrity
* - Return Process: Question flows and validation for return processing
* - Error handling: Specialized error types for OMS operations
* - Operators: Custom RxJS operators for OMS-specific use cases
*/
export * from './lib/errors';
@@ -19,4 +20,5 @@ export * from './lib/models';
export * from './lib/helpers/return-process';
export * from './lib/schemas';
export * from './lib/services';
export * from './lib/operators';
export * from './lib/stores';

View File

@@ -9,8 +9,12 @@ import {
describe('CreateReturnProcessError', () => {
const params = {
processId: 123,
receipt: { id: 321 } as Receipt,
items: [] as ReceiptItem[],
returns: [
{
receipt: { id: 321 } as Receipt,
items: [] as ReceiptItem[],
},
],
};
it('should create an error instance with NO_RETURNABLE_ITEMS reason', () => {

View File

@@ -78,8 +78,7 @@ export class CreateReturnProcessError extends DataAccessError<'CREATE_RETURN_PRO
public readonly reason: CreateReturnProcessErrorReason,
public readonly params: {
processId: number;
receipt: Receipt;
items: ReceiptItem[];
returns: { receipt: Receipt; items: ReceiptItem[] }[];
},
) {
super('CREATE_RETURN_PROCESS', CreateReturnProcessErrorMessages[reason]);

View File

@@ -0,0 +1,73 @@
import { canReturnReceiptItem } from './can-return-receipt-item.helper';
import { ReceiptItem } from '../../models/receipt-item';
import { Product } from '../../models/product';
import { Quantity } from '../../models/quantity';
describe('canReturnReceiptItem', () => {
const product: Product = {
name: 'Test Product',
contributors: 'Author',
catalogProductNumber: '123',
ean: '1234567890123',
format: 'Hardcover',
formatDetail: 'Detail',
volume: '1',
manufacturer: 'Test Publisher',
};
const quantity: Quantity = { quantity: 1 };
const baseItem: ReceiptItem = {
id: 1,
product,
quantity,
receiptNumber: 'R-001',
actions: [],
};
it('should return false if actions property is missing', () => {
const item = { ...baseItem };
delete (item as { actions?: unknown }).actions;
expect(canReturnReceiptItem(item as ReceiptItem)).toBe(false);
});
it('should return false if canReturn action is missing', () => {
const item = { ...baseItem, actions: [{ key: 'other', value: 'true' }] };
expect(canReturnReceiptItem(item)).toBe(false);
});
it('should return false if canReturn action value is falsy', () => {
const item = { ...baseItem, actions: [{ key: 'canReturn', value: '' }] };
// coerceBooleanProperty('') returns true, so this should be true
expect(canReturnReceiptItem(item)).toBe(true);
const itemZero = {
...baseItem,
actions: [{ key: 'canReturn', value: '0' }],
};
// coerceBooleanProperty('0') returns true (string '0' is truthy)
expect(canReturnReceiptItem(itemZero)).toBe(true);
});
it('should return true if canReturn action value is truthy', () => {
const item = {
...baseItem,
actions: [{ key: 'canReturn', value: 'true' }],
};
expect(canReturnReceiptItem(item)).toBe(true);
});
it('should coerce canReturn action value to boolean', () => {
const item = { ...baseItem, actions: [{ key: 'canReturn', value: '1' }] };
expect(canReturnReceiptItem(item)).toBe(true);
const itemFalse = {
...baseItem,
actions: [{ key: 'canReturn', value: '' }],
};
expect(canReturnReceiptItem(itemFalse)).toBe(true);
const itemStringZero = {
...baseItem,
actions: [{ key: 'canReturn', value: '0' }],
};
// coerceBooleanProperty('0') returns true
expect(canReturnReceiptItem(itemStringZero)).toBe(true);
});
});

View File

@@ -0,0 +1,25 @@
import { coerceBooleanProperty } from '@angular/cdk/coercion';
import { ReceiptItem } from '../../models';
import { getReceiptItemAction } from './get-receipt-item-action.helper';
/**
* Determines if a receipt item can be returned.
*
* @param receiptItem - The receipt item to check for return eligibility. Must have an 'actions' property (array of action objects).
* @returns {boolean} True if the item has a 'canReturn' action with a truthy value (coerced to boolean), otherwise false.
*
* @remarks
* - Returns false if the 'actions' property is missing or not an array.
* - Returns false if the 'canReturn' action is not present in the actions array.
* - Uses Angular's coerceBooleanProperty to interpret the action value.
*/
export function canReturnReceiptItem(receiptItem: ReceiptItem): boolean {
if (!receiptItem.actions) {
return false;
}
const canReturnAction = getReceiptItemAction(receiptItem, 'canReturn');
if (!canReturnAction) {
return false;
}
return coerceBooleanProperty(canReturnAction.value);
}

View File

@@ -0,0 +1,55 @@
import { getReceiptItemAction } from './get-receipt-item-action.helper';
import { ReceiptItem } from '../../models/receipt-item';
import { Product } from '../../models/product';
import { Quantity } from '../../models/quantity';
describe('getReceiptItemAction', () => {
const product: Product = {
name: 'Test Product',
contributors: 'Author',
catalogProductNumber: '123',
ean: '1234567890123',
format: 'Hardcover',
formatDetail: 'Detail',
volume: '1',
manufacturer: 'Test Publisher',
};
const quantity: Quantity = { quantity: 1 };
const baseItem: ReceiptItem = {
id: 1,
product,
quantity,
receiptNumber: 'R-001',
actions: [],
};
it('should return undefined if actions property is missing', () => {
const item = { ...baseItem };
delete (item as { actions?: unknown }).actions;
expect(
getReceiptItemAction(item as ReceiptItem, 'canReturn'),
).toBeUndefined();
});
it('should return undefined if no action with the given key exists', () => {
const item = { ...baseItem, actions: [{ key: 'other', value: 'true' }] };
expect(getReceiptItemAction(item, 'canReturn')).toBeUndefined();
});
it('should return the action object if the key exists', () => {
const action = { key: 'canReturn', value: 'true' };
const item = {
...baseItem,
actions: [action, { key: 'other', value: 'false' }],
};
expect(getReceiptItemAction(item, 'canReturn')).toBe(action);
});
it('should return the first matching action if multiple exist', () => {
const action1 = { key: 'canReturn', value: 'true' };
const action2 = { key: 'canReturn', value: 'false' };
const item = { ...baseItem, actions: [action1, action2] };
expect(getReceiptItemAction(item, 'canReturn')).toBe(action1);
});
});

View File

@@ -0,0 +1,24 @@
import { KeyValueDTOOfStringAndString } from '@generated/swagger/oms-api';
import { ReceiptItem } from '../../models';
/**
* Retrieves a specific action object from a receipt item's actions array by key.
*
* @param receiptItem - The receipt item containing the actions array.
* @param actionKey - The key of the action to retrieve.
* @returns The action object with the specified key, or undefined if not found or if actions are missing.
*
* @remarks
* - Returns undefined if the 'actions' property is missing or not an array.
* - Returns undefined if no action with the given key exists.
*/
export function getReceiptItemAction(
receiptItem: ReceiptItem,
actionKey: string,
): KeyValueDTOOfStringAndString | undefined {
if ('actions' in receiptItem === false) {
return undefined;
}
return receiptItem.actions?.find((a) => a.key === actionKey);
}

View File

@@ -0,0 +1,55 @@
import { getReceiptItemProductCategory } from './get-receipt-item-product-category.helper';
import { ReceiptItem } from '../../models/receipt-item';
import { Product } from '../../models/product';
import { Quantity } from '../../models/quantity';
import { ProductCategory } from '../../questions/constants';
describe('getReceiptItemProductCategory', () => {
const product: Product = {
name: 'Test Product',
contributors: 'Author',
catalogProductNumber: '123',
ean: '1234567890123',
format: 'Hardcover',
formatDetail: 'Detail',
volume: '1',
manufacturer: 'Test Publisher',
};
const quantity: Quantity = { quantity: 1 };
const baseItem: ReceiptItem = {
id: 1,
product,
quantity,
receiptNumber: 'R-001',
features: {},
};
it('should return ProductCategory.Unknown if features property is missing', () => {
const item = { ...baseItem };
delete (item as { features?: unknown }).features;
expect(getReceiptItemProductCategory(item as ReceiptItem)).toBe(
ProductCategory.Unknown,
);
});
it('should return ProductCategory.Unknown if category is not set', () => {
const item = { ...baseItem, features: {} };
expect(getReceiptItemProductCategory(item)).toBe(ProductCategory.Unknown);
});
it('should return the category if set in features', () => {
const item = {
...baseItem,
features: { category: ProductCategory.BookCalendar },
};
expect(getReceiptItemProductCategory(item)).toBe(
ProductCategory.BookCalendar,
);
});
it('should return ProductCategory.Unknown if category is set to a falsy value', () => {
const item = { ...baseItem, features: { category: '' } };
expect(getReceiptItemProductCategory(item)).toBe(ProductCategory.Unknown);
});
});

View File

@@ -0,0 +1,23 @@
import { ReceiptItem } from '../../models';
import { ProductCategory } from '../../questions';
/**
* Retrieves the product category for a given receipt item.
*
* @param item - The receipt item to extract the product category from.
* @returns The product category if present in the item's features; otherwise, ProductCategory.Unknown.
*
* @remarks
* - Returns ProductCategory.Unknown if the 'features' property is missing or does not contain a 'category'.
* - Casts the 'category' feature to ProductCategory if present.
*/
export function getReceiptItemProductCategory(
item: ReceiptItem,
): ProductCategory {
if (!item.features) {
return ProductCategory.Unknown;
}
return (
(item.features['category'] as ProductCategory) || ProductCategory.Unknown
);
}

View File

@@ -1,10 +1,14 @@
export * from './active-return-process-questions.helper';
export * from './all-return-process-questions-answered.helper';
export * from './calculate-longest-question-depth.helper';
export * from './get-next-question.helper';
export * from './get-return-info.helper';
export * from './can-return-receipt-item.helper';
export * from './eligible-for-return.helper';
export * from './get-next-question.helper';
export * from './get-receipt-item-action.helper';
export * from './get-receipt-item-product-category.helper';
export * from './get-return-info.helper';
export * from './get-return-process-questions.helper';
export * from './return-receipt-values-mapping.helper';
export * from './return-details-mapping.helper';
export * from './get-tolino-questions.helper';
export * from './receipt-item-has-category.helper';
export * from './return-details-mapping.helper';
export * from './return-receipt-values-mapping.helper';

View File

@@ -0,0 +1,62 @@
import { receiptItemHasCategory } from './receipt-item-has-category.helper';
import { ReceiptItem } from '../../models/receipt-item';
import { Product } from '../../models/product';
import { Quantity } from '../../models/quantity';
import { ProductCategory } from '../../questions/constants';
describe('receiptItemHasCategory', () => {
const product: Product = {
name: 'Test Product',
contributors: 'Author',
catalogProductNumber: '123',
ean: '1234567890123',
format: 'Hardcover',
formatDetail: 'Detail',
volume: '1',
manufacturer: 'Test Publisher',
};
const quantity: Quantity = { quantity: 1 };
const baseItem: ReceiptItem = {
id: 1,
product,
quantity,
receiptNumber: 'R-001',
features: {},
};
it('should return false if features property is missing', () => {
const item = { ...baseItem };
delete (item as { features?: unknown }).features;
expect(
receiptItemHasCategory(item as ReceiptItem, ProductCategory.BookCalendar),
).toBe(false);
});
it('should return false if category does not match', () => {
const item = {
...baseItem,
features: { category: ProductCategory.Tolino },
};
expect(receiptItemHasCategory(item, ProductCategory.BookCalendar)).toBe(
false,
);
});
it('should return true if category matches', () => {
const item = {
...baseItem,
features: { category: ProductCategory.BookCalendar },
};
expect(receiptItemHasCategory(item, ProductCategory.BookCalendar)).toBe(
true,
);
});
it('should return false if category is missing in features', () => {
const item = { ...baseItem, features: {} };
expect(receiptItemHasCategory(item, ProductCategory.BookCalendar)).toBe(
false,
);
});
});

View File

@@ -0,0 +1,24 @@
import { ReceiptItem } from '../../models';
import { ProductCategory } from '../../questions';
/**
* Checks if a receipt item has the specified product category.
*
* @param item - The receipt item to check.
* @param category - The product category to compare against.
* @returns True if the item's features contain the specified category, otherwise false.
*
* @remarks
* - Returns false if the 'features' property is missing.
* - Performs a strict equality check between the item's category and the provided category.
*/
export function receiptItemHasCategory(
item: ReceiptItem,
category: ProductCategory,
) {
if (!item.features) {
return false;
}
const itemCategory = item.features['category'];
return itemCategory === category;
}

View File

@@ -5,6 +5,7 @@ export * from './eligible-for-return';
export * from './gender';
export * from './product';
export * from './quantity';
export * from './receipt-item-list-item';
export * from './receipt-item-task-list-item';
export * from './receipt-item';
export * from './receipt-list-item';
@@ -17,5 +18,6 @@ export * from './return-process-question-type';
export * from './return-process-question';
export * from './return-process-status';
export * from './return-process';
export * from './shipping-address-2';
export * from './shipping-type';
export * from './task-action-type';

View File

@@ -0,0 +1,10 @@
import { ReceiptItemListItemDTO } from '@generated/swagger/oms-api';
import { Product } from './product';
import { Quantity } from './quantity';
export interface ReceiptItemListItem extends ReceiptItemListItemDTO {
id: number;
product: Product;
quantity: Quantity;
receiptItemNumber: string;
}

View File

@@ -6,4 +6,5 @@ export interface ReceiptItem extends ReceiptItemDTO {
id: number;
product: Product;
quantity: Quantity;
receiptNumber: string;
}

View File

@@ -2,4 +2,5 @@ import { ReceiptListItemDTO } from '@generated/swagger/oms-api';
export interface ReceiptListItem extends ReceiptListItemDTO {
id: number;
receiptNumber: string;
}

View File

@@ -2,9 +2,11 @@ import { ReceiptDTO } from '@generated/swagger/oms-api';
import { EntityContainer } from '@isa/common/data-access';
import { ReceiptItem } from './receipt-item';
import { Buyer } from './buyer';
import { ShippingAddress2 } from './shipping-address-2';
export interface Receipt extends ReceiptDTO {
id: number;
items: EntityContainer<ReceiptItem>[];
buyer: Buyer;
shipping?: ShippingAddress2;
}

View File

@@ -0,0 +1,3 @@
import { ShippingAddressDTO2 } from '@generated/swagger/oms-api';
export type ShippingAddress2 = ShippingAddressDTO2;

View File

@@ -0,0 +1 @@
export * from './take-until-aborted';

View File

@@ -0,0 +1,50 @@
import { Observable } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
/**
* Creates an Observable that emits when an AbortSignal is aborted.
*
* @param signal - The AbortSignal instance to listen to
* @returns An Observable that emits and completes when the signal is aborted
*/
export const fromAbortSignal = (signal: AbortSignal): Observable<void> => {
// If the signal is already aborted, return an Observable that immediately completes
if (signal.aborted) {
return new Observable<void>((subscriber) => {
subscriber.complete();
});
}
// Otherwise, create an Observable from the abort event
return new Observable<void>((subscriber) => {
const abortHandler = () => {
subscriber.next();
subscriber.complete();
};
// Listen for the 'abort' event
signal.addEventListener('abort', abortHandler);
// Clean up the event listener when the Observable is unsubscribed
return () => {
signal.removeEventListener('abort', abortHandler);
};
});
};
/**
* Operator that completes the source Observable when the provided AbortSignal is aborted.
* Similar to takeUntil, but works with AbortSignal instead of an Observable.
*
* @param signal - The AbortSignal instance that will trigger completion when aborted
* @returns An Observable that completes when the source completes or when the signal is aborted
*/
export const takeUntilAborted =
<T>(signal: AbortSignal) =>
(source: Observable<T>): Observable<T> => {
// Convert the AbortSignal to an Observable
const aborted$ = fromAbortSignal(signal);
// Use the standard takeUntil operator with our abort Observable
return source.pipe(takeUntil(aborted$));
};

View File

@@ -5,6 +5,7 @@ import { ReturnProcessQuestionKey } from '../models';
* Constants for product categories used in the return process.
*/
export const ProductCategory = {
Unknown: 'unknown',
BookCalendar: 'Buch/Kalender',
TonDatentraeger: 'Ton-/Datenträger',
SpielwarenPuzzle: 'Spielwaren/Puzzle',

View File

@@ -26,4 +26,5 @@ export const CategoryQuestions: Record<
[ProductCategory.SonstigesNonbook]: nonbookQuestions,
[ProductCategory.ElektronischeGeraete]: elektronischeGeraeteQuestions,
[ProductCategory.Tolino]: tolinoQuestions,
[ProductCategory.Unknown]: [],
};

View File

@@ -12,6 +12,7 @@ import {
returnReceiptValuesMapping,
} from '../helpers/return-process';
import { isReturnProcessTypeGuard } from '../guards';
import { takeUntilAborted } from '@isa/common/data-access';
/**
* Service for determining if a return process can proceed based on
@@ -35,14 +36,20 @@ export class ReturnCanReturnService {
* @param returnProcess - The return process object to evaluate.
* @returns A promise resolving to a CanReturn result or undefined if the process should continue.
*/
async canReturn(returnProcess: ReturnProcess): Promise<CanReturn | undefined>;
async canReturn(
returnProcess: ReturnProcess,
abortSignal?: AbortSignal,
): Promise<CanReturn | undefined>;
/**
* Determines if a return can proceed based on mapped receipt values.
*
* @param returnValues - The mapped return receipt values.
* @returns A promise resolving to a CanReturn result.
*/
async canReturn(returnValues: ReturnReceiptValues): Promise<CanReturn>;
async canReturn(
returnValues: ReturnReceiptValues,
abortSignal?: AbortSignal,
): Promise<CanReturn>;
/**
* Determines if a return can proceed, accepting either a ReturnProcess or ReturnReceiptValues.
@@ -53,6 +60,7 @@ export class ReturnCanReturnService {
*/
async canReturn(
input: ReturnProcess | ReturnReceiptValues,
abortSignal?: AbortSignal,
): Promise<CanReturn | undefined> {
let data: ReturnReceiptValues | undefined = undefined;
@@ -66,14 +74,20 @@ export class ReturnCanReturnService {
return undefined; // Prozess soll weitergehen, daher kein Error
}
let req$ = this.#receiptService.ReceiptCanReturn(
data as ReturnReceiptValuesDTO,
);
if (abortSignal) {
req$ = req$.pipe(takeUntilAborted(abortSignal));
}
try {
return await firstValueFrom(
this.#receiptService
.ReceiptCanReturn(data as ReturnReceiptValuesDTO)
.pipe(
debounceTime(50),
map((res) => res as CanReturn),
),
req$.pipe(
debounceTime(50),
map((res) => res as CanReturn),
),
);
} catch (error) {
throw new Error(`ReceiptCanReturn failed: ${String(error)}`);

View File

@@ -1,7 +1,10 @@
import { createServiceFactory, SpectatorService } from '@ngneat/spectator/jest';
import { ReturnDetailsService } from './return-details.service';
import { ReceiptService } from '@generated/swagger/oms-api';
import { of } from 'rxjs';
import {
ReceiptService,
ResponseArgsOfReceiptDTO,
} from '@generated/swagger/oms-api';
import { of, NEVER } from 'rxjs';
import { FetchReturnDetails } from '../schemas';
import { Receipt } from '../models';
@@ -16,7 +19,7 @@ describe('ReturnDetailsService', () => {
spectator = createService();
});
it('should fetch return details successfully', (done) => {
it('should fetch return details successfully', async () => {
// Arrange
const mockParams: FetchReturnDetails = { receiptId: 123 };
const mockResponse: any = { result: { id: 123, data: 'mockData' } };
@@ -24,14 +27,12 @@ describe('ReturnDetailsService', () => {
receiptService.ReceiptGetReceipt.mockReturnValue(of(mockResponse));
// Act
spectator.service.fetchReturnDetails(mockParams).subscribe((result) => {
// Assert
expect(result).toEqual(mockResponse.result as Receipt);
done();
});
const result = await spectator.service.fetchReturnDetails(mockParams);
expect(result).toEqual(mockResponse.result as Receipt);
});
it('should throw an error if API response contains an error', (done) => {
it('should throw an error if API response contains an error', async () => {
// Arrange
const mockParams: FetchReturnDetails = { receiptId: 123 };
const mockResponse: any = { error: 'API error' };
@@ -39,26 +40,72 @@ describe('ReturnDetailsService', () => {
receiptService.ReceiptGetReceipt.mockReturnValue(of(mockResponse));
// Act
spectator.service.fetchReturnDetails(mockParams).subscribe({
error: (err) => {
// Assert
expect(err.message).toBe('Failed to fetch return details');
done();
},
});
try {
await spectator.service.fetchReturnDetails(mockParams);
} catch (err) {
expect((err as Error).message).toBe('Failed to fetch return details');
}
});
it('should throw an error if parameters are invalid', (done) => {
/**
* Should return undefined or throw if API returns an empty object.
*/
it('should handle empty API response gracefully', async () => {
// Arrange
const invalidParams: any = { receiptId: null };
const mockParams: FetchReturnDetails = { receiptId: 123 };
const mockResponse: ResponseArgsOfReceiptDTO = {
error: false,
result: undefined,
};
const receiptService = spectator.inject(ReceiptService);
receiptService.ReceiptGetReceipt.mockReturnValue(of(mockResponse));
// Act & Assert
await expect(
spectator.service.fetchReturnDetails(mockParams),
).rejects.toThrow('Failed to fetch return details');
});
/**
* Should call ReceiptGetReceipt with correct parameters.
*/
it('should call ReceiptGetReceipt with correct params', async () => {
// Arrange
const mockParams: FetchReturnDetails = { receiptId: 456 };
const mockResponse: ResponseArgsOfReceiptDTO = {
error: false,
result: { id: 456, data: 'mockData' } as unknown as Receipt,
};
const receiptService = spectator.inject(ReceiptService);
const spy = jest.spyOn(receiptService, 'ReceiptGetReceipt');
spy.mockReturnValue(of(mockResponse));
// Act
spectator.service.fetchReturnDetails(invalidParams).subscribe({
error: (err) => {
// Assert
expect(err).toBeTruthy();
done();
},
});
await spectator.service.fetchReturnDetails(mockParams);
// Assert
expect(spy).toHaveBeenCalledWith({ ...mockParams, eagerLoading: 2 });
});
/**
* Should handle observable that never emits (simulate hanging request).
*/
it('should handle observable that never emits', async () => {
// Arrange
const mockParams: FetchReturnDetails = { receiptId: 789 };
const receiptService = spectator.inject(ReceiptService);
// Simulate never emitting observable
receiptService.ReceiptGetReceipt.mockReturnValue(NEVER);
// Act & Assert
// Should timeout or hang, so we expect the promise not to resolve
// For test safety, wrap in a Promise.race with a timeout
const fetchPromise = spectator.service.fetchReturnDetails(mockParams);
const timeout = new Promise((_, reject) =>
setTimeout(() => reject(new Error('Timeout')), 100),
);
await expect(Promise.race([fetchPromise, timeout])).rejects.toThrow(
'Timeout',
);
});
});

View File

@@ -4,27 +4,43 @@ import {
FetchReturnDetailsSchema,
ReturnReceiptValues,
} from '../schemas';
import { map, Observable, throwError } from 'rxjs';
import { firstValueFrom } from 'rxjs';
import { ReceiptService } from '@generated/swagger/oms-api';
import { Receipt, ReceiptItem } from '../models';
import { CategoryQuestions } from '../questions';
import { CanReturn, Receipt, ReceiptItem, ReceiptListItem } from '../models';
import { CategoryQuestions, ProductCategory } from '../questions';
import { KeyValue } from '@angular/common';
import { ReturnCanReturnService } from './return-can-return.service';
import { takeUntilAborted } from '@isa/common/data-access';
import { z } from 'zod';
/**
* Service responsible for managing receipt return details and operations.
*
* This service provides functionality to:
* - Check if items are eligible for return
* - Fetch receipt details by receipt ID
* - Query receipts by customer email
* - Get available product categories for returns
*/
@Injectable({ providedIn: 'root' })
export class ReturnDetailsService {
#receiptService = inject(ReceiptService);
#returnCanReturnService = inject(ReturnCanReturnService);
/**
* Determines if a specific receipt item can be returned for a given category.
*
* @param params - The parameters for the return check.
* @param params.item - The receipt item to check.
* @param params.category - The product category to check against.
* @returns A promise resolving to the result of the canReturn check.
* @param abortSignal - Optional AbortSignal to cancel the request.
* @returns A promise resolving to the result of the canReturn check, containing
* eligibility status and any relevant constraints or messages.
* @throws Will throw an error if the return check fails or is aborted.
*/
async canReturn({ item, category }: { item: ReceiptItem; category: string }) {
async canReturn(
{ item, category }: { item: ReceiptItem; category: ProductCategory },
abortSignal?: AbortSignal,
): Promise<CanReturn> {
const returnReceiptValues: ReturnReceiptValues = {
quantity: item.quantity.quantity,
receiptItem: {
@@ -33,44 +49,107 @@ export class ReturnDetailsService {
category,
};
return await this.#returnCanReturnService.canReturn(returnReceiptValues);
return this.#returnCanReturnService.canReturn(
returnReceiptValues,
abortSignal,
);
}
/**
* Gets all available product categories that have defined question sets.
*
* @returns {KeyValue<string, string>[]} Array of key-value pairs representing available categories.
* This method filters out the "Unknown" category and returns all other
* categories defined in the CategoryQuestions object.
*
* @returns {KeyValue<ProductCategory, string>[]} Array of key-value pairs representing
* available categories, where the key is the ProductCategory enum value
* and the value is the string representation.
*/
availableCategories(): KeyValue<string, string>[] {
return Object.keys(CategoryQuestions).map((key) => {
return { key, value: key };
availableCategories(): KeyValue<ProductCategory, string>[] {
const categories = Object.keys(CategoryQuestions).map((key) => {
return { key: key as ProductCategory, value: key };
});
}
return categories.filter(
(category) => category.key !== ProductCategory.Unknown,
);
}
/**
* Fetches the return details for a specific receipt.
*
* This method retrieves detailed information about a receipt using its ID.
* The data is validated using Zod schema validation before making the API call.
*
* @param params - The parameters required to fetch the return details, including the receipt ID.
* @returns An observable that emits the fetched receipt details.
* @param abortSignal - Optional AbortSignal that can be used to cancel the request.
* @returns A promise that resolves to the fetched receipt details.
* @throws Will throw an error if the parameters are invalid or if the API response contains an error.
*/
fetchReturnDetails(params: FetchReturnDetails): Observable<Receipt> {
try {
const parsed = FetchReturnDetailsSchema.parse(params);
async fetchReturnDetails(
params: FetchReturnDetails,
abortSignal?: AbortSignal,
): Promise<Receipt> {
const parsed = FetchReturnDetailsSchema.parse(params);
return this.#receiptService
.ReceiptGetReceipt({ receiptId: parsed.receiptId, eagerLoading: 2 })
.pipe(
map((res) => {
if (res.error || !res.result) {
throw new Error(res.message || 'Failed to fetch return details');
}
let req$ = this.#receiptService.ReceiptGetReceipt({
receiptId: parsed.receiptId,
eagerLoading: 2,
});
return res.result as Receipt;
}),
);
} catch (error) {
return throwError(() => error);
if (abortSignal) {
req$ = req$.pipe(takeUntilAborted(abortSignal));
}
const res = await firstValueFrom(req$);
if (res.error || !res.result) {
throw new Error(res.message || 'Failed to fetch return details');
}
return res.result as Receipt;
}
/**
* Schema definition for email-based receipt query parameters.
* Validates that the email parameter is a properly formatted email address.
*/
static FetchReceiptsEmailParamsSchema = z.object({
email: z.string().email(),
});
/**
* Fetches receipts associated with a specific email address.
*
* This method queries the receipt service for receipts that match the provided email address.
* The email is validated using Zod schema validation before making the API call.
*
* @param params - The parameters containing the email to search for.
* @param params.email - Email address to search for in receipt records.
* @param abortSignal - Optional AbortSignal that can be used to cancel the request.
* @returns A promise that resolves to an array of receipt list items matching the email.
* @throws Will throw an error if the email is invalid or if the API response contains an error.
*/
async fetchReceiptsByEmail(
params: z.infer<typeof ReturnDetailsService.FetchReceiptsEmailParamsSchema>,
abortSignal?: AbortSignal,
): Promise<ReceiptListItem[]> {
const { email } =
ReturnDetailsService.FetchReceiptsEmailParamsSchema.parse(params);
let req$ = this.#receiptService.ReceiptQueryReceipt({
queryToken: {
input: { qs: email },
filter: { receipt_type: '1;128;1024' },
},
});
if (abortSignal) {
req$ = req$.pipe(takeUntilAborted(abortSignal));
}
const res = await firstValueFrom(req$);
if (res.error || !res.result) {
throw new Error(res.message || 'Failed to fetch return items by email');
}
return res.result as ReceiptListItem[];
}
}

View File

@@ -1,169 +1,57 @@
import { createServiceFactory } from '@ngneat/spectator/jest';
import { ReturnDetailsStore } from './return-details.store';
import { receiptConfig, ReturnDetailsStore } from './return-details.store';
import { ReturnDetailsService } from '../services';
import { SessionStorageProvider } from '@isa/core/storage';
import { LoggingService } from '@isa/core/logging';
import { patchState } from '@ngrx/signals';
import { AsyncResultStatus } from '@isa/common/data-access';
import { addEntity } from '@ngrx/signals/entities';
import { Receipt } from '../models';
import { of, throwError } from 'rxjs';
import { unprotected } from '@ngrx/signals/testing';
import { Receipt, ReceiptItem } from '../models';
import { addEntities } from '@ngrx/signals/entities';
describe('ReturnDetailsStore', () => {
const createService = createServiceFactory({
service: ReturnDetailsStore,
mocks: [ReturnDetailsService],
mocks: [ReturnDetailsService, SessionStorageProvider, LoggingService],
});
describe('Initialization', () => {
it('should create an instance of ReturnDetailsStore', () => {
const spectator = createService();
expect(spectator.service).toBeTruthy();
});
it('should create an instance of ReturnDetailsStore', () => {
const spectator = createService();
expect(spectator.service).toBeTruthy();
});
describe('Entity Management', () => {
describe('beforeFetch', () => {
it('should create a new entity and set status to Pending if it does not exist', () => {
const spectator = createService();
const receiptId = 123;
spectator.service.beforeFetch(receiptId);
expect(spectator.service.entityMap()[123]).toEqual({
id: receiptId,
data: undefined,
status: AsyncResultStatus.Pending,
});
});
it('should update the existing entity status to Pending', () => {
const spectator = createService();
const receiptId = 123;
const data = {};
patchState(
spectator.service as any,
addEntity({ id: receiptId, data, status: AsyncResultStatus.Idle }),
);
spectator.service.beforeFetch(receiptId);
expect(spectator.service.entityMap()[123]).toEqual({
id: receiptId,
data,
status: AsyncResultStatus.Pending,
});
});
});
describe('fetchSuccess', () => {
it('should update the entity with fetched data and set status to Success', () => {
const spectator = createService();
const receiptId = 123;
const data: Receipt = {
id: receiptId,
items: [],
buyer: { buyerNumber: '321' },
};
patchState(
spectator.service as any,
addEntity({
id: receiptId,
data: undefined,
status: AsyncResultStatus.Pending,
}),
);
spectator.service.fetchSuccess(receiptId, data);
expect(spectator.service.entityMap()[123]).toEqual({
id: receiptId,
data,
status: AsyncResultStatus.Success,
});
});
});
describe('fetchError', () => {
it('should update the entity status to Error', () => {
const spectator = createService();
const receiptId = 123;
const error = new Error('Fetch error');
patchState(
spectator.service as any,
addEntity({
id: receiptId,
data: undefined,
status: AsyncResultStatus.Pending,
}),
);
spectator.service.fetchError(receiptId, error);
const entity = spectator.service.entityMap()[123];
expect(entity).toMatchObject({
id: receiptId,
status: AsyncResultStatus.Error,
error,
});
});
});
});
describe('fetch', () => {
it('should call the service and update the store on success', () => {
describe('items', () => {
it('should return the items from the receiptsEntities', () => {
// Arrange
const spectator = createService();
const receiptId = 123;
const data: Receipt = {
id: receiptId,
items: [],
buyer: { buyerNumber: '321' },
};
spectator.service.beforeFetch(receiptId);
spectator
.inject(ReturnDetailsService)
.fetchReturnDetails.mockReturnValueOnce(of(data));
const receiptItems = [
{ id: 1 } as ReceiptItem,
{ id: 2 } as ReceiptItem,
{ id: 3 } as ReceiptItem,
{ id: 4 } as ReceiptItem,
];
spectator.service.fetch({ receiptId });
const receiptsEntities = [
{
id: 1,
items: [{ data: receiptItems[0] }, { data: receiptItems[1] }],
} as Receipt,
{
id: 2,
items: [{ data: receiptItems[2] }, { data: receiptItems[3] }],
} as Receipt,
] as Receipt[];
expect(
spectator.inject(ReturnDetailsService).fetchReturnDetails,
).toHaveBeenCalledWith({
receiptId,
});
patchState(
unprotected(spectator.service),
addEntities(receiptsEntities, receiptConfig),
);
expect(spectator.service.entityMap()[123]).toEqual({
id: receiptId,
data,
status: AsyncResultStatus.Success,
});
});
// Act
const items = spectator.service.items();
it('should handle errors and update the store accordingly', () => {
const spectator = createService();
const receiptId = 123;
const error = new Error('Fetch error');
spectator.service.beforeFetch(receiptId);
spectator
.inject(ReturnDetailsService)
.fetchReturnDetails.mockReturnValueOnce(throwError(() => error));
spectator.service.fetch({ receiptId });
expect(
spectator.inject(ReturnDetailsService).fetchReturnDetails,
).toHaveBeenCalledWith({
receiptId,
});
const entity = spectator.service.entityMap()[123];
expect(entity).toMatchObject({
id: receiptId,
status: AsyncResultStatus.Error,
error,
});
// Assert
expect(items).toEqual(receiptItems);
});
});
});

View File

@@ -1,218 +1,267 @@
import { patchState, signalStore, withMethods } from '@ngrx/signals';
import { addEntity, updateEntity, withEntities } from '@ngrx/signals/entities';
import { AsyncResult, AsyncResultStatus } from '@isa/common/data-access';
import { rxMethod } from '@ngrx/signals/rxjs-interop';
import { pipe, switchMap, tap } from 'rxjs';
import { inject } from '@angular/core';
import { ReturnDetailsService } from '../services';
import { tapResponse } from '@ngrx/operators';
import { Receipt } from '../models';
import { computed, effect, inject, resource, untracked } from '@angular/core';
import {
CanReturn,
ProductCategory,
Receipt,
ReceiptItem,
ReturnDetailsService,
} from '@isa/oms/data-access';
import {
getState,
patchState,
signalStore,
type,
withComputed,
withMethods,
withProps,
withState,
} from '@ngrx/signals';
import { setEntity, withEntities, entityConfig } from '@ngrx/signals/entities';
import {
canReturnReceiptItem,
getReceiptItemProductCategory,
receiptItemHasCategory,
} from '../helpers/return-process';
import { SessionStorageProvider } from '@isa/core/storage';
import { logger } from '@isa/core/logging';
/**
* Represents the result of a return operation, including the receipt data and status.
*/
export type ReturnResult = AsyncResult<Receipt | undefined> & { id: number };
interface ReturnDetailsState {
_storageId: number | undefined;
_selectedItemIds: number[];
selectedProductCategory: Record<number, ProductCategory>;
selectedQuantity: Record<number, number>;
canReturn: Record<string, CanReturn>;
}
/**
* Initial state for a return result entity, excluding the unique identifier.
*/
const initialEntity: Omit<ReturnResult, 'id'> = {
data: undefined,
status: AsyncResultStatus.Idle,
const initialState: ReturnDetailsState = {
_storageId: undefined,
_selectedItemIds: [],
selectedProductCategory: {},
selectedQuantity: {},
canReturn: {},
};
/**
* Store for managing return details using NgRx signals.
* Provides methods for fetching and updating return details.
*/
export const receiptConfig = entityConfig({
entity: type<Receipt>(),
collection: 'receipts',
});
export const ReturnDetailsStore = signalStore(
{ providedIn: 'root' },
withEntities<ReturnResult>(),
withState(initialState),
withEntities(receiptConfig),
withProps(() => ({
_logger: logger(() => ({ store: 'ReturnDetailsStore' })),
_returnDetailsService: inject(ReturnDetailsService),
_storage: inject(SessionStorageProvider),
})),
withMethods((store) => ({
/**
* Prepares the store before fetching return details by adding or updating the entity with a pending status.
* @param receiptId - The unique identifier of the receipt.
* @returns The updated or newly created entity.
*/
beforeFetch(receiptId: number) {
// Using optional chaining to safely retrieve the entity from the map
let entity: ReturnResult | undefined = store.entityMap()?.[receiptId];
if (!entity) {
entity = {
...initialEntity,
id: receiptId,
status: AsyncResultStatus.Pending,
};
patchState(store, addEntity(entity));
_storageKey: () => `ReturnDetailsStore:${store._storageId}`,
})),
withMethods((store) => ({
_storeState: () => {
const state = getState(store);
if (!store._storageId) {
return;
}
store._storage.set(store._storageKey(), state);
store._logger.debug('State stored:', () => state);
},
_restoreState: async () => {
const data = await store._storage.get(store._storageKey());
if (data) {
patchState(store, data);
store._logger.debug('State restored:', () => ({ data }));
} else {
patchState(
store,
updateEntity({
id: receiptId,
changes: { status: AsyncResultStatus.Pending },
}),
);
patchState(store, { ...initialState, _storageId: store._storageId() });
store._logger.debug('No state found, initialized with default state');
}
},
/**
* Updates the store with the fetched return details on a successful fetch operation.
* @param receiptId - The unique identifier of the receipt.
* @param data - The fetched receipt data.
*/
fetchSuccess(receiptId: number, data: Receipt) {
patchState(
store,
updateEntity({
id: receiptId,
changes: { data, status: AsyncResultStatus.Success },
}),
);
},
/**
* Updates the store with an error state if the fetch operation fails.
* @param receiptId - The unique identifier of the receipt.
* @param error - The error encountered during the fetch operation.
*/
fetchError(receiptId: number, error: unknown) {
patchState(
store,
updateEntity({
id: receiptId,
changes: { error, status: AsyncResultStatus.Error },
}),
);
},
/**
* Updates the product quantity for a specific item in a receipt.
* This method modifies the quantity feature of an item within a receipt's data.
*
* @param params - Object containing parameters for the update
* @param params.receiptId - The unique identifier of the receipt containing the item
* @param params.itemId - The unique identifier of the item to update
* @param params.quantity - The new quantity value to assign to the item (defaults to 0 if falsy)
* @returns void
*/
updateProductQuantityForItem({
receiptId,
itemId,
quantity,
}: {
receiptId: number;
itemId: number;
quantity: number;
}) {
const receipt = store.entityMap()?.[receiptId];
if (!receipt) return;
const updatedItems = receipt?.data?.items.map((item) => {
if (item.id !== itemId) {
return item;
}
return {
...item,
data: {
...item.data,
quantity: {
...item?.data?.quantity,
quantity: quantity || 0,
},
},
};
});
patchState(
store,
updateEntity({
id: receiptId,
changes: {
data: {
...receipt.data,
items: updatedItems,
} as Receipt,
},
}),
);
},
/**
* Updates the product category for a specific item in a receipt.
* This method modifies the category feature of an item within a receipt's data.
*
* @param params - Object containing parameters for the update
* @param params.receiptId - The unique identifier of the receipt containing the item
* @param params.itemId - The unique identifier of the item to update
* @param params.category - The new category value to assign to the item (defaults to 'unknown' if falsy)
* @returns void
*/
updateProductCategoryForItem({
receiptId,
itemId,
category,
}: {
receiptId: number;
itemId: number;
category: string;
}) {
const receipt = store.entityMap()?.[receiptId];
if (!receipt) return;
const updatedItems = receipt?.data?.items.map((item) => {
if (item.id !== itemId) {
return item;
}
return {
...item,
data: {
...item.data,
features: {
...item?.data?.features,
category: category || 'unknown',
},
},
};
});
patchState(
store,
updateEntity({
id: receiptId,
changes: {
data: {
...receipt.data,
items: updatedItems,
} as Receipt,
},
}),
);
},
})),
withMethods((store, returnDetailsService = inject(ReturnDetailsService)) => ({
/**
* Fetches return details for a given receipt ID.
* Updates the store with the appropriate state based on the fetch result.
* @param params - An object containing the receipt ID.
*/
fetch: rxMethod<{ receiptId: number }>(
pipe(
tap(({ receiptId }) => store.beforeFetch(receiptId)),
switchMap(({ receiptId }) =>
returnDetailsService.fetchReturnDetails({ receiptId }).pipe(
tapResponse({
next(value) {
store.fetchSuccess(receiptId, value);
},
error(error) {
store.fetchError(receiptId, error);
},
}),
),
),
),
withComputed((store) => ({
items: computed<Array<ReceiptItem>>(() =>
store
.receiptsEntities()
.map((receipt) => receipt.items)
.flat()
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
.map((container) => container.data!),
),
})),
withComputed((store) => ({
selectedItemIds: computed(() => {
const selectedIds = store._selectedItemIds();
const canReturn = store.canReturn();
return selectedIds.filter((id) => {
const canReturnResult = canReturn[id]?.result;
return typeof canReturnResult === 'boolean' ? canReturnResult : true;
});
}),
})),
withComputed((store) => ({
returnableItems: computed(() => {
const items = store.items();
return items.filter(canReturnReceiptItem);
}),
selectableItems: computed(() => {
const items = store.items();
const selectedProductCategory = store.selectedProductCategory();
return items.filter(canReturnReceiptItem).filter((item) => {
const category = selectedProductCategory[item.id];
return (
category || !receiptItemHasCategory(item, ProductCategory.Unknown)
);
});
}),
selectedItems: computed(() => {
const selectedIds = store.selectedItemIds();
const items = store
.receiptsEntities()
.flatMap((receipt) => receipt.items.map((item) => item.data!));
return items.filter((item) => selectedIds.includes(item.id));
}),
})),
withMethods((store) => ({
receiptResource: (receiptId: () => number | undefined) =>
resource({
request: receiptId,
loader: async ({ abortSignal, request }) => {
if (!request) {
return undefined;
}
const receipt = await store._returnDetailsService.fetchReturnDetails(
{ receiptId: request },
abortSignal,
);
patchState(store, setEntity(receipt, receiptConfig));
store._storeState();
return receipt;
},
}),
canReturnResource: (receiptItem: () => ReceiptItem | undefined) =>
resource({
request: () => {
const item = receiptItem();
if (!item) {
return undefined;
}
return {
item: item,
category:
store.selectedProductCategory()[item.id] ||
getReceiptItemProductCategory(item),
};
},
loader: async ({ request, abortSignal }) => {
if (request === undefined) {
return undefined;
}
const key = `${request.item.id}:${request.category}`;
if (store.canReturn()[key]) {
return store.canReturn()[key];
}
const res = await store._returnDetailsService.canReturn(
request,
abortSignal,
);
patchState(store, {
canReturn: { ...store.canReturn(), [key]: res },
});
store._storeState();
return res;
},
}),
getReceipt: (receiptId: () => number) =>
computed(() => {
const id = receiptId();
const entities = store.receiptsEntityMap();
return entities[id];
}),
getItems: (receiptId: () => number) =>
computed(() => {
const id = receiptId();
const entities = store.receiptsEntityMap();
const receipt = entities[id];
return receipt?.items.map((item) => item.data!);
}),
getSelectableItems: (receiptId: () => number) =>
computed(() =>
store
.selectableItems()
.filter((item) => item.receipt?.id === receiptId()),
),
getItemSelected: (item: () => ReceiptItem) =>
computed(() => {
const selectedIds = store.selectedItemIds();
return selectedIds.includes(item().id);
}),
getItemQuantity: (item: () => ReceiptItem) =>
computed(() => {
const itemData = item();
return (
store.selectedQuantity()[itemData.id] || itemData.quantity.quantity
);
}),
getProductCategory: (item: () => ReceiptItem) =>
computed(() => {
const itemData = item();
return (
store.selectedProductCategory()[itemData.id] ||
getReceiptItemProductCategory(itemData)
);
}),
isSelectable: (receiptItem: () => ReceiptItem) =>
computed(() => {
const item = receiptItem();
const selectableItems = store.selectableItems();
return selectableItems.some((i) => i.id === item.id);
}),
getCanReturn: (item: () => ReceiptItem) =>
computed<CanReturn | undefined>(() => {
const itemData = item();
return store.canReturn()[itemData.id];
}),
})),
withMethods((store) => ({
selectStorage: (id: number) => {
untracked(() => {
patchState(store, { _storageId: id });
store._restoreState();
store._storeState();
store._logger.debug('Storage ID set:', () => ({ id }));
});
},
addSelectedItems(itemIds: number[]) {
const currentIds = store.selectedItemIds();
const newIds = Array.from(new Set([...currentIds, ...itemIds]));
patchState(store, { _selectedItemIds: newIds });
store._storeState();
},
removeSelectedItems(itemIds: number[]) {
const currentIds = store.selectedItemIds();
const newIds = currentIds.filter((id) => !itemIds.includes(id));
patchState(store, { _selectedItemIds: newIds });
store._storeState();
},
async setProductCategory(itemId: number, category: ProductCategory) {
const currentCategory = store.selectedProductCategory();
const newCategory = { ...currentCategory, [itemId]: category };
patchState(store, { selectedProductCategory: newCategory });
store._storeState();
},
setQuantity(itemId: number, quantity: number) {
const currentQuantity = store.selectedQuantity();
const newQuantity = { ...currentQuantity, [itemId]: quantity };
patchState(store, { selectedQuantity: newQuantity });
store._storeState();
},
})),
);

View File

@@ -3,7 +3,8 @@ import { ReturnProcessStore } from './return-process.store';
import { IDBStorageProvider } from '@isa/core/storage';
import { ProcessService } from '@isa/core/process';
import { patchState } from '@ngrx/signals';
import { setAllEntities } from '@ngrx/signals/entities';
import { setAllEntities, setEntity } from '@ngrx/signals/entities';
import { unprotected } from '@ngrx/signals/testing';
import { Product, Receipt, ReturnProcess } from '../models';
import { CreateReturnProcessError } from '../errors/return-process';
@@ -17,6 +18,7 @@ const TEST_ITEMS: Record<number, ReturnProcess['receiptItem']> = {
formatDetail: 'Taschenbuch',
} as Product,
quantity: { quantity: 1 },
receiptNumber: 'R-001',
},
2: {
id: 2,
@@ -27,6 +29,7 @@ const TEST_ITEMS: Record<number, ReturnProcess['receiptItem']> = {
formatDetail: 'Buch',
} as Product,
quantity: { quantity: 1 },
receiptNumber: 'R-002',
},
3: {
id: 3,
@@ -37,6 +40,7 @@ const TEST_ITEMS: Record<number, ReturnProcess['receiptItem']> = {
formatDetail: 'Audio',
} as Product,
quantity: { quantity: 1 },
receiptNumber: 'R-003',
},
};
@@ -64,12 +68,39 @@ describe('ReturnProcessStore', () => {
const store = spectator.service;
patchState(
store as any,
unprotected(store),
setAllEntities([
{ id: 1, processId: 1, name: 'Process 1' },
{ id: 2, processId: 2, name: 'Process 2' },
{ id: 3, processId: 1, name: 'Process 3' },
]),
{
id: 1,
processId: 1,
receiptId: 1,
receiptItem: TEST_ITEMS[1],
receiptDate: '',
answers: {},
productCategory: undefined,
returnReceipt: undefined,
},
{
id: 2,
processId: 2,
receiptId: 2,
receiptItem: TEST_ITEMS[2],
receiptDate: '',
answers: {},
productCategory: undefined,
returnReceipt: undefined,
},
{
id: 3,
processId: 1,
receiptId: 3,
receiptItem: TEST_ITEMS[3],
receiptDate: '',
answers: {},
productCategory: undefined,
returnReceipt: undefined,
},
] as ReturnProcess[]),
);
store.removeAllEntitiesByProcessId(1);
@@ -82,8 +113,19 @@ describe('ReturnProcessStore', () => {
const store = spectator.service;
patchState(
store as any,
setAllEntities([{ id: 1, processId: 1, answers: {} }]),
unprotected(store),
setAllEntities([
{
id: 1,
processId: 1,
receiptId: 1,
receiptItem: TEST_ITEMS[1],
receiptDate: '',
answers: {},
productCategory: undefined,
returnReceipt: undefined,
},
] as ReturnProcess[]),
);
store.setAnswer(1, 'question1', 'answer1');
@@ -95,10 +137,20 @@ describe('ReturnProcessStore', () => {
const store = spectator.service;
patchState(
store as any,
setAllEntities([
{ id: 1, processId: 1, answers: { question1: 'answer1' } },
]),
unprotected(store),
setEntity({
id: 1,
processId: 1,
answers: { question1: 'answer1', question2: 'answer2' } as Record<
string,
unknown
>,
receiptDate: new Date().toJSON(),
receiptItem: TEST_ITEMS[1],
receiptId: 123,
productCategory: undefined,
returnReceipt: undefined,
} as ReturnProcess),
);
store.removeAnswer(1, 'question1');
@@ -113,8 +165,26 @@ describe('ReturnProcessStore', () => {
store.startProcess({
processId: 1,
receipt: {} as Receipt,
items: [TEST_ITEMS[1], TEST_ITEMS[3]],
returns: [
{
receipt: {
id: 1,
printedDate: '',
items: [],
buyer: { buyerNumber: '' },
},
items: [TEST_ITEMS[1]],
},
{
receipt: {
id: 2,
printedDate: '',
items: [],
buyer: { buyerNumber: '' },
},
items: [TEST_ITEMS[3]],
},
],
});
expect(store.entities()).toHaveLength(2);
@@ -127,8 +197,17 @@ describe('ReturnProcessStore', () => {
expect(() => {
store.startProcess({
processId: 1,
receipt: {} as Receipt,
items: [TEST_ITEMS[2]],
returns: [
{
receipt: {
id: 2,
printedDate: '',
items: [],
buyer: { buyerNumber: '' },
},
items: [TEST_ITEMS[2]], // Non-returnable item
},
],
});
}).toThrow(CreateReturnProcessError);
});
@@ -140,8 +219,17 @@ describe('ReturnProcessStore', () => {
expect(() => {
store.startProcess({
processId: 1,
receipt: {} as Receipt,
items: [TEST_ITEMS[1], TEST_ITEMS[2], TEST_ITEMS[3]],
returns: [
{
receipt: {
id: 3,
printedDate: '',
items: [],
buyer: { buyerNumber: '' },
},
items: [TEST_ITEMS[1], TEST_ITEMS[2], TEST_ITEMS[3]],
},
],
});
}).toThrow(CreateReturnProcessError);
});

View File

@@ -4,6 +4,7 @@ import {
withComputed,
withHooks,
withMethods,
withProps,
} from '@ngrx/signals';
import {
withEntities,
@@ -14,19 +15,19 @@ import { IDBStorageProvider, withStorage } from '@isa/core/storage';
import { computed, effect, inject } from '@angular/core';
import { ProcessService } from '@isa/core/process';
import { Receipt, ReceiptItem, ReturnProcess } from '../models';
import { coerceBooleanProperty } from '@angular/cdk/coercion';
import {
CreateReturnProcessError,
CreateReturnProcessErrorReason,
} from '../errors/return-process';
import { logger } from '@isa/core/logging';
import { canReturnReceiptItem } from '../helpers/return-process';
/**
* Interface representing the parameters required to start a return process.
*/
export type StartProcess = {
processId: number;
receipt: Receipt;
items: ReceiptItem[];
returns: { receipt: Receipt; items: ReceiptItem[] }[];
};
/**
@@ -57,6 +58,11 @@ export const ReturnProcessStore = signalStore(
{ providedIn: 'root' },
withStorage('return-process', IDBStorageProvider),
withEntities<ReturnProcess>(),
withProps(() => ({
_logger: logger(() => ({
store: 'ReturnProcessStore',
})),
})),
withComputed((store) => ({
nextId: computed(() => Math.max(0, ...store.ids().map(Number)) + 1),
})),
@@ -134,38 +140,40 @@ export const ReturnProcessStore = signalStore(
const entities: ReturnProcess[] = [];
const nextId = store.nextId();
const returnableItems = params.items.filter((item) =>
item.actions?.some(
(a) => a.key === 'canReturn' && coerceBooleanProperty(a.value),
),
);
const returnableItems = params.returns
.flatMap((r) => r.items)
.filter(canReturnReceiptItem);
if (returnableItems.length === 0) {
throw new CreateReturnProcessError(
const err = new CreateReturnProcessError(
CreateReturnProcessErrorReason.NO_RETURNABLE_ITEMS,
params,
);
store._logger.error(err.message, err);
throw err;
}
if (returnableItems.length !== params.items.length) {
throw new CreateReturnProcessError(
if (returnableItems.length !== params.returns.length) {
const err = new CreateReturnProcessError(
CreateReturnProcessErrorReason.MISMATCH_RETURNABLE_ITEMS,
params,
);
store._logger.error(err.message, err);
throw err;
}
for (let i = 0; i < params.items.length; i++) {
const item = params.items[i];
entities.push({
id: nextId + i,
processId: params.processId,
receiptId: params.receipt.id,
productCategory: item.features?.['category'],
receiptDate: params.receipt.printedDate,
receiptItem: item,
answers: {},
});
for (const { receipt, items } of params.returns) {
for (const item of items) {
entities.push({
id: nextId + entities.length,
processId: params.processId,
receiptId: receipt.id,
productCategory: item.features?.['category'],
receiptDate: receipt.printedDate,
receiptItem: item,
answers: {},
});
}
}
patchState(store, setAllEntities(entities));

View File

@@ -75,7 +75,7 @@ describe('ReturnSearchStore', () => {
error: false,
take: 10,
invalidProperties: {},
result: [{ id: 1 }],
result: [{ id: 1 } as ReceiptListItem],
};
spectator.service.beforeSearch(1);
@@ -111,7 +111,7 @@ describe('ReturnSearchStore', () => {
error: false,
take: 10,
invalidProperties: {},
result: [{ id: 1 }],
result: [{ id: 1 } as ReceiptListItem],
};
const returnSearchService = spectator.inject(ReturnSearchService);
returnSearchService.search.mockReturnValue(of(mockResponse));

View File

@@ -1,3 +1,5 @@
@let r = receipt();
<ui-item-row-data>
<ui-item-row-data-row>
<ui-item-row-data-label>Belegdatum:</ui-item-row-data-label>
@@ -10,7 +12,7 @@
<ui-item-row-data-row>
<ui-item-row-data-label>Belegart:</ui-item-row-data-label>
<ui-item-row-data-value>
<span>{{ receipt().receiptType | omsReceiptTypeTranslation }}</span>
<span>{{ r.receiptType | omsReceiptTypeTranslation }}</span>
</ui-item-row-data-value>
</ui-item-row-data-row>
</ui-item-row-data>

View File

@@ -3,6 +3,9 @@ import { ChangeDetectionStrategy, Component, input } from '@angular/core';
import { Receipt } from '@isa/oms/data-access';
import { ReceiptTypeTranslationPipe } from '@isa/oms/utils/translation';
import { ItemRowDataImports } from '@isa/ui/item-rows';
export type ReceiptInput = Pick<Receipt, 'printedDate' | 'receiptType'>;
@Component({
selector: 'oms-feature-return-details-data',
templateUrl: './return-details-data.component.html',
@@ -11,5 +14,5 @@ import { ItemRowDataImports } from '@isa/ui/item-rows';
imports: [ItemRowDataImports, ReceiptTypeTranslationPipe, DatePipe],
})
export class ReturnDetailsDataComponent {
receipt = input.required<Receipt>();
receipt = input.required<ReceiptInput>();
}

View File

@@ -6,13 +6,13 @@ import {
input,
} from '@angular/core';
import { isaActionChevronDown, isaNavigationKunden } from '@isa/icons';
import { Buyer } from '@isa/oms/data-access';
import { InfoButtonComponent } from '@isa/ui/buttons';
import { NgIconComponent, provideIcons } from '@ng-icons/core';
import { formatName } from 'libs/oms/utils/format-name';
import { UiMenu } from '@isa/ui/menu';
import { RouterLink } from '@angular/router';
import { CdkMenuTrigger } from '@angular/cdk/menu';
import { ReturnDetailsStore } from '@isa/oms/data-access';
@Component({
selector: 'oms-feature-return-details-header',
@@ -30,7 +30,13 @@ import { CdkMenuTrigger } from '@angular/cdk/menu';
providers: [provideIcons({ isaNavigationKunden, isaActionChevronDown })],
})
export class ReturnDetailsHeaderComponent {
buyer = input.required<Buyer>();
#store = inject(ReturnDetailsStore);
receiptId = input.required<number>();
receipt = this.#store.getReceipt(this.receiptId);
buyer = computed(() => this.receipt().buyer);
referenceId = computed(() => {
const buyer = this.buyer();
@@ -38,7 +44,11 @@ export class ReturnDetailsHeaderComponent {
});
name = computed(() => {
console.log({ buyer: this.buyer() });
const buyer = this.buyer();
if (!buyer) {
return '';
}
const firstName = this.buyer()?.firstName;
const lastName = this.buyer()?.lastName;
const organisationName = this.buyer()?.organisation?.name;

View File

@@ -0,0 +1,43 @@
<ng-container uiExpandable #expandable="uiExpandable">
<oms-feature-return-details-order-group
uiExpandableTrigger
[receipt]="receipt()"
></oms-feature-return-details-order-group>
<ng-container *uiExpanded>
@if (receiptResource.isLoading()) {
<ui-progress-bar mode="indeterminate" class="w-full"></ui-progress-bar>
} @else if (receiptResource.value()) {
@let r = receiptResource.value()!;
<ng-container uiExpandable #showMore="uiExpandable">
<oms-feature-return-details-order-group-data
*uiExpanded
[receipt]="r"
></oms-feature-return-details-order-group-data>
<button
type="button"
uiTextButton
type="button"
color="strong"
size="small"
uiExpandableTrigger
>
@if (showMore.expanded()) {
<ng-icon name="isaActionMinus"></ng-icon>
Weniger anzeigen
} @else {
<ng-icon name="isaActionPlus"></ng-icon>
Bestelldetails anzeigen
}
</button>
</ng-container>
@for (item of r.items; track item.id; let last = $last) {
<oms-feature-return-details-order-group-item
class="border-b border-solid border-isa-neutral-300 last:border-none"
[item]="item.data!"
></oms-feature-return-details-order-group-item>
}
}
</ng-container>
</ng-container>

View File

@@ -0,0 +1,57 @@
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
input,
viewChild,
} from '@angular/core';
import { ReturnDetailsOrderGroupComponent } from '../return-details-order-group/return-details-order-group.component';
import { ReturnDetailsOrderGroupDataComponent } from '../return-details-order-group-data/return-details-order-group-data.component';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { isaActionPlus, isaActionMinus } from '@isa/icons';
import { ReturnDetailsOrderGroupItemComponent } from '../return-details-order-group-item/return-details-order-group-item.component';
import { ReceiptListItem, ReturnDetailsStore } from '@isa/oms/data-access';
import { ExpandableDirective, ExpandableDirectives } from '@isa/ui/expandable';
import { TextButtonComponent } from '@isa/ui/buttons';
import { ProgressBarComponent } from '@isa/ui/progress-bar';
@Component({
selector: 'oms-feature-return-details-lazy',
templateUrl: './return-details-lazy.component.html',
styleUrls: ['./return-details-lazy.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [
ReturnDetailsOrderGroupComponent,
ReturnDetailsOrderGroupDataComponent,
ReturnDetailsOrderGroupItemComponent,
NgIcon,
ExpandableDirectives,
TextButtonComponent,
ProgressBarComponent,
],
providers: [provideIcons({ isaActionPlus, isaActionMinus })],
})
export class ReturnDetailsLazyComponent {
#store = inject(ReturnDetailsStore);
receipt = input.required<ReceiptListItem>();
receiptMap = this.#store.receiptsEntityMap;
exbandable = viewChild(ExpandableDirective);
receiptId = computed(() => {
const ex = this.exbandable();
if (!ex) {
return;
}
if (!ex.expanded()) {
return;
}
return this.receipt().id;
});
receiptResource = this.#store.receiptResource(this.receiptId);
}

View File

@@ -1,49 +1,51 @@
<ui-item-row-data>
<ui-item-row-data-row>
<ui-item-row-data-label>Belegdatum:</ui-item-row-data-label>
<ui-item-row-data-value>
{{ (receipt().printedDate | date: 'dd.MM.yyyy | HH:mm') + ' Uhr' }}
</ui-item-row-data-value>
</ui-item-row-data-row>
<ui-item-row-data-row>
<ui-item-row-data-label>Belegart:</ui-item-row-data-label>
<ui-item-row-data-value>
{{ receipt().receiptType | omsReceiptTypeTranslation }}
</ui-item-row-data-value>
</ui-item-row-data-row>
<ui-item-row-data-row>
<ui-item-row-data-label>Vorgang-ID:</ui-item-row-data-label>
<ui-item-row-data-value>
{{ receipt().order?.data?.orderNumber }}
</ui-item-row-data-value>
</ui-item-row-data-row>
<ui-item-row-data-row>
<ui-item-row-data-label>Bestelldatum:</ui-item-row-data-label>
<ui-item-row-data-value>
{{
(receipt().order?.data?.orderDate | date: 'dd.MM.yyyy | HH:mm') + ' Uhr'
}}
</ui-item-row-data-value>
</ui-item-row-data-row>
@if (receipt().buyer?.address; as address) {
<ui-item-row-data-row>
<ui-item-row-data-label>Anschrift:</ui-item-row-data-label>
<ui-item-row-data-value>
<div>{{ buyerName() }}</div>
<div>{{ address.street }} {{ address.streetNumber }}</div>
<div>{{ address.zipCode }} {{ address.city }}</div>
</ui-item-row-data-value>
</ui-item-row-data-row>
}
@let r = receipt();
@if (receipt().shipping.address; as address) {
@if (r) {
<ui-item-row-data>
<ui-item-row-data-row>
<ui-item-row-data-label>Lieferanschrift:</ui-item-row-data-label>
<ui-item-row-data-label>Belegdatum:</ui-item-row-data-label>
<ui-item-row-data-value>
<div>{{ shippingName() }}</div>
<div>{{ address.street }} {{ address.streetNumber }}</div>
<div>{{ address.zipCode }} {{ address.city }}</div>
{{ (r.printedDate | date: 'dd.MM.yyyy | HH:mm') + ' Uhr' }}
</ui-item-row-data-value>
</ui-item-row-data-row>
}
</ui-item-row-data>
<ui-item-row-data-row>
<ui-item-row-data-label>Belegart:</ui-item-row-data-label>
<ui-item-row-data-value>
{{ r.receiptType | omsReceiptTypeTranslation }}
</ui-item-row-data-value>
</ui-item-row-data-row>
<ui-item-row-data-row>
<ui-item-row-data-label>Vorgang-ID:</ui-item-row-data-label>
<ui-item-row-data-value>
{{ r.order?.data?.orderNumber }}
</ui-item-row-data-value>
</ui-item-row-data-row>
<ui-item-row-data-row>
<ui-item-row-data-label>Bestelldatum:</ui-item-row-data-label>
<ui-item-row-data-value>
{{ (r.order?.data?.orderDate | date: 'dd.MM.yyyy | HH:mm') + ' Uhr' }}
</ui-item-row-data-value>
</ui-item-row-data-row>
@if (r.buyer?.address; as address) {
<ui-item-row-data-row>
<ui-item-row-data-label>Anschrift:</ui-item-row-data-label>
<ui-item-row-data-value>
<div>{{ buyerName() }}</div>
<div>{{ address.street }} {{ address.streetNumber }}</div>
<div>{{ address.zipCode }} {{ address.city }}</div>
</ui-item-row-data-value>
</ui-item-row-data-row>
}
@if (r.shipping?.address; as address) {
<ui-item-row-data-row>
<ui-item-row-data-label>Lieferanschrift:</ui-item-row-data-label>
<ui-item-row-data-value>
<div>{{ shippingName() }}</div>
<div>{{ address.street }} {{ address.streetNumber }}</div>
<div>{{ address.zipCode }} {{ address.city }}</div>
</ui-item-row-data-value>
</ui-item-row-data-row>
}
</ui-item-row-data>
}

View File

@@ -1,9 +1,19 @@
import { DatePipe } from '@angular/common';
import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core';
import {
ChangeDetectionStrategy,
Component,
computed,
input,
} from '@angular/core';
import { Receipt } from '@isa/oms/data-access';
import { ReceiptTypeTranslationPipe } from '@isa/oms/utils/translation';
import { ItemRowDataImports } from '@isa/ui/item-rows';
export type ReceiptInput = Pick<
Receipt,
'buyer' | 'shipping' | 'printedDate' | 'receiptType' | 'order'
>;
@Component({
selector: 'oms-feature-return-details-order-group-data',
templateUrl: './return-details-order-group-data.component.html',
@@ -13,15 +23,29 @@ import { ItemRowDataImports } from '@isa/ui/item-rows';
imports: [ItemRowDataImports, ReceiptTypeTranslationPipe, DatePipe],
})
export class ReturnDetailsOrderGroupDataComponent {
receipt = input.required<Receipt>();
receipt = input.required<ReceiptInput>();
buyerName = computed(() => {
const receipt = this.receipt();
return [receipt.buyer?.firstName, receipt.buyer?.lastName].filter(Boolean).join(' ');
if (!receipt) {
return '';
}
return [receipt.buyer?.firstName, receipt.buyer?.lastName]
.filter(Boolean)
.join(' ');
});
shippingName = computed(() => {
const receipt = this.receipt();
return [receipt.shipping?.firstName, receipt.shipping?.lastName].filter(Boolean).join(' ');
if (!receipt) {
return '';
}
return [receipt.shipping?.firstName, receipt.shipping?.lastName]
.filter(Boolean)
.join(' ');
});
}

View File

@@ -1,9 +1,6 @@
@if (canReturnReceiptItem()) {
@if (quantityDropdownValues().length > 1) {
<ui-dropdown
[value]="quantity()"
(valueChange)="changeProductQuantity($event)"
>
<ui-dropdown [value]="quantity()" (valueChange)="setQuantity($event)">
@for (quantity of quantityDropdownValues(); track quantity) {
<ui-dropdown-option [value]="quantity">{{
quantity
@@ -14,7 +11,7 @@
<ui-dropdown
label="Produktart"
[value]="getProductCategory()"
[value]="productCategory()"
(valueChange)="setProductCategory($event)"
>
@for (kv of availableCategories; track kv.key) {
@@ -22,17 +19,17 @@
}
</ui-dropdown>
@if (selectable()) {
@if (!canReturnResource.isLoading() && selectable()) {
<ui-checkbox appearance="bullet">
<input
type="checkbox"
[checked]="selected()"
(click)="selected.set(!selected())"
[ngModel]="selected()"
(ngModelChange)="setSelected($event)"
data-what="return-item-checkbox"
[attr.data-which]="item().product.ean"
/>
</ui-checkbox>
} @else if (showProductCategoryDropdownLoading()) {
} @else if (canReturnResource.isLoading()) {
<ui-icon-button
[pending]="true"
[color]="'tertiary'"

View File

@@ -1,20 +1,15 @@
import { coerceBooleanProperty } from '@angular/cdk/coercion';
import { KeyValue } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
computed,
effect,
inject,
input,
model,
output,
signal,
untracked,
} from '@angular/core';
import { logger, provideLoggerContext } from '@isa/core/logging';
import { provideLoggerContext } from '@isa/core/logging';
import {
CanReturn,
canReturnReceiptItem,
ProductCategory,
ReceiptItem,
ReturnDetailsService,
ReturnDetailsStore,
@@ -25,6 +20,7 @@ import {
DropdownButtonComponent,
DropdownOptionComponent,
} from '@isa/ui/input-controls';
import { FormsModule } from '@angular/forms';
@Component({
selector: 'oms-feature-return-details-order-group-item-controls',
@@ -37,6 +33,7 @@ import {
IconButtonComponent,
DropdownButtonComponent,
DropdownOptionComponent,
FormsModule,
],
providers: [
provideLoggerContext({
@@ -45,109 +42,60 @@ import {
],
})
export class ReturnDetailsOrderGroupItemControlsComponent {
item = input.required<ReceiptItem>();
receiptId = input.required<number>();
#returnDetailsService = inject(ReturnDetailsService);
#returnDetailsStore = inject(ReturnDetailsStore);
#store = inject(ReturnDetailsStore);
#logger = logger();
item = input.required<ReceiptItem>();
selected = model(false);
selected = this.#store.getItemSelected(this.item);
availableCategories: KeyValue<string, string>[] =
this.#returnDetailsService.availableCategories();
productCategoryChanged = signal<ProductCategory>(ProductCategory.Unknown);
quantity = computed(() => {
return this.item()?.quantity.quantity;
canReturnRequest = computed(() => {
const productCategory = this.productCategoryChanged();
if (productCategory === ProductCategory.Unknown) {
return undefined;
}
return this.item();
});
quantityDropdownValues = signal<number[]>([]);
canReturnResource = this.#store.canReturnResource(this.canReturnRequest);
readonly showProductCategoryDropdownLoading = signal(false);
availableCategories = this.#returnDetailsService.availableCategories();
getProductCategory = computed(() => {
return this.item()?.features?.['category'] || 'unknown';
quantity = this.#store.getItemQuantity(this.item);
quantityDropdownValues = computed(() => {
const itemQuantity = this.item().quantity.quantity;
return Array.from({ length: itemQuantity }, (_, i) => i + 1);
});
selectable = signal(false);
productCategory = this.#store.getProductCategory(this.item);
canReturn = output<CanReturn | undefined>();
selectable = this.#store.isSelectable(this.item);
canReturnReceiptItem = computed(() =>
this.item()?.actions?.some(
(a) => a.key === 'canReturn' && coerceBooleanProperty(a.value),
),
);
canReturnReceiptItem = computed(() => canReturnReceiptItem(this.item()));
constructor() {
effect(() => {
const quantityDropdown = this.quantityDropdownValues();
untracked(() => {
if (quantityDropdown.length === 0) {
this.quantityDropdownValues.set(
Array.from({ length: this.quantity() }, (_, i) => i + 1),
);
}
});
});
effect(() => {
const productCategory = this.getProductCategory();
const canReturnReceiptItem = this.canReturnReceiptItem();
const isSelectable =
canReturnReceiptItem && productCategory !== 'unknown';
if (!isSelectable) {
this.selectable.set(false);
} else {
this.selectable.set(true);
}
});
setProductCategory(category: ProductCategory | undefined) {
if (!category) {
category = ProductCategory.Unknown;
}
this.#store.setProductCategory(this.item().id, category);
this.productCategoryChanged.set(category);
}
async setProductCategory(category: string | undefined) {
const itemToUpdate = {
item: this.item(),
category: category || 'unknown',
};
setQuantity(quantity: number | undefined) {
if (quantity === undefined) {
quantity = this.item().quantity.quantity;
}
this.#store.setQuantity(this.item().id, quantity);
}
try {
this.showProductCategoryDropdownLoading.set(true);
this.canReturn.emit(undefined);
this.selectable.set(false);
const canReturn =
await this.#returnDetailsService.canReturn(itemToUpdate);
this.canReturn.emit(canReturn);
this.changeProductCategory(category || 'unknown');
this.showProductCategoryDropdownLoading.set(false);
} catch (error) {
this.#logger.error('Failed to setProductCategory', error, () => ({
itemId: this.item().id,
category,
}));
this.canReturn.emit(undefined);
this.showProductCategoryDropdownLoading.set(false);
setSelected(selected: boolean) {
if (selected) {
this.#store.addSelectedItems([this.item().id]);
} else {
this.#store.removeSelectedItems([this.item().id]);
}
}
changeProductCategory(category: string) {
this.#returnDetailsStore.updateProductCategoryForItem({
receiptId: this.receiptId(),
itemId: this.item().id,
category,
});
}
changeProductQuantity(quantity: number) {
this.#returnDetailsStore.updateProductQuantityForItem({
receiptId: this.receiptId(),
itemId: this.item().id,
quantity,
});
}
}

View File

@@ -61,14 +61,7 @@
{{ i.product.publicationDate | date: 'dd. MMM yyyy' }}
</div>
</div>
<oms-feature-return-details-order-group-item-controls
uiItemRowProductControls
(canReturn)="canReturn.set($event)"
[receiptId]="receiptId()"
(selectedChange)="selected.set($event)"
[selected]="selected()"
[item]="i"
>
<oms-feature-return-details-order-group-item-controls [item]="i">
</oms-feature-return-details-order-group-item-controls>
</ui-item-row>

View File

@@ -1,110 +0,0 @@
import { byText } from '@ngneat/spectator';
import { createComponentFactory, Spectator } from '@ngneat/spectator/jest';
import { MockDirective } from 'ng-mocks';
import { ReceiptItem, ReturnDetailsService } from '@isa/oms/data-access';
import { ReturnDetailsOrderGroupItemComponent } from './return-details-order-group-item.component';
import { ProductImageDirective } from '@isa/shared/product-image';
import { ReturnDetailsOrderGroupItemControlsComponent } from '../return-details-order-group-item-controls/return-details-order-group-item-controls.component';
// Helper function to create mock ReceiptItem data
const createMockItem = (
ean: string,
canReturn: boolean,
name = 'Test Product',
category = 'BOOK', // Add default category that's not 'unknown'
): ReceiptItem =>
({
id: 123,
quantity: { quantity: 1 },
price: {
value: { value: 19.99, currency: 'EUR' },
vat: { inPercent: 19 },
},
product: {
ean: ean,
name: name,
contributors: 'Test Author',
format: 'HC',
formatDetail: 'Hardcover',
manufacturer: 'Test Publisher',
publicationDate: '2024-01-01T00:00:00Z',
catalogProductNumber: '1234567890',
volume: '1',
},
actions: [{ key: 'canReturn', value: String(canReturn) }],
features: { category: category }, // Add the features property with category
}) as ReceiptItem;
describe('ReturnDetailsOrderGroupItemComponent', () => {
let spectator: Spectator<ReturnDetailsOrderGroupItemComponent>;
const mockItemSelectable = createMockItem('1234567890123', true);
const createComponent = createComponentFactory({
component: ReturnDetailsOrderGroupItemComponent,
mocks: [ReturnDetailsService],
componentMocks: [ReturnDetailsOrderGroupItemControlsComponent],
// Spectator automatically stubs standalone dependencies like ItemRowComponent, CheckboxComponent etc.
// We don't need deep interaction, just verify the host component renders correctly.
// If specific interactions were needed, we could provide mocks or use overrideComponents.
overrideComponents: [
[
ReturnDetailsOrderGroupItemComponent,
{
remove: { imports: [ProductImageDirective] },
add: {
imports: [MockDirective(ProductImageDirective)],
},
},
],
],
detectChanges: false, // Control initial detection manually
});
beforeEach(() => {
// Default setup with a selectable item
spectator = createComponent({
props: {
item: mockItemSelectable, // Use signal for input
selected: false, // Use signal for model
receiptId: 123,
},
});
});
it('should create', () => {
// Arrange
spectator.detectChanges(); // Trigger initial render
// Assert
expect(spectator.component).toBeTruthy();
});
it('should display product details correctly', () => {
// Arrange
spectator.detectChanges();
const item = mockItemSelectable;
// Assert
expect(spectator.query(byText(item.product.contributors))).toExist();
expect(spectator.query(`[data-what="product-name"]`)).toHaveText(
item.product.name,
);
expect(spectator.query(`[data-what="product-price"]`)).toHaveText('€19.99'); // Assuming default locale formatting
expect(
spectator.query(byText(`inkl. ${item.price?.vat?.inPercent}% MwSt`)),
).toExist();
expect(spectator.query(`[data-what="product-info"]`)).toHaveText(
`${item.product.manufacturer} | ${item.product.ean}`,
);
// Date formatting depends on locale, checking for year is safer
expect(
spectator.query(byText(/Jan 2024/)), // Adjust regex based on expected format/locale
).toExist();
expect(spectator.query(`img[data-what="product-image"]`)).toHaveAttribute(
'data-which',
item.product.ean,
);
});
});

View File

@@ -1,16 +1,17 @@
import { coerceBooleanProperty } from '@angular/cdk/coercion';
import { CurrencyPipe, DatePipe, LowerCasePipe } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
input,
model,
signal,
WritableSignal,
} from '@angular/core';
import { isaActionClose, ProductFormatIconGroup } from '@isa/icons';
import { CanReturn, ReceiptItem } from '@isa/oms/data-access';
import {
getReceiptItemAction,
ReceiptItem,
ReturnDetailsStore,
} from '@isa/oms/data-access';
import { ProductImageDirective } from '@isa/shared/product-image';
import { ItemRowComponent } from '@isa/ui/item-rows';
import { NgIconComponent, provideIcons } from '@ng-icons/core';
@@ -34,41 +35,41 @@ import { ReturnDetailsOrderGroupItemControlsComponent } from '../return-details-
providers: [provideIcons({ ...ProductFormatIconGroup, isaActionClose })],
})
export class ReturnDetailsOrderGroupItemComponent {
#store = inject(ReturnDetailsStore);
/**
* The receipt item data to display.
* Contains all information about a product including details, price, and return eligibility.
*/
item = input.required<ReceiptItem>();
/**
* The unique identifier of the receipt to which this item belongs.
* Used for making return eligibility checks against the backend API.
*/
receiptId = input.required<number>();
/**
* Two-way binding for the selection state of the item.
* Indicates whether the item is currently selected for return.
*/
selected = model(false);
selected = computed<boolean>(() => {
const selectedIds = this.#store.selectedItemIds();
const item = this.item();
return selectedIds.includes(item.id);
});
/**
* Holds the return eligibility information from the API.
* Contains both the eligibility result (boolean) and any message explaining the reason.
* This signal may be undefined if the eligibility check hasn't completed yet.
*/
canReturn: WritableSignal<CanReturn | undefined> = signal(undefined);
canReturn = this.#store.getCanReturn(this.item);
/**
* Computes whether the item can be returned.
* Prefers the endpoint result if available, otherwise checks the item's actions.
*/
canReturnReceiptItem = computed(() => {
const canReturn = this.canReturn()?.result;
const canReturnReceiptItem = this.item()?.actions?.some(
(a) => a.key === 'canReturn' && coerceBooleanProperty(a.value),
const returnableItems = this.#store.returnableItems();
const item = this.item();
return returnableItems.some(
(returnableItem) => returnableItem.id === item.id,
);
return canReturn ?? canReturnReceiptItem; // Endpoint Result (if existing) overrules item result
});
/**
@@ -76,10 +77,15 @@ export class ReturnDetailsOrderGroupItemComponent {
* Prefers the endpoint message if available, otherwise uses the item's action description.
*/
canReturnMessage = computed(() => {
const item = this.item();
const canReturnAction = getReceiptItemAction(item, 'canReturn');
if (canReturnAction?.description) {
return canReturnAction.description;
}
const canReturnMessage = this.canReturn()?.message;
const canReturnMessageOnReceiptItem = this.item()?.actions?.find(
(a) => a.key === 'canReturn',
)?.description;
return canReturnMessage ?? canReturnMessageOnReceiptItem; // Endpoint Message (if existing) overrules item message
return canReturnMessage ?? '';
});
}

View File

@@ -1,12 +1,13 @@
@let r = receipt();
<ui-toolbar size="small" class="justify-self-stretch">
<div class="isa-text-body-2-bold text-isa-neutral-900">
{{ items().length }} Artikel
{{ itemCount() }} Artikel
</div>
<div class="isa-text-body-2-bold text-isa-neutral-900">
{{ receipt().printedDate | date }}
{{ r.printedDate | date }}
</div>
<div class="isa-text-body-2-regular text-isa-neutral-900">
{{ receipt().receiptNumber }}
{{ r.receiptNumber }}
</div>
<div class="flex-grow"></div>
@@ -16,7 +17,7 @@
uiTextButton
color="strong"
size="small"
(click)="selectOrUnselectAll()"
(click)="selectOrUnselectAll(); $event.stopPropagation()"
>
@if (allSelected()) {
Alles abwählen
@@ -25,4 +26,10 @@
}
</button>
}
@if (expandableTrigger?.expanded()) {
<ng-icon size="1.5rem" name="isaActionChevronUp"> </ng-icon>
} @else if (expandableTrigger) {
<ng-icon size="1.5rem" name="isaActionChevronDown"> </ng-icon>
}
</ui-toolbar>

View File

@@ -1,15 +1,29 @@
import { coerceBooleanProperty } from '@angular/cdk/coercion';
import { DatePipe } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
input,
model,
} from '@angular/core';
import { Receipt, ReceiptItem } from '@isa/oms/data-access';
import { isaActionChevronDown, isaActionChevronUp } from '@isa/icons';
import {
Receipt,
ReceiptListItem,
ReturnDetailsStore,
} from '@isa/oms/data-access';
import { TextButtonComponent } from '@isa/ui/buttons';
import {
ExpandableDirective,
ExpandableTriggerDirective,
ExpandedDirective,
} from '@isa/ui/expandable';
import { ToolbarComponent } from '@isa/ui/toolbar';
import { NgIcon, provideIcons } from '@ng-icons/core';
export type ReceiptInput =
| Pick<Receipt, 'id' | 'printedDate' | 'receiptNumber' | 'items'>
| Pick<ReceiptListItem, 'id' | 'printedDate' | 'receiptNumber' | 'items'>;
@Component({
selector: 'oms-feature-return-details-order-group',
@@ -17,37 +31,48 @@ import { ToolbarComponent } from '@isa/ui/toolbar';
styleUrls: ['./return-details-order-group.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [ToolbarComponent, TextButtonComponent, DatePipe],
imports: [ToolbarComponent, TextButtonComponent, DatePipe, NgIcon],
providers: [provideIcons({ isaActionChevronDown, isaActionChevronUp })],
})
export class ReturnDetailsOrderGroupComponent {
receipt = input.required<Receipt>();
items = input.required<ReceiptItem[]>();
#store = inject(ReturnDetailsStore);
selectedItems = model<ReceiptItem[]>([]);
selectableItems = computed(() => {
return this.items().filter(
(item) =>
item.actions?.some(
(a) => a.key === 'canReturn' && coerceBooleanProperty(a.value),
) && item?.features?.['category'] !== 'unknown',
);
expandableTrigger = inject(ExpandableTriggerDirective, {
self: true,
optional: true,
});
selectOrUnselectAll() {
const selectedItems = this.selectedItems();
const selectableItems = this.selectableItems();
if (selectedItems.length === selectableItems.length) {
this.selectedItems.set([]);
return;
}
receipt = input.required<ReceiptInput>();
this.selectedItems.set(this.selectableItems());
receiptId = computed(() => this.receipt().id);
itemCount = computed(() => {
const receipt = this.receipt();
if (typeof receipt.items === 'number') {
return receipt.items || 0;
}
return receipt.items?.length || 0;
});
items = this.#store.getItems(this.receiptId);
selectableItems = this.#store.getSelectableItems(this.receiptId);
selectOrUnselectAll() {
const selectableItems = this.selectableItems();
if (this.allSelected()) {
this.#store.removeSelectedItems(selectableItems.map((item) => item.id));
} else {
this.#store.addSelectedItems(selectableItems.map((item) => item.id));
}
}
allSelected = computed(() => {
const selectedItems = this.selectedItems();
const selectedItemIds = this.#store.selectedItemIds();
const selectableItems = this.selectableItems();
return selectedItems.length === selectableItems.length;
return selectableItems.every((item) => {
return selectedItemIds.includes(item.id);
});
});
}

View File

@@ -0,0 +1,42 @@
@let r = receipt();
<oms-feature-return-details-header
[receiptId]="r.id"
></oms-feature-return-details-header>
<ng-container uiExpandable #showMore="uiExpandable">
<oms-feature-return-details-order-group-data
*uiExpanded
[receipt]="r"
></oms-feature-return-details-order-group-data>
<oms-feature-return-details-data
*uiCollapsed
[receipt]="r"
></oms-feature-return-details-data>
<button
type="button"
class="-ml-3"
uiTextButton
type="button"
color="strong"
size="small"
uiExpandableTrigger
>
@if (showMore.expanded()) {
<ng-icon name="isaActionMinus"></ng-icon>
Weniger anzeigen
} @else {
<ng-icon name="isaActionPlus"></ng-icon>
Bestelldetails anzeigen
}
</button>
</ng-container>
<oms-feature-return-details-order-group
[receipt]="r"
></oms-feature-return-details-order-group>
@for (item of r.items; track item.id; let last = $last) {
<oms-feature-return-details-order-group-item
class="border-b border-solid border-isa-neutral-300 last:border-none"
[item]="item.data!"
></oms-feature-return-details-order-group-item>
}

View File

@@ -0,0 +1,33 @@
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
import { ReturnDetailsHeaderComponent } from '../return-details-header/return-details-header.component';
import { ReturnDetailsOrderGroupComponent } from '../return-details-order-group/return-details-order-group.component';
import { ReturnDetailsOrderGroupDataComponent } from '../return-details-order-group-data/return-details-order-group-data.component';
import { ReturnDetailsDataComponent } from '../return-details-data/return-details-data.component';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { isaActionPlus, isaActionMinus } from '@isa/icons';
import { ReturnDetailsOrderGroupItemComponent } from '../return-details-order-group-item/return-details-order-group-item.component';
import { Receipt } from '@isa/oms/data-access';
import { ExpandableDirectives } from '@isa/ui/expandable';
import { TextButtonComponent } from '@isa/ui/buttons';
@Component({
selector: 'oms-feature-return-details-static',
templateUrl: './return-details-static.component.html',
styleUrls: ['./return-details-static.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [
ReturnDetailsHeaderComponent,
ReturnDetailsOrderGroupComponent,
ReturnDetailsOrderGroupDataComponent,
ReturnDetailsDataComponent,
ReturnDetailsOrderGroupItemComponent,
NgIcon,
ExpandableDirectives,
TextButtonComponent,
],
providers: [provideIcons({ isaActionPlus, isaActionMinus })],
})
export class ReturnDetailsStaticComponent {
receipt = input.required<Receipt>();
}

View File

@@ -1,5 +1,3 @@
@let receipt = receiptResult().data;
<div>
<button
uiButton
@@ -14,69 +12,37 @@
</button>
</div>
@if (receipt) {
<div
class="flex flex-col items-start justify-stretch gap-6 rounded-2xl bg-isa-white px-4 py-6"
>
<oms-feature-return-details-header
[buyer]="receipt.buyer"
></oms-feature-return-details-header>
@if (showMore()) {
<oms-feature-return-details-order-group-data
[receipt]="receipt"
></oms-feature-return-details-order-group-data>
<button
class="-ml-3"
uiTextButton
type="button"
color="strong"
size="small"
(click)="showMore.set(false)"
>
<ng-icon name="isaActionMinus"></ng-icon>
Weniger anzeigen
</button>
<div
class="flex flex-col items-start justify-stretch rounded-2xl bg-isa-white px-4 py-6"
>
@if (receiptResource.value(); as r) {
<oms-feature-return-details-static
class="flex flex-col items-start justify-stretch gap-6 px-4 py-6 w-full"
[receipt]="r"
></oms-feature-return-details-static>
@if (customerReceiptsResource.isLoading()) {
<ui-progress-bar class="w-full" mode="indeterminate"></ui-progress-bar>
} @else {
<oms-feature-return-details-data
[receipt]="receipt"
></oms-feature-return-details-data>
<button
class="-ml-3"
uiTextButton
type="button"
color="strong"
size="small"
(click)="showMore.set(true)"
>
<ng-icon name="isaActionPlus"></ng-icon>
Bestelldetails anzeigen
</button>
@for (receipt of customerReceiptsResource.value(); track receipt.id) {
@if (r.id !== receipt.id) {
<oms-feature-return-details-lazy
class="flex flex-col items-start justify-stretch gap-6 px-4 py-6 w-full"
[receipt]="receipt"
></oms-feature-return-details-lazy>
}
}
}
<div></div>
<oms-feature-return-details-order-group
[receipt]="receipt"
[items]="receiptItems()"
[(selectedItems)]="selectedItems"
></oms-feature-return-details-order-group>
@for (item of receipt.items; track item.id; let last = $last) {
<oms-feature-return-details-order-group-item
class="border-b border-solid border-isa-neutral-300 last:border-none"
[item]="item.data"
[receiptId]="itemId()"
(selectedChange)="selectItemById(item.id, $event)"
[selected]="selectedItemIds().includes(item.id)"
></oms-feature-return-details-order-group-item>
}
</div>
}
} @else {
<ui-progress-bar class="w-full" mode="indeterminate"></ui-progress-bar>
}
</div>
<div class="h-20"></div>
<button
class="fixed bottom-6 right-6"
uiButton
color="brand"
size="large"
[disabled]="selectedItems().length === 0"
[disabled]="!canStartProcess()"
(click)="startProcess()"
>
Rückgabe starten

View File

@@ -4,33 +4,28 @@ import {
computed,
effect,
inject,
signal,
untracked,
resource,
} from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { toSignal } from '@angular/core/rxjs-interop';
import { z } from 'zod';
import { NgIconComponent, provideIcons } from '@ng-icons/core';
import {
isaActionPlus,
isaActionMinus,
isaActionChevronLeft,
} from '@isa/icons';
import { ButtonComponent, TextButtonComponent } from '@isa/ui/buttons';
import {
ReceiptItem,
ReturnDetailsService,
ReturnDetailsStore,
ReturnProcessStore,
} from '@isa/oms/data-access';
import { isaActionChevronLeft } from '@isa/icons';
import { ButtonComponent } from '@isa/ui/buttons';
import { injectActivatedProcessId } from '@isa/core/process';
import { ReturnDetailsDataComponent } from './return-details-data/return-details-data.component';
import { ReturnDetailsHeaderComponent } from './return-details-header/return-details-header.component';
import { ReturnDetailsOrderGroupComponent } from './return-details-order-group/return-details-order-group.component';
import { ReturnDetailsOrderGroupItemComponent } from './return-details-order-group-item/return-details-order-group-item.component';
import { ReturnDetailsOrderGroupDataComponent } from './return-details-order-group-data/return-details-order-group-data.component';
import { KeyValue, Location } from '@angular/common';
import { Location } from '@angular/common';
import { ExpandableDirectives } from '@isa/ui/expandable';
import { ProgressBarComponent } from '@isa/ui/progress-bar';
import {
ReturnDetailsService,
ReturnProcessStore,
ReturnDetailsStore,
} from '@isa/oms/data-access';
import { ReturnDetailsStaticComponent } from './return-details-static/return-details-static.component';
import { ReturnDetailsLazyComponent } from './return-details-lazy/return-details-lazy.component';
import { logger } from '@isa/core/logging';
import { groupBy } from 'lodash';
@Component({
selector: 'oms-feature-return-details',
@@ -38,20 +33,26 @@ import { KeyValue, Location } from '@angular/common';
styleUrls: ['./return-details.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
ReturnDetailsStaticComponent,
ReturnDetailsLazyComponent,
NgIconComponent,
TextButtonComponent,
ButtonComponent,
ReturnDetailsHeaderComponent,
ReturnDetailsDataComponent,
ReturnDetailsOrderGroupComponent,
ReturnDetailsOrderGroupItemComponent,
ReturnDetailsOrderGroupDataComponent,
],
providers: [
provideIcons({ isaActionPlus, isaActionMinus, isaActionChevronLeft }),
ExpandableDirectives,
ProgressBarComponent,
],
providers: [provideIcons({ isaActionChevronLeft })],
})
export class ReturnDetailsComponent {
#logger = logger(() => ({
component: ReturnDetailsComponent.name,
itemId: this.receiptId(),
processId: this.processId(),
params: this.params(),
}));
#store = inject(ReturnDetailsStore);
#returnDetailsService = inject(ReturnDetailsService);
#returnProcessStore = inject(ReturnProcessStore);
private processId = injectActivatedProcessId();
private _router = inject(Router);
@@ -62,25 +63,7 @@ export class ReturnDetailsComponent {
params = toSignal(this._activatedRoute.params);
selectedItems = signal<ReceiptItem[]>([]);
selectedItemIds = computed(() => {
return this.selectedItems().map((item) => item.id);
});
showMore = signal(false);
#returnDetailsStore = inject(ReturnDetailsStore);
#returnDetailsService = inject(ReturnDetailsService);
#returnProcessStore = inject(ReturnProcessStore);
// Also strongly type if `availableCategories` is an array of strings
availableCategories = computed<KeyValue<string, string>[]>(() => {
return this.#returnDetailsService.availableCategories() ?? [];
});
itemId = computed<number>(() => {
receiptId = computed<number>(() => {
const params = this.params();
if (params) {
return z.coerce.number().parse(params['receiptId']);
@@ -88,92 +71,74 @@ export class ReturnDetailsComponent {
throw new Error('No receiptId found in route params');
});
receiptResult = computed(() => {
const itemId = this.itemId();
return this.#returnDetailsStore.entityMap()[itemId];
});
// Effect resets the Store's state when the receiptId changes
// This ensures that the store is always in sync with the current receiptId
receiptIdEffect = effect(() => this.#store.selectStorage(this.receiptId()));
receiptItems = computed<ReceiptItem[]>(() => {
const receiptResult = this.receiptResult();
if (!receiptResult) {
return [];
}
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return receiptResult.data?.items?.map((i) => i.data!) || [];
});
receiptResource = this.#store.receiptResource(this.receiptId);
constructor() {
effect(() => {
const itemId = this.itemId();
if (itemId) {
untracked(() => this.#returnDetailsStore.fetch({ receiptId: itemId }));
customerReceiptsResource = resource({
request: this.receiptResource.value,
loader: async ({ request, abortSignal }) => {
console.log('Fetching customer receipts for:', request);
const email = request?.buyer?.communicationDetails?.email;
if (!email) {
return [];
}
});
}
return await this.#returnDetailsService.fetchReceiptsByEmail(
{ email },
abortSignal,
);
},
});
changeProductCategory({
item,
category,
}: {
item: ReceiptItem;
category: string;
}) {
this.#returnDetailsStore.updateProductCategoryForItem({
receiptId: this.itemId(),
itemId: item.id,
category,
});
}
changeProductQuantity({
item,
quantity,
}: {
item: ReceiptItem;
quantity: number;
}) {
this.#returnDetailsStore.updateProductQuantityForItem({
receiptId: this.itemId(),
itemId: item.id,
quantity,
});
}
selectItemById(id: number, selected: boolean) {
const items = this.receiptItems();
const item = items.find((i) => i.id === id);
if (!item) {
return;
}
if (selected) {
this.selectedItems.update((items) => [...items, item]);
} else {
this.selectedItems.update((items) => items.filter((i) => i.id !== id));
}
}
canStartProcess = computed(() => {
return (
this.#store.selectedItemIds().length > 0 && this.processId() !== undefined
);
});
startProcess() {
if (!this.canStartProcess()) {
this.#logger.warn(
'Cannot start process: No items selected or no process ID',
);
return;
}
const processId = this.processId();
const selectedItems = this.selectedItems();
const selectedItems = this.#store.selectedItems();
this.#logger.info('Starting return process', () => ({
processId: processId,
selectedItems: selectedItems.map((item) => item.id),
}));
if (!selectedItems.length || !processId) {
return;
}
const receipt = this.receiptResult().data;
if (!receipt) {
return;
}
const itemsGrouptByReceiptId = groupBy(
selectedItems,
(item) => item.receipt?.id,
);
const receipts = this.#store.receiptsEntityMap();
const items = this.selectedItems();
const returns = Object.entries(itemsGrouptByReceiptId).map(
([receiptId, items]) => ({
receipt: receipts[Number(receiptId)],
items,
}),
);
if (items.length === 0) {
return;
}
this.#logger.info('Starting return process with returns', () => ({
processId,
returns,
}));
this.#returnProcessStore.startProcess({
processId,
receipt,
items,
returns,
});
this._router.navigate(['../../', 'process'], {

View File

@@ -0,0 +1,180 @@
# UI Expandable Directives
A set of Angular directives for creating expandable/collapsible content sections with proper accessibility support.
## Features
- Signal-based expanded/collapsed state management
- Simple template-based conditional rendering
- Supports two-way binding
- Built-in accessibility support (ARIA attributes)
- Modern Angular implementation using directives, signals, effects, and DI
## Installation
This library is part of the ISA-Frontend project and is already available in the workspace.
## Usage
### Basic Usage
```typescript
import { Component } from '@angular/core';
import {
ExpandableDirective,
ExpandedDirective,
CollapsedDirective,
ExpandableTriggerDirective
} from '@isa/ui/expandable';
@Component({
selector: 'app-my-component',
standalone: true,
imports: [
ExpandableDirective,
ExpandedDirective,
CollapsedDirective,
ExpandableTriggerDirective
],
template: `
<div uiExpandable>
<button
uiExpandableTrigger="my-section"
#trigger="uiExpandableTrigger"
uiTextButton
type="button"
color="strong"
size="small"
>
<ng-icon [name]="trigger.expanded() ? 'isaActionMinus' : 'isaActionPlus'"></ng-icon>
{{ trigger.expanded() ? 'Less details' : 'More details' }}
</button>
<div [id]="'my-section'">
<ng-container *uiCollapsed>
<!-- Content shown when collapsed -->
<p>Summary information</p>
</ng-container>
<ng-container *uiExpanded>
<!-- Content shown when expanded -->
<p>Detailed information</p>
</ng-container>
</div>
</div>
`
})
export class MyComponent {}
```
### With Two-Way Binding
```typescript
import { Component, signal } from '@angular/core';
import { ExpandableDirectives } from '@isa/ui/expandable';
@Component({
selector: 'app-my-component',
standalone: true,
imports: [...ExpandableDirectives],
template: `
<div uiExpandable [(uiExpandable)]="isExpanded">
<button
uiExpandableTrigger="details-section"
#trigger="uiExpandableTrigger"
uiTextButton
>
Toggle Details
</button>
<div id="details-section">
<ng-container *uiExpanded>
Expanded content
</ng-container>
<ng-container *uiCollapsed>
Collapsed content
</ng-container>
</div>
</div>
<p>The section is currently: {{ isExpanded() ? 'Expanded' : 'Collapsed' }}</p>
<button (click)="isExpanded.set(true)">Expand from outside</button>
`
})
export class MyComponent {
isExpanded = signal(false);
}
```
## API Reference
### ExpandableDirective
The main container directive that manages expanded/collapsed state.
**Selector:** `[uiExpandable]`
**Inputs:**
- `[(uiExpandable)]`: Two-way binding for the expanded state.
**Methods:**
- `toggle()`: Toggles between expanded and collapsed states.
### ExpandedDirective
Structural directive that shows content only when expanded.
**Selector:** `[uiExpanded]`
### CollapsedDirective
Structural directive that shows content only when collapsed.
**Selector:** `[uiCollapsed]`
### ExpandableTriggerDirective
Adds toggle functionality and accessibility attributes to an element.
**Selector:** `[uiExpandableTrigger]`
**Inputs:**
- `uiExpandableTrigger`: String ID of the element being controlled (used for aria-controls).
**Methods:**
- `toggle()`: Toggles the parent expandable's state.
- `expanded()`: Returns the current expanded state (signal).
**Exported as:** `uiExpandableTrigger`
## Accessibility
These directives automatically add the following accessibility features:
- `role="button"` on the trigger element
- `aria-expanded` with the current state on the trigger element
- `aria-controls` linking the trigger to the content section (requires matching the ID)
## Example: Real-world Usage in Return Details Component
```html
<div uiExpandable>
<label class="-ml-3" uiTextButton type="button" color="strong" size="small" uiExpandableTrigger="return-details" #trigger="uiExpandableTrigger">
<ng-icon [name]="trigger.expanded() ? 'isaActionMinus' : 'isaActionPlus'"></ng-icon>
{{ trigger.expanded() ? 'Weniger anzeigen' : 'Bestelldetails anzeigen' }}
</label>
<div id="return-details">
<ng-container *uiExpanded>
<oms-feature-return-details-order-group-data [receipt]="receipt"></oms-feature-return-details-order-group-data>
</ng-container>
<ng-container *uiCollapsed>
<oms-feature-return-details-data [receipt]="receipt"></oms-feature-return-details-data>
</ng-container>
</div>
</div>
```
## Running unit tests
Run `nx test ui-expandable` to execute the unit tests.

View File

@@ -0,0 +1,34 @@
import nx from '@nx/eslint-plugin';
import baseConfig from '../../../eslint.config.mjs';
export default [
...baseConfig,
...nx.configs['flat/angular'],
...nx.configs['flat/angular-template'],
{
files: ['**/*.ts'],
rules: {
'@angular-eslint/directive-selector': [
'error',
{
type: 'attribute',
prefix: 'ui',
style: 'camelCase',
},
],
'@angular-eslint/component-selector': [
'error',
{
type: 'element',
prefix: 'ui',
style: 'kebab-case',
},
],
},
},
{
files: ['**/*.html'],
// Override or add rules here
rules: {},
},
];

View File

@@ -0,0 +1,21 @@
export default {
displayName: 'ui-expandable',
preset: '../../../jest.preset.js',
setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'],
coverageDirectory: '../../../coverage/libs/ui/expandable',
transform: {
'^.+\\.(ts|mjs|js|html)$': [
'jest-preset-angular',
{
tsconfig: '<rootDir>/tsconfig.spec.json',
stringifyContentPathRegex: '\\.(html|svg)$',
},
],
},
transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'],
snapshotSerializers: [
'jest-preset-angular/build/serializers/no-ng-attributes',
'jest-preset-angular/build/serializers/ng-snapshot',
'jest-preset-angular/build/serializers/html-comment',
],
};

View File

@@ -0,0 +1,20 @@
{
"name": "ui-expandable",
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/ui/expandable/src",
"prefix": "ui",
"projectType": "library",
"tags": [],
"targets": {
"test": {
"executor": "@nx/jest:jest",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
"options": {
"jestConfig": "libs/ui/expandable/jest.config.ts"
}
},
"lint": {
"executor": "@nx/eslint:lint"
}
}
}

View File

@@ -0,0 +1,5 @@
export * from './lib/expandable.directive';
export * from './lib/expanded.directive';
export * from './lib/collapsed.directive';
export * from './lib/expandable-trigger.directive';
export * from './lib/directives';

View File

@@ -0,0 +1,59 @@
import {
Directive,
effect,
inject,
TemplateRef,
ViewContainerRef,
} from '@angular/core';
import { ExpandableDirective } from './expandable.directive';
/**
* Structural directive that conditionally renders content when the
* parent ExpandableDirective is in the collapsed state.
*
* This directive must be used within an element that has the uiExpandable
* directive applied. It shows its content only when the parent expandable
* is in the collapsed state.
*
* @example
* ```html
* <div uiExpandable>
* <ng-container *uiCollapsed>
* Content shown when collapsed
* </ng-container>
* </div>
* ```
*/
@Directive({ selector: '[uiCollapsed]' })
export class CollapsedDirective {
/**
* Reference to the parent ExpandableDirective that controls state
* @private
*/
#expandable = inject(ExpandableDirective, { host: true });
/**
* Template to be rendered
* @private
*/
#templateRef = inject(TemplateRef<unknown>);
/**
* Container for rendering the view
* @private
*/
#viewContainer = inject(ViewContainerRef);
/**
* Effect that reacts to changes in the expanded state.
* Creates or clears the view based on the expandable state.
* @private
*/
render = effect(() => {
if (this.#expandable.expanded()) {
this.#viewContainer.clear();
} else {
this.#viewContainer.createEmbeddedView(this.#templateRef);
}
});
}

View File

@@ -0,0 +1,22 @@
import { ExpandableDirective } from './expandable.directive';
import { ExpandedDirective } from './expanded.directive';
import { CollapsedDirective } from './collapsed.directive';
import { ExpandableTriggerDirective } from './expandable-trigger.directive';
/**
* Convenience array containing all the directives required for the Expandable system.
*
* Can be used to import all directives at once in a component or module:
* ```typescript
* @Component({
* imports: [...ExpandableDirectives]
* })
* ```
*/
export const ExpandableDirectives = [
ExpandableDirective,
ExpandedDirective,
CollapsedDirective,
ExpandableTriggerDirective,
];

View File

@@ -0,0 +1,52 @@
import { Directive, inject, input } from '@angular/core';
import { ExpandableDirective } from './expandable.directive';
/**
* Directive that turns an element into a toggle trigger for an ExpandableDirective.
*
* This directive must be used within an element that has the uiExpandable
* directive applied. It adds click handling and proper accessibility attributes
* to control the expanded/collapsed state.
*
* @example
* ```html
* <div uiExpandable>
* <button uiExpandableTrigger="section-id" #trigger="uiExpandableTrigger">
* {{ trigger.expanded() ? 'Hide' : 'Show' }} Content
* </button>
* </div>
* ```
*/
@Directive({
selector: '[uiExpandableTrigger]',
exportAs: 'uiExpandableTrigger',
host: {
'(click)': 'toggle()',
'[attr.aria-expanded]': 'expanded()',
'role': 'button',
},
})
export class ExpandableTriggerDirective {
/**
* Reference to the parent ExpandableDirective that controls state
* @private
*/
#expandable = inject(ExpandableDirective, { host: true });
/**
* Toggles the expanded/collapsed state of the parent expandable section.
*/
toggle() {
this.#expandable.toggle();
}
/**
* Returns the current expanded state as a signal.
* Useful for conditionally showing content in the trigger based on state.
*
* @returns A signal with the current expanded state (boolean)
*/
expanded() {
return this.#expandable.expanded();
}
}

View File

@@ -0,0 +1,40 @@
import { Directive, model } from '@angular/core';
/**
* Core directive that manages the expanded/collapsed state of a section.
*
* This directive serves as the foundation for the expandable system, providing state management
* and a toggle function. It uses Angular's model input API for two-way binding.
*
* @example
* ```html
* <div uiExpandable [(uiExpanded)]="isExpanded">
* <!-- Content here -->
* </div>
* ```
*/
@Directive({ selector: '[uiExpandable]', exportAs: 'uiExpandable' })
export class ExpandableDirective {
/**
* Signal model that tracks and manages the expanded state.
* Can be bound to with [(uiExpanded)] for two-way binding.
*
* @default false - Initially collapsed
*/
expanded = model(false, { alias: 'uiExpanded' });
/**
* Toggles the expanded/collapsed state.
*/
toggle() {
this.expanded.update((expanded) => !expanded);
}
expand() {
this.expanded.set(true);
}
collapse() {
this.expanded.set(false);
}
}

View File

@@ -0,0 +1,59 @@
import {
Directive,
effect,
inject,
TemplateRef,
ViewContainerRef,
} from '@angular/core';
import { ExpandableDirective } from './expandable.directive';
/**
* Structural directive that conditionally renders content when the
* parent ExpandableDirective is in the expanded state.
*
* This directive must be used within an element that has the uiExpandable
* directive applied. It shows its content only when the parent expandable
* is in the expanded state.
*
* @example
* ```html
* <div uiExpandable>
* <ng-container *uiExpanded>
* Content shown when expanded
* </ng-container>
* </div>
* ```
*/
@Directive({ selector: '[uiExpanded]' })
export class ExpandedDirective {
/**
* Reference to the parent ExpandableDirective that controls state
* @private
*/
#expandable = inject(ExpandableDirective, { host: true });
/**
* Template to be rendered
* @private
*/
#templateRef = inject(TemplateRef<unknown>);
/**
* Container for rendering the view
* @private
*/
#viewContainer = inject(ViewContainerRef);
/**
* Effect that reacts to changes in the expanded state.
* Creates or clears the view based on the expandable state.
* @private
*/
render = effect(() => {
if (!this.#expandable.expanded()) {
this.#viewContainer.clear();
} else {
this.#viewContainer.createEmbeddedView(this.#templateRef);
}
});
}

View File

@@ -0,0 +1,6 @@
import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone';
setupZoneTestEnv({
errorOnUnknownElements: true,
errorOnUnknownProperties: true,
});

View File

@@ -0,0 +1,28 @@
{
"compilerOptions": {
"target": "es2022",
"forceConsistentCasingInFileNames": true,
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
},
{
"path": "./tsconfig.spec.json"
}
],
"extends": "../../../tsconfig.base.json",
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
}
}

View File

@@ -0,0 +1,17 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../dist/out-tsc",
"declaration": true,
"declarationMap": true,
"inlineSources": true,
"types": []
},
"exclude": [
"src/**/*.spec.ts",
"src/test-setup.ts",
"jest.config.ts",
"src/**/*.test.ts"
],
"include": ["src/**/*.ts"]
}

View File

@@ -0,0 +1,16 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../dist/out-tsc",
"module": "commonjs",
"target": "es2016",
"types": ["jest", "node"]
},
"files": ["src/test-setup.ts"],
"include": [
"jest.config.ts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.d.ts"
]
}

View File

@@ -76,6 +76,7 @@
"@isa/ui/datepicker": ["libs/ui/datepicker/src/index.ts"],
"@isa/ui/dialog": ["libs/ui/dialog/src/index.ts"],
"@isa/ui/empty-state": ["libs/ui/empty-state/src/index.ts"],
"@isa/ui/expandable": ["libs/ui/expandable/src/index.ts"],
"@isa/ui/input-controls": ["libs/ui/input-controls/src/index.ts"],
"@isa/ui/item-rows": ["libs/ui/item-rows/src/index.ts"],
"@isa/ui/layout": ["libs/ui/layout/src/index.ts"],