mirror of
https://dev.azure.com/hugendubel/ISA/_git/ISA-Frontend
synced 2025-12-28 22:42:11 +01:00
Merged PR 1851: Retoure // Mehrere Belege in der Retouren-Detailansicht anzeigen
Related work items: #5002, #5148
This commit is contained in:
committed by
Nino Righi
parent
dd598d100c
commit
3eb6981e3a
2
.github/instructions/nx.instructions.md
vendored
2
.github/instructions/nx.instructions.md
vendored
@@ -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
|
||||
|
||||
|
||||
|
||||
|
||||
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@@ -30,6 +30,9 @@
|
||||
}
|
||||
],
|
||||
"github.copilot.chat.codeGeneration.instructions": [
|
||||
{
|
||||
"file": ".vscode/llms/angular.txt"
|
||||
},
|
||||
{
|
||||
"file": "docs/tech-stack.md"
|
||||
},
|
||||
|
||||
@@ -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: {},
|
||||
};
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from './lib/errors';
|
||||
export * from './lib/models';
|
||||
export * from './lib/operators';
|
||||
|
||||
1
libs/common/data-access/src/lib/operators/index.ts
Normal file
1
libs/common/data-access/src/lib/operators/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './take-until-aborted';
|
||||
@@ -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$));
|
||||
};
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -6,4 +6,5 @@ export interface ReceiptItem extends ReceiptItemDTO {
|
||||
id: number;
|
||||
product: Product;
|
||||
quantity: Quantity;
|
||||
receiptNumber: string;
|
||||
}
|
||||
|
||||
@@ -2,4 +2,5 @@ import { ReceiptListItemDTO } from '@generated/swagger/oms-api';
|
||||
|
||||
export interface ReceiptListItem extends ReceiptListItemDTO {
|
||||
id: number;
|
||||
receiptNumber: string;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
import { ShippingAddressDTO2 } from '@generated/swagger/oms-api';
|
||||
|
||||
export type ShippingAddress2 = ShippingAddressDTO2;
|
||||
1
libs/oms/data-access/src/lib/operators/index.ts
Normal file
1
libs/oms/data-access/src/lib/operators/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './take-until-aborted';
|
||||
50
libs/oms/data-access/src/lib/operators/take-until-aborted.ts
Normal file
50
libs/oms/data-access/src/lib/operators/take-until-aborted.ts
Normal 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$));
|
||||
};
|
||||
@@ -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',
|
||||
|
||||
@@ -26,4 +26,5 @@ export const CategoryQuestions: Record<
|
||||
[ProductCategory.SonstigesNonbook]: nonbookQuestions,
|
||||
[ProductCategory.ElektronischeGeraete]: elektronischeGeraeteQuestions,
|
||||
[ProductCategory.Tolino]: tolinoQuestions,
|
||||
[ProductCategory.Unknown]: [],
|
||||
};
|
||||
|
||||
@@ -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)}`);
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
})),
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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(' ');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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'"
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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 ?? '';
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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>();
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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'], {
|
||||
|
||||
180
libs/ui/expandable/README.md
Normal file
180
libs/ui/expandable/README.md
Normal 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.
|
||||
34
libs/ui/expandable/eslint.config.mjs
Normal file
34
libs/ui/expandable/eslint.config.mjs
Normal 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: {},
|
||||
},
|
||||
];
|
||||
21
libs/ui/expandable/jest.config.ts
Normal file
21
libs/ui/expandable/jest.config.ts
Normal 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',
|
||||
],
|
||||
};
|
||||
20
libs/ui/expandable/project.json
Normal file
20
libs/ui/expandable/project.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
5
libs/ui/expandable/src/index.ts
Normal file
5
libs/ui/expandable/src/index.ts
Normal 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';
|
||||
59
libs/ui/expandable/src/lib/collapsed.directive.ts
Normal file
59
libs/ui/expandable/src/lib/collapsed.directive.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
22
libs/ui/expandable/src/lib/directives.ts
Normal file
22
libs/ui/expandable/src/lib/directives.ts
Normal 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,
|
||||
];
|
||||
|
||||
52
libs/ui/expandable/src/lib/expandable-trigger.directive.ts
Normal file
52
libs/ui/expandable/src/lib/expandable-trigger.directive.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
40
libs/ui/expandable/src/lib/expandable.directive.ts
Normal file
40
libs/ui/expandable/src/lib/expandable.directive.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
59
libs/ui/expandable/src/lib/expanded.directive.ts
Normal file
59
libs/ui/expandable/src/lib/expanded.directive.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
6
libs/ui/expandable/src/test-setup.ts
Normal file
6
libs/ui/expandable/src/test-setup.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone';
|
||||
|
||||
setupZoneTestEnv({
|
||||
errorOnUnknownElements: true,
|
||||
errorOnUnknownProperties: true,
|
||||
});
|
||||
28
libs/ui/expandable/tsconfig.json
Normal file
28
libs/ui/expandable/tsconfig.json
Normal 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
|
||||
}
|
||||
}
|
||||
17
libs/ui/expandable/tsconfig.lib.json
Normal file
17
libs/ui/expandable/tsconfig.lib.json
Normal 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"]
|
||||
}
|
||||
16
libs/ui/expandable/tsconfig.spec.json
Normal file
16
libs/ui/expandable/tsconfig.spec.json
Normal 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"
|
||||
]
|
||||
}
|
||||
@@ -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"],
|
||||
|
||||
Reference in New Issue
Block a user