mirror of
https://dev.azure.com/hugendubel/ISA/_git/ISA-Frontend
synced 2025-12-28 22:42:11 +01:00
- feat(remission-shared-produt-shelf-meta-info): Intermediate commit. - feat(remission-shared-product-shelf-meta-info): improve template structure and data attributes - feat(remission-list-item): add product shelf meta info and improve E2E selectors Refs: #4769, #5196
This commit is contained in:
committed by
Lorenz Hilpert
parent
a36d746fb8
commit
5f74c6ddf8
8
.github/copilot-instructions.md
vendored
8
.github/copilot-instructions.md
vendored
@@ -4,6 +4,14 @@
|
||||
|
||||
You are Mentor, an AI assistant focused on ensuring code quality, strict adherence to best practices, and development efficiency. **Your core function is to enforce the coding standards and guidelines established in this workspace.** Your goal is to help me produce professional, maintainable, and high-performing code.
|
||||
|
||||
**Always reference the latest official documentation for Angular, Nx, or any related technology when answering questions or providing feedback. Use the canonical Context7 documentation URL pattern for the relevant technology and version as the primary source:**
|
||||
|
||||
- `https://context7.com/[git-repository]/[version]/llms.txt?topic=[documentation-topic]`
|
||||
|
||||
_Example for Angular 20:_
|
||||
|
||||
- [Angular 20 Context7 Documentation](https://context7.com/angular/angular/20.0.0/llms.txt?topic=test+inputsignal)
|
||||
|
||||
## Tone and Personality
|
||||
|
||||
Maintain a professional, objective, and direct tone consistently:
|
||||
|
||||
39
.github/testing-instructions.md
vendored
39
.github/testing-instructions.md
vendored
@@ -2,8 +2,10 @@
|
||||
|
||||
## Framework and Tools
|
||||
|
||||
- Use **Jest** as the testing framework.
|
||||
- For unit tests, utilize **Spectator** to simplify Angular component testing.
|
||||
- **Vitest** is the recommended testing framework.
|
||||
[Vitest Documentation (latest)](https://context7.com/vitest-dev/vitest/llms.txt?topic=getting+started)
|
||||
- **Jest** and **Spectator** are **deprecated**.
|
||||
Do not use them for new tests. Existing tests should be migrated to Vitest where possible.
|
||||
|
||||
## Guidelines
|
||||
|
||||
@@ -23,28 +25,31 @@
|
||||
## Example Test Structure
|
||||
|
||||
```typescript
|
||||
// Example using Jest and Spectator
|
||||
import { createComponentFactory, Spectator } from '@ngneat/spectator';
|
||||
// Example using Vitest (Jest and Spectator are deprecated)
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { render } from '@testing-library/angular';
|
||||
import { MyComponent } from './my-component.component';
|
||||
|
||||
describe('MyComponent', () => {
|
||||
let spectator: Spectator<MyComponent>;
|
||||
const createComponent = createComponentFactory(MyComponent);
|
||||
let component: MyComponent;
|
||||
|
||||
beforeEach(() => {
|
||||
spectator = createComponent();
|
||||
beforeEach(async () => {
|
||||
const { fixture } = await render(MyComponent);
|
||||
component = fixture.componentInstance;
|
||||
});
|
||||
|
||||
it('should display the correct title', () => {
|
||||
it('should display the correct title', async () => {
|
||||
// Arrange
|
||||
const expectedTitle = 'Hello World';
|
||||
|
||||
// Act
|
||||
spectator.component.title = expectedTitle;
|
||||
spectator.detectChanges();
|
||||
component.title = expectedTitle;
|
||||
// If using Angular, trigger change detection:
|
||||
// fixture.detectChanges();
|
||||
|
||||
// Assert
|
||||
expect(spectator.query('h1')).toHaveText(expectedTitle);
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
expect(compiled.querySelector('h1')?.textContent).toBe(expectedTitle);
|
||||
});
|
||||
|
||||
it('should handle error cases gracefully', () => {
|
||||
@@ -52,15 +57,17 @@ describe('MyComponent', () => {
|
||||
const invalidInput = null;
|
||||
|
||||
// Act
|
||||
spectator.component.input = invalidInput;
|
||||
component.input = invalidInput;
|
||||
|
||||
// Assert
|
||||
expect(() => spectator.component.processInput()).toThrowError('Invalid input');
|
||||
expect(() => component.processInput()).toThrowError('Invalid input');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Additional Resources
|
||||
|
||||
- [Jest Documentation](https://jestjs.io/docs/getting-started)
|
||||
- [Spectator Documentation](https://ngneat.github.io/spectator/)
|
||||
- [Vitest Documentation (latest)](https://context7.com/vitest-dev/vitest/llms.txt?topic=getting+started)
|
||||
- [Vitest Official Guide](https://vitest.dev/guide/)
|
||||
- [Testing Library for Angular](https://testing-library.com/docs/angular-testing-library/intro/)
|
||||
- **Jest** and **Spectator** documentation are deprecated
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { type Meta, type StoryObj, argsToTemplate } from '@storybook/angular';
|
||||
import { ProductShelfMetaInfoComponent } from '@isa/remission/shared/product';
|
||||
|
||||
const meta: Meta<ProductShelfMetaInfoComponent> = {
|
||||
component: ProductShelfMetaInfoComponent,
|
||||
title: 'remission/shared/product/ProductShelfMetaInfoComponent',
|
||||
args: {
|
||||
department: 'Reise',
|
||||
shelfLabel: 'Europa',
|
||||
productGroupKey: '311',
|
||||
productGroupValue: 'Romane TB',
|
||||
assortment: 'Basissortiment|BPrämienartikel|n',
|
||||
returnReason: 'Beschädigt',
|
||||
},
|
||||
argTypes: {
|
||||
department: {
|
||||
control: { type: 'text' },
|
||||
description: 'The department of the product.',
|
||||
defaultValue: undefined,
|
||||
},
|
||||
shelfLabel: {
|
||||
control: { type: 'text' },
|
||||
description: 'The shelf label of the product.',
|
||||
defaultValue: undefined,
|
||||
},
|
||||
productGroupKey: {
|
||||
control: { type: 'text' },
|
||||
description: 'The key of the product group.',
|
||||
defaultValue: undefined,
|
||||
},
|
||||
productGroupValue: {
|
||||
control: { type: 'text' },
|
||||
description: 'The value of the product group.',
|
||||
defaultValue: undefined,
|
||||
},
|
||||
assortment: {
|
||||
control: { type: 'text' },
|
||||
description: 'The assortment of the product.',
|
||||
defaultValue: undefined,
|
||||
},
|
||||
returnReason: {
|
||||
control: { type: 'text' },
|
||||
description: 'The reason for the return of the product.',
|
||||
defaultValue: undefined,
|
||||
},
|
||||
},
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: `<remi-product-shelf-meta-info ${argsToTemplate(args)}></remi-product-shelf-meta-info>`,
|
||||
}),
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<ProductShelfMetaInfoComponent>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
department: 'Reise',
|
||||
shelfLabel: 'Europa',
|
||||
productGroupKey: '311',
|
||||
productGroupValue: 'Romane TB',
|
||||
assortment: 'Basissortiment|BPrämienartikel|n',
|
||||
returnReason: 'Beschädigt',
|
||||
},
|
||||
};
|
||||
@@ -114,7 +114,7 @@ export const isaFiliale =
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="currentColor"> <path fill-rule="evenodd" clip-rule="evenodd" d="M11.4772 1.33637C11.8195 1.24369 12.1803 1.24369 12.5226 1.33637C12.92 1.44396 13.2545 1.70662 13.5215 1.91625C13.547 1.93627 13.5719 1.9558 13.5961 1.97466L20.3784 7.24979C20.4046 7.27009 20.4305 7.2902 20.4562 7.31015C20.8328 7.60242 21.1646 7.85993 21.4119 8.19422C21.6288 8.48761 21.7905 8.81812 21.8889 9.16952C22.0009 9.56993 22.0005 9.98994 22 10.4667C21.9999 10.4992 21.9999 10.532 21.9999 10.5651V17.8386C21.9999 18.3657 21.9999 18.8205 21.9693 19.195C21.937 19.5904 21.8657 19.9836 21.6729 20.362C21.3853 20.9265 20.9264 21.3854 20.3619 21.673C19.9835 21.8658 19.5903 21.9371 19.1949 21.9694C18.8204 22 18.3656 22 17.8385 22H6.16133C5.63419 22 5.17943 22 4.80487 21.9694C4.40952 21.9371 4.0163 21.8658 3.63792 21.673C3.07344 21.3854 2.6145 20.9265 2.32688 20.362C2.13408 19.9836 2.06277 19.5904 2.03046 19.195C1.99986 18.8205 1.99988 18.3657 1.99989 17.8385L1.9999 10.5651C1.9999 10.532 1.99986 10.4992 1.99982 10.4667C1.99931 9.98994 1.99886 9.56993 2.11094 9.16952C2.2093 8.81811 2.37094 8.48761 2.58794 8.19422C2.83519 7.85992 3.16701 7.60242 3.54364 7.31013C3.56934 7.29019 3.59524 7.27009 3.62134 7.24979L10.4037 1.97466C10.4279 1.9558 10.4528 1.93626 10.4783 1.91625C10.7453 1.70662 11.0798 1.44396 11.4772 1.33637ZM9.9999 20H13.9999V13.6C13.9999 13.3035 13.9991 13.1412 13.9896 13.0246C13.9892 13.02 13.9888 13.0156 13.9884 13.0114C13.9843 13.0111 13.9799 13.0107 13.9753 13.0103C13.8587 13.0008 13.6964 13 13.3999 13H10.5999C10.3034 13 10.1411 13.0008 10.0245 13.0103C10.0199 13.0107 10.0155 13.0111 10.0113 13.0114C10.011 13.0156 10.0106 13.02 10.0102 13.0246C10.0007 13.1412 9.9999 13.3035 9.9999 13.6V20ZM15.9999 20L15.9999 13.5681C15.9999 13.3157 16 13.0699 15.983 12.8618C15.9643 12.6332 15.9202 12.3634 15.7819 12.092C15.5902 11.7157 15.2842 11.4097 14.9079 11.218C14.6365 11.0797 14.3667 11.0356 14.1381 11.0169C13.93 10.9999 13.6842 11 13.4318 11H10.568C10.3156 11 10.0698 10.9999 9.86167 11.0169C9.63307 11.0356 9.36334 11.0797 9.09191 11.218C8.71559 11.4097 8.40963 11.7157 8.21788 12.092C8.07959 12.3634 8.03552 12.6332 8.01684 12.8618C7.99983 13.0699 7.99986 13.3157 7.99989 13.5681L7.9999 20H6.1999C5.62334 20 5.25107 19.9992 4.96773 19.9761C4.69607 19.9539 4.59535 19.9162 4.5459 19.891C4.35774 19.7951 4.20476 19.6422 4.10889 19.454C4.0837 19.4045 4.04602 19.3038 4.02382 19.0322C4.00067 18.7488 3.9999 18.3766 3.9999 17.8V10.5651C3.9999 9.93408 4.00858 9.80982 4.03691 9.70862C4.0697 9.59148 4.12358 9.48131 4.19591 9.38352C4.2584 9.29903 4.35115 9.21588 4.84923 8.82849L11.6315 3.55337C11.8184 3.40799 11.9174 3.33175 11.9926 3.28154C11.9951 3.27984 11.9976 3.27823 11.9999 3.27671C12.0022 3.27823 12.0046 3.27984 12.0072 3.28154C12.0823 3.33175 12.1814 3.40799 12.3683 3.55337L19.1506 8.82849C19.6486 9.21588 19.7414 9.29903 19.8039 9.38352C19.8762 9.48131 19.9301 9.59148 19.9629 9.70862C19.9912 9.80982 19.9999 9.93408 19.9999 10.5651V17.8C19.9999 18.3766 19.9991 18.7488 19.976 19.0322C19.9538 19.3038 19.9161 19.4045 19.8909 19.454C19.795 19.6422 19.642 19.7951 19.4539 19.891C19.4044 19.9162 19.3037 19.9539 19.0321 19.9761C18.7487 19.9992 18.3764 20 17.7999 20H15.9999Z" fill="currentColor"/></svg>';
|
||||
|
||||
export const isaFilialeLocation =
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="currentColor"> <path d="M12 13C13.6569 13 15 11.6569 15 10C15 8.34315 13.6569 7 12 7C10.3431 7 9 8.34315 9 10C9 11.6569 10.3431 13 12 13Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> <path d="M12 22C16 18 20 14.4183 20 10C20 5.58172 16.4183 2 12 2C7.58172 2 4 5.58172 4 10C4 14.4183 8 18 12 22Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>';
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"><path d="M12 13C13.6569 13 15 11.6569 15 10C15 8.34315 13.6569 7 12 7C10.3431 7 9 8.34315 9 10C9 11.6569 10.3431 13 12 13Z" stroke="black" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><path d="M12 22C16 18 20 14.4183 20 10C20 5.58172 16.4183 2 12 2C7.58172 2 4 5.58172 4 10C4 14.4183 8 18 12 22Z" stroke="black" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>';
|
||||
|
||||
export const isaArtikelKartoniert = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M7.24787 2.5C7.25947 2.5 7.2711 2.5 7.28276 2.5L7.60047 2.5C8.34332 2.49999 8.95985 2.49998 9.46281 2.54033C9.98596 2.5823 10.4728 2.67271 10.9325 2.90269C11.3354 3.10427 11.6965 3.37467 12.0002 3.6995C12.3038 3.37467 12.665 3.10427 13.0679 2.90269C13.5275 2.67271 14.0144 2.5823 14.5375 2.54033C15.0405 2.49998 15.657 2.49999 16.3999 2.5L16.7525 2.5C17.111 2.49998 17.437 2.49996 17.7088 2.52177C18.0006 2.54518 18.3162 2.59847 18.6274 2.75419C19.0751 2.9782 19.4425 3.3374 19.6739 3.78334C19.8357 4.09512 19.891 4.41165 19.9152 4.70214C19.9376 4.97133 19.9376 5.29341 19.9375 5.64431L19.9375 15.0162C19.9375 15.3671 19.9375 15.6892 19.9151 15.9584C19.8909 16.2489 19.8356 16.5654 19.6739 16.8772C19.4425 17.3231 19.0751 17.6823 18.6273 17.9063C18.3161 18.062 18.0006 18.1153 17.7088 18.1387C17.4369 18.1605 17.111 18.1605 16.7524 18.1605L15.7637 18.1605C14.8337 18.1605 14.5735 18.1704 14.3521 18.2364C14.127 18.3035 13.9187 18.4132 13.7388 18.5585C13.5634 18.7 13.4135 18.9026 12.8969 19.6636L12.8274 19.7659C12.6413 20.04 12.3315 20.2042 12.0001 20.2042C11.6687 20.2042 11.3589 20.04 11.1728 19.7659L11.1033 19.6636C10.5867 18.9026 10.4368 18.7 10.2615 18.5585C10.0815 18.4132 9.87318 18.3035 9.64811 18.2364C9.42671 18.1704 9.1665 18.1605 8.23647 18.1605L7.24783 18.1605C6.88925 18.1605 6.56329 18.1605 6.29144 18.1387C5.99966 18.1153 5.68411 18.062 5.37287 17.9063C4.92515 17.6823 4.55774 17.3231 4.32635 16.8772C4.16457 16.5654 4.10926 16.2489 4.08509 15.9584C4.0627 15.6892 4.06272 15.3671 4.06275 15.0162L4.06281 5.67991C4.06281 5.67991 4.06281 5.67991 4.06281 5.67991C4.06281 5.66801 4.06281 5.65612 4.06281 5.64428C4.06279 5.29339 4.06276 4.97133 4.08516 4.70215C4.10933 4.41166 4.16464 4.09512 4.32642 3.78334C4.55781 3.33739 4.92522 2.9782 5.37293 2.75419C5.68418 2.59847 5.99972 2.54518 6.2915 2.52177C6.56335 2.49996 6.8893 2.49998 7.24787 2.5ZM6.26449 4.54428C6.26445 4.54427 6.26506 4.54398 6.26646 4.54347L6.26449 4.54428ZM6.26646 4.54347C6.27658 4.53983 6.32436 4.52556 6.45145 4.51536C6.63354 4.50075 6.87803 4.5 7.28276 4.5H7.56026C8.35352 4.5 8.88941 4.50075 9.30286 4.53392C9.70517 4.5662 9.90362 4.62429 10.0376 4.69131C10.3731 4.85916 10.6424 5.12525 10.8101 5.44839C10.8752 5.57377 10.9333 5.76196 10.9658 6.15304C10.9994 6.55634 11.0002 7.07998 11.0002 7.85982L11.0001 16.6511C10.7538 16.5122 10.492 16.401 10.2197 16.3198C9.68245 16.1596 9.10813 16.16 8.36282 16.1604C8.32125 16.1605 8.27913 16.1605 8.23647 16.1605H7.2827C6.87797 16.1605 6.63348 16.1598 6.45139 16.1451C6.3243 16.1349 6.2767 16.1207 6.26659 16.1171C6.19397 16.0805 6.13794 16.0243 6.10332 15.9593C6.0995 15.9476 6.08735 15.9024 6.07821 15.7925C6.06355 15.6164 6.06275 15.3789 6.06275 14.9806L6.06281 5.67992C6.06281 5.2816 6.06362 5.04409 6.07827 4.86798C6.08742 4.75806 6.09956 4.71289 6.10339 4.7012C6.13801 4.63617 6.19384 4.58011 6.26646 4.54347ZM13.0001 16.6511C13.2464 16.5122 13.5082 16.401 13.7805 16.3198C14.3178 16.1596 14.8921 16.16 15.6374 16.1604C15.679 16.1605 15.7211 16.1605 15.7637 16.1605H16.7175C17.1222 16.1605 17.3667 16.1598 17.5488 16.1451C17.6759 16.1349 17.7235 16.1207 17.7336 16.1171C17.8062 16.0805 17.8623 16.0243 17.8969 15.9593C17.9007 15.9476 17.9129 15.9024 17.922 15.7925C17.9367 15.6164 17.9375 15.3789 17.9375 14.9806L17.9375 5.67991C17.9375 5.28159 17.9367 5.04409 17.9221 4.86798C17.9129 4.75807 17.9008 4.7129 17.8969 4.7012C17.8623 4.63617 17.8063 4.58004 17.7337 4.5434C17.7236 4.53976 17.676 4.52556 17.5489 4.51536C17.3668 4.50075 17.1223 4.5 16.7176 4.5H16.4401C15.6468 4.5 15.1109 4.50075 14.6975 4.53392C14.2952 4.5662 14.0967 4.62429 13.9628 4.69131C13.6273 4.85916 13.3579 5.12525 13.1902 5.44839C13.1252 5.57377 13.0671 5.76196 13.0345 6.15304C13.001 6.55634 13.0002 7.07998 13.0002 7.85983L13.0001 16.6511ZM17.7358 16.1162C17.7358 16.1162 17.735 16.1166 17.7336 16.1171L17.7358 16.1162Z" fill="#212529"/>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
export * from './lib/services';
|
||||
export * from './lib/schemas';
|
||||
export * from './lib/models';
|
||||
export * from './lib/services';
|
||||
export * from './lib/models';
|
||||
export * from './lib/stores';
|
||||
export * from './lib/schemas';
|
||||
export * from './lib/helpers';
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { calculateAvailableStock } from './calc-available-stock.helper';
|
||||
|
||||
describe('calculateAvailableStock', () => {
|
||||
it('should return stock when removedFromStock is undefined', () => {
|
||||
// Arrange
|
||||
const input = { stock: 10 };
|
||||
|
||||
// Act
|
||||
const result = calculateAvailableStock(input);
|
||||
|
||||
// Assert
|
||||
expect(result).toBe(10);
|
||||
});
|
||||
|
||||
it('should return 0 when stock is undefined and removedFromStock is undefined', () => {
|
||||
// Arrange
|
||||
const input = { stock: undefined };
|
||||
|
||||
// Act
|
||||
const result = calculateAvailableStock(input);
|
||||
|
||||
// Assert
|
||||
expect(result).toBe(0);
|
||||
});
|
||||
|
||||
it('should subtract removedFromStock from stock', () => {
|
||||
// Arrange
|
||||
const input = { stock: 20, removedFromStock: 5 };
|
||||
|
||||
// Act
|
||||
const result = calculateAvailableStock(input);
|
||||
|
||||
// Assert
|
||||
expect(result).toBe(15);
|
||||
});
|
||||
|
||||
it('should return 0 if result is negative', () => {
|
||||
// Arrange
|
||||
const input = { stock: 3, removedFromStock: 5 };
|
||||
|
||||
// Act
|
||||
const result = calculateAvailableStock(input);
|
||||
|
||||
// Assert
|
||||
expect(result).toBe(0);
|
||||
});
|
||||
|
||||
it('should treat undefined stock as 0', () => {
|
||||
// Arrange
|
||||
const input = { stock: undefined, removedFromStock: 2 };
|
||||
|
||||
// Act
|
||||
const result = calculateAvailableStock(input);
|
||||
|
||||
// Assert
|
||||
expect(result).toBe(0);
|
||||
});
|
||||
|
||||
it('should treat undefined removedFromStock as 0', () => {
|
||||
// Arrange
|
||||
const input = { stock: 7, removedFromStock: undefined };
|
||||
|
||||
// Act
|
||||
const result = calculateAvailableStock(input);
|
||||
|
||||
// Assert
|
||||
expect(result).toBe(7);
|
||||
});
|
||||
|
||||
it('should return 0 if both stock and removedFromStock are undefined', () => {
|
||||
// Arrange
|
||||
const input = { stock: undefined, removedFromStock: undefined };
|
||||
|
||||
// Act
|
||||
const result = calculateAvailableStock(input);
|
||||
|
||||
// Assert
|
||||
expect(result).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Current available stock.
|
||||
* Calculation: stock - removedFromStock
|
||||
* Returns 0 if result is negative.
|
||||
*
|
||||
* @remarks
|
||||
* Used as the base for further stock calculations.
|
||||
*/
|
||||
export const calculateAvailableStock = ({
|
||||
stock,
|
||||
removedFromStock,
|
||||
}: {
|
||||
stock: number | undefined;
|
||||
removedFromStock?: number;
|
||||
}): number => {
|
||||
const availableStock = (stock ?? 0) - (removedFromStock ?? 0);
|
||||
return availableStock < 0 ? 0 : availableStock;
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { calculateStockToRemit } from './calc-stock-to-remit.helper';
|
||||
|
||||
describe('calculateStockToRemit', () => {
|
||||
it('should return predefinedReturnQuantity if set', () => {
|
||||
const input = {
|
||||
availableStock: 10,
|
||||
predefinedReturnQuantity: 5,
|
||||
remainingQuantityInStock: 2,
|
||||
};
|
||||
const result = calculateStockToRemit(input);
|
||||
expect(result).toBe(5);
|
||||
});
|
||||
|
||||
it('should calculate as availableStock - remainingQuantityInStock if predefinedReturnQuantity is not set', () => {
|
||||
const input = {
|
||||
availableStock: 10,
|
||||
remainingQuantityInStock: 3,
|
||||
};
|
||||
const result = calculateStockToRemit(input);
|
||||
expect(result).toBe(7);
|
||||
});
|
||||
|
||||
it('should return 0 if result is negative', () => {
|
||||
const input = {
|
||||
availableStock: 2,
|
||||
remainingQuantityInStock: 5,
|
||||
};
|
||||
const result = calculateStockToRemit(input);
|
||||
expect(result).toBe(0);
|
||||
});
|
||||
|
||||
it('should treat undefined remainingQuantityInStock as 0', () => {
|
||||
const input = {
|
||||
availableStock: 8,
|
||||
};
|
||||
const result = calculateStockToRemit(input);
|
||||
expect(result).toBe(8);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Quantity to remit (remission quantity).
|
||||
*
|
||||
* - If `predefinedReturnQuantity` is set (non-zero), returns that value.
|
||||
* - Otherwise, calculates as `availableStock - remainingQuantityInStock`.
|
||||
* - Returns 0 if the result is negative.
|
||||
*
|
||||
* @remarks
|
||||
* This value is used as an input for `targetStock`.
|
||||
*/
|
||||
export const calculateStockToRemit = ({
|
||||
availableStock,
|
||||
predefinedReturnQuantity,
|
||||
remainingQuantityInStock,
|
||||
}: {
|
||||
availableStock: number;
|
||||
predefinedReturnQuantity?: number;
|
||||
remainingQuantityInStock?: number;
|
||||
}): number => {
|
||||
if (!predefinedReturnQuantity) {
|
||||
const stockToRemit = availableStock - (remainingQuantityInStock ?? 0);
|
||||
return stockToRemit < 0 ? 0 : stockToRemit;
|
||||
}
|
||||
|
||||
return predefinedReturnQuantity;
|
||||
};
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { calculateTargetStock } from './calc-target-stock.helper';
|
||||
|
||||
describe('calculateTargetStock', () => {
|
||||
it('should return remainingQuantityInStock if set', () => {
|
||||
// Arrange
|
||||
const input = {
|
||||
availableStock: 10,
|
||||
stockToRemit: 3,
|
||||
remainingQuantityInStock: 5,
|
||||
};
|
||||
|
||||
// Act
|
||||
const result = calculateTargetStock(input);
|
||||
|
||||
// Assert
|
||||
expect(result).toBe(5);
|
||||
});
|
||||
|
||||
it('should calculate as availableStock - stockToRemit if remainingQuantityInStock is not set', () => {
|
||||
// Arrange
|
||||
const input = {
|
||||
availableStock: 10,
|
||||
stockToRemit: 4,
|
||||
};
|
||||
|
||||
// Act
|
||||
const result = calculateTargetStock(input);
|
||||
|
||||
// Assert
|
||||
expect(result).toBe(6);
|
||||
});
|
||||
|
||||
it('should return 0 if result is negative', () => {
|
||||
// Arrange
|
||||
const input = {
|
||||
availableStock: 2,
|
||||
stockToRemit: 5,
|
||||
};
|
||||
|
||||
// Act
|
||||
const result = calculateTargetStock(input);
|
||||
|
||||
// Assert
|
||||
expect(result).toBe(0);
|
||||
});
|
||||
|
||||
it('should treat undefined stockToRemit as 0', () => {
|
||||
// Arrange
|
||||
const input = {
|
||||
availableStock: 7,
|
||||
};
|
||||
|
||||
// Act
|
||||
const result = calculateTargetStock(input);
|
||||
|
||||
// Assert
|
||||
expect(result).toBe(7);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Target stock after remission.
|
||||
*
|
||||
* - If `remainingQuantityInStock` is set (non-zero), returns that value.
|
||||
* - Otherwise, calculates as `availableStock - stockToRemit`.
|
||||
* - Returns 0 if the result is negative.
|
||||
*
|
||||
* @remarks
|
||||
* Depends on `stockToRemit` for calculation.
|
||||
* Represents the expected stock after the remission process.
|
||||
*/
|
||||
export const calculateTargetStock = ({
|
||||
availableStock,
|
||||
stockToRemit,
|
||||
remainingQuantityInStock,
|
||||
}: {
|
||||
availableStock: number;
|
||||
stockToRemit?: number;
|
||||
remainingQuantityInStock?: number;
|
||||
}): number => {
|
||||
if (!remainingQuantityInStock) {
|
||||
const targetStock = availableStock - (stockToRemit ?? 0);
|
||||
return targetStock < 0 ? 0 : targetStock;
|
||||
}
|
||||
|
||||
return remainingQuantityInStock;
|
||||
};
|
||||
3
libs/remission/data-access/src/lib/helpers/index.ts
Normal file
3
libs/remission/data-access/src/lib/helpers/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './calc-available-stock.helper';
|
||||
export * from './calc-stock-to-remit.helper';
|
||||
export * from './calc-target-stock.helper';
|
||||
@@ -5,4 +5,5 @@ import { Price } from './price';
|
||||
export interface ReturnItem extends ReturnItemDTO {
|
||||
product: Product;
|
||||
retailPrice: Price;
|
||||
quantity: number;
|
||||
}
|
||||
|
||||
@@ -5,4 +5,5 @@ import { Price } from './price';
|
||||
export interface ReturnSuggestion extends ReturnSuggestionDTO {
|
||||
product: Product;
|
||||
retailPrice: Price;
|
||||
quantity: number;
|
||||
}
|
||||
|
||||
@@ -1,94 +1,89 @@
|
||||
import { inject, Injectable } from '@angular/core';
|
||||
import { RemiService } from '@generated/swagger/inventory-api';
|
||||
import { KeyValueStringAndString } from '../models';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { logger } from '@isa/core/logging';
|
||||
import { RemissionStockService } from './remission-stock.service';
|
||||
import { injectStorage, MemoryStorageProvider } from '@isa/core/storage';
|
||||
import {
|
||||
DataAccessError,
|
||||
ResponseArgsError,
|
||||
takeUntilAborted,
|
||||
} from '@isa/common/data-access';
|
||||
import { InFlightWithCache } from '@isa/common/decorators';
|
||||
|
||||
/**
|
||||
* Service responsible for managing remission product groups.
|
||||
* Handles fetching product group data from the remission API.
|
||||
*
|
||||
* @class RemissionProductGroupService
|
||||
* @injectable
|
||||
*
|
||||
* @example
|
||||
* // Inject the service
|
||||
* constructor(private productGroupService: RemissionProductGroupService) {}
|
||||
*
|
||||
* // Fetch product groups for a stock
|
||||
* const groups = await this.productGroupService.fetchProductGroups({
|
||||
* assignedStockId: 'stock123'
|
||||
* });
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class RemissionProductGroupService {
|
||||
#remiService = inject(RemiService);
|
||||
#stockService = inject(RemissionStockService);
|
||||
#logger = logger(() => ({ service: 'RemissionProductGroupService' }));
|
||||
|
||||
/**
|
||||
* Fetches all available product groups for the specified stock.
|
||||
* Validates input parameters using FetchProductGroupsSchema.
|
||||
*
|
||||
* @async
|
||||
* @param {FetchProductGroups} params - Parameters for the product groups query
|
||||
* @param {string} params.assignedStockId - ID of the assigned stock
|
||||
* @returns {Promise<KeyValueStringAndString[]>} Array of key-value pairs representing product groups
|
||||
* @throws {Error} When the API request fails or returns an error
|
||||
* @throws {z.ZodError} When parameter validation fails
|
||||
*
|
||||
* @example
|
||||
* try {
|
||||
* const productGroups = await service.fetchProductGroups({
|
||||
* assignedStockId: 'stock123'
|
||||
* });
|
||||
* productGroups.forEach(group => {
|
||||
* console.log(`${group.key}: ${group.value}`);
|
||||
* });
|
||||
* } catch (error) {
|
||||
* console.error('Failed to fetch product groups:', error);
|
||||
* }
|
||||
*/
|
||||
@InFlightWithCache()
|
||||
async fetchProductGroups(
|
||||
abortSignal?: AbortSignal,
|
||||
): Promise<KeyValueStringAndString[]> {
|
||||
this.#logger.debug('Fetching product groups');
|
||||
|
||||
const assignedStock = await this.#stockService.fetchAssignedStock();
|
||||
|
||||
this.#logger.info('Fetching product groups from API', () => ({
|
||||
stockId: assignedStock.id,
|
||||
}));
|
||||
|
||||
let req$ = this.#remiService.RemiProductgroups({
|
||||
stockId: assignedStock.id,
|
||||
});
|
||||
|
||||
if (abortSignal) {
|
||||
req$ = req$.pipe(takeUntilAborted(abortSignal));
|
||||
}
|
||||
|
||||
const res = await firstValueFrom(req$);
|
||||
|
||||
if (res.error || !res.result) {
|
||||
const error = new ResponseArgsError(res);
|
||||
this.#logger.error('Failed to fetch product groups', error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
this.#logger.debug('Successfully fetched product groups', () => ({
|
||||
groupCount: res.result?.length || 0,
|
||||
}));
|
||||
|
||||
return res.result as KeyValueStringAndString[];
|
||||
}
|
||||
}
|
||||
import { inject, Injectable } from '@angular/core';
|
||||
import { RemiService } from '@generated/swagger/inventory-api';
|
||||
import { KeyValueStringAndString } from '../models';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { logger } from '@isa/core/logging';
|
||||
import { RemissionStockService } from './remission-stock.service';
|
||||
import { ResponseArgsError, takeUntilAborted } from '@isa/common/data-access';
|
||||
import { InFlightWithCache } from '@isa/common/decorators';
|
||||
|
||||
/**
|
||||
* Service responsible for managing remission product groups.
|
||||
* Handles fetching product group data from the remission API.
|
||||
*
|
||||
* @class RemissionProductGroupService
|
||||
* @injectable
|
||||
*
|
||||
* @example
|
||||
* // Inject the service
|
||||
* constructor(private productGroupService: RemissionProductGroupService) {}
|
||||
*
|
||||
* // Fetch product groups for a stock
|
||||
* const groups = await this.productGroupService.fetchProductGroups({
|
||||
* assignedStockId: 'stock123'
|
||||
* });
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class RemissionProductGroupService {
|
||||
#remiService = inject(RemiService);
|
||||
#stockService = inject(RemissionStockService);
|
||||
#logger = logger(() => ({ service: 'RemissionProductGroupService' }));
|
||||
|
||||
/**
|
||||
* Fetches all available product groups for the specified stock.
|
||||
* Validates input parameters using FetchProductGroupsSchema.
|
||||
*
|
||||
* @async
|
||||
* @param {FetchProductGroups} params - Parameters for the product groups query
|
||||
* @param {string} params.assignedStockId - ID of the assigned stock
|
||||
* @returns {Promise<KeyValueStringAndString[]>} Array of key-value pairs representing product groups
|
||||
* @throws {Error} When the API request fails or returns an error
|
||||
* @throws {z.ZodError} When parameter validation fails
|
||||
*
|
||||
* @example
|
||||
* try {
|
||||
* const productGroups = await service.fetchProductGroups({
|
||||
* assignedStockId: 'stock123'
|
||||
* });
|
||||
* productGroups.forEach(group => {
|
||||
* console.log(`${group.key}: ${group.value}`);
|
||||
* });
|
||||
* } catch (error) {
|
||||
* console.error('Failed to fetch product groups:', error);
|
||||
* }
|
||||
*/
|
||||
@InFlightWithCache()
|
||||
async fetchProductGroups(
|
||||
abortSignal?: AbortSignal,
|
||||
): Promise<KeyValueStringAndString[]> {
|
||||
this.#logger.debug('Fetching product groups');
|
||||
|
||||
const assignedStock = await this.#stockService.fetchAssignedStock();
|
||||
|
||||
this.#logger.info('Fetching product groups from API', () => ({
|
||||
stockId: assignedStock.id,
|
||||
}));
|
||||
|
||||
let req$ = this.#remiService.RemiProductgroups({
|
||||
stockId: assignedStock.id,
|
||||
});
|
||||
|
||||
if (abortSignal) {
|
||||
req$ = req$.pipe(takeUntilAborted(abortSignal));
|
||||
}
|
||||
|
||||
const res = await firstValueFrom(req$);
|
||||
|
||||
if (res.error || !res.result) {
|
||||
const error = new ResponseArgsError(res);
|
||||
this.#logger.error('Failed to fetch product groups', error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
this.#logger.debug('Successfully fetched product groups', () => ({
|
||||
groupCount: res.result?.length || 0,
|
||||
}));
|
||||
|
||||
return res.result as KeyValueStringAndString[];
|
||||
}
|
||||
}
|
||||
|
||||
1
libs/remission/data-access/src/lib/stores/index.ts
Normal file
1
libs/remission/data-access/src/lib/stores/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './remission.store';
|
||||
@@ -0,0 +1,52 @@
|
||||
import { RemissionSelectionStore } from './remission.store';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
|
||||
describe('RemissionSelectionStore', () => {
|
||||
let store: InstanceType<typeof RemissionSelectionStore>;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [RemissionSelectionStore],
|
||||
});
|
||||
store = TestBed.inject(RemissionSelectionStore);
|
||||
});
|
||||
|
||||
it('should create an instance of RemissionSelectionStore', () => {
|
||||
expect(store).toBeTruthy();
|
||||
});
|
||||
|
||||
describe('initial state', () => {
|
||||
it('should have correct initial state', () => {
|
||||
// Assert
|
||||
expect(store.returnId()).toBeUndefined();
|
||||
expect(store.receiptId()).toBeUndefined();
|
||||
expect(store.selectedItems()).toEqual({});
|
||||
expect(store.selectedQuantity()).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('startRemission', () => {
|
||||
it('should set returnId and receiptId when called for the first time', () => {
|
||||
// Arrange
|
||||
const returnId = 123;
|
||||
const receiptId = 456;
|
||||
|
||||
// Act
|
||||
store.startRemission(returnId, receiptId);
|
||||
|
||||
// Assert
|
||||
expect(store.returnId()).toBe(returnId);
|
||||
expect(store.receiptId()).toBe(receiptId);
|
||||
});
|
||||
|
||||
it('should throw an error if returnId or receiptId is already set', () => {
|
||||
// Arrange
|
||||
store.startRemission(123, 456);
|
||||
|
||||
// Act & Assert
|
||||
expect(() => store.startRemission(789, 101)).toThrowError(
|
||||
'Remission has already been started. returnId and receiptId can only be set once.',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
162
libs/remission/data-access/src/lib/stores/remission.store.ts
Normal file
162
libs/remission/data-access/src/lib/stores/remission.store.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import { patchState, signalStore, withMethods, withState } from '@ngrx/signals';
|
||||
import { ReturnItem, ReturnSuggestion } from '../models';
|
||||
|
||||
/**
|
||||
* Union type representing items that can be selected for remission.
|
||||
* Can be either a ReturnItem or a ReturnSuggestion.
|
||||
*/
|
||||
type RemissionItem = ReturnItem | ReturnSuggestion;
|
||||
|
||||
/**
|
||||
* Interface defining the state structure for the remission selection store.
|
||||
*/
|
||||
interface RemissionState {
|
||||
/** The unique identifier for the return process. Can only be set once. */
|
||||
returnId: number | undefined;
|
||||
/** The unique identifier for the receipt. Can only be set once. */
|
||||
receiptId: number | undefined;
|
||||
/** Map of selected remission items indexed by their ID */
|
||||
selectedItems: Record<number, RemissionItem>;
|
||||
/** Map of selected quantities for each remission item indexed by their ID */
|
||||
selectedQuantity: Record<number, number>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initial state for the remission selection store.
|
||||
* All values are undefined or empty objects.
|
||||
*/
|
||||
const initialState: RemissionState = {
|
||||
returnId: undefined,
|
||||
receiptId: undefined,
|
||||
selectedItems: {},
|
||||
selectedQuantity: {},
|
||||
};
|
||||
|
||||
/**
|
||||
* NgRx Signal Store for managing remission selection state.
|
||||
* Provides methods to start remission processes, select items, update quantities,
|
||||
* and manage the overall selection state.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Inject the store in a component
|
||||
* readonly remissionStore = inject(RemissionSelectionStore);
|
||||
*
|
||||
* // Start a remission process
|
||||
* this.remissionStore.startRemission(123, 456);
|
||||
*
|
||||
* // Select an item
|
||||
* this.remissionStore.selectRemissionItem(1, returnItem);
|
||||
*
|
||||
* // Update quantity
|
||||
* this.remissionStore.updateRemissionQuantity(1, returnItem, 5);
|
||||
* ```
|
||||
*/
|
||||
export const RemissionSelectionStore = signalStore(
|
||||
{ providedIn: 'root' },
|
||||
withState(initialState),
|
||||
withMethods((store) => ({
|
||||
/**
|
||||
* Initializes a remission process with the given return and receipt IDs.
|
||||
* Can only be called once - subsequent calls will throw an error.
|
||||
*
|
||||
* @param returnId - The unique identifier for the return process
|
||||
* @param receiptId - The unique identifier for the receipt
|
||||
* @throws {Error} When remission has already been started (returnId or receiptId already set)
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* remissionStore.startRemission(123, 456);
|
||||
* ```
|
||||
*/
|
||||
startRemission(returnId: number, receiptId: number) {
|
||||
if (store.returnId() !== undefined || store.receiptId() !== undefined) {
|
||||
throw new Error(
|
||||
'Remission has already been started. returnId and receiptId can only be set once.',
|
||||
);
|
||||
}
|
||||
patchState(store, { returnId, receiptId });
|
||||
},
|
||||
|
||||
/**
|
||||
* Selects a remission item and adds it to the selected items collection.
|
||||
* If the item is already selected, it will be replaced with the new item.
|
||||
*
|
||||
* @param remissionItemId - The unique identifier for the remission item
|
||||
* @param item - The remission item to select (ReturnItem or ReturnSuggestion)
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const returnItem: ReturnItem = { id: 1, name: 'Product A' };
|
||||
* remissionStore.selectRemissionItem(1, returnItem);
|
||||
* ```
|
||||
*/
|
||||
selectRemissionItem(remissionItemId: number, item: RemissionItem) {
|
||||
patchState(store, {
|
||||
selectedItems: { ...store.selectedItems(), [remissionItemId]: item },
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Updates the quantity for a selected remission item.
|
||||
* Also ensures the item is in the selected items collection.
|
||||
*
|
||||
* @param remissionItemId - The unique identifier for the remission item
|
||||
* @param item - The remission item to update (ReturnItem or ReturnSuggestion)
|
||||
* @param quantity - The new quantity value
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const returnItem: ReturnItem = { id: 1, name: 'Product A' };
|
||||
* remissionStore.updateRemissionQuantity(1, returnItem, 5);
|
||||
* ```
|
||||
*/
|
||||
updateRemissionQuantity(
|
||||
remissionItemId: number,
|
||||
item: RemissionItem,
|
||||
quantity: number,
|
||||
) {
|
||||
patchState(store, {
|
||||
selectedItems: { ...store.selectedItems(), [remissionItemId]: item },
|
||||
selectedQuantity: {
|
||||
...store.selectedQuantity(),
|
||||
[remissionItemId]: quantity,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Removes a remission item from both the selected items and quantities collections.
|
||||
*
|
||||
* @param remissionItemId - The unique identifier for the remission item to remove
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* remissionStore.removeItem(1);
|
||||
* ```
|
||||
*/
|
||||
removeItem(remissionItemId: number) {
|
||||
const items = { ...store.selectedItems() };
|
||||
const quantities = { ...store.selectedQuantity() };
|
||||
delete items[remissionItemId];
|
||||
delete quantities[remissionItemId];
|
||||
patchState(store, {
|
||||
selectedItems: items,
|
||||
selectedQuantity: quantities,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Clears all selection data and resets the store to its initial state.
|
||||
* This includes clearing returnId, receiptId, selected items, and quantities.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* remissionStore.clearSelection();
|
||||
* ```
|
||||
*/
|
||||
clearSelection() {
|
||||
patchState(store, initialState);
|
||||
},
|
||||
})),
|
||||
);
|
||||
@@ -1,20 +1,47 @@
|
||||
@let i = item();
|
||||
@let s = stock();
|
||||
<ui-client-row data-what="remission-list-item" [attr.data-which]="i.id">
|
||||
<ui-client-row-content>
|
||||
<remi-product-info
|
||||
[item]="i"
|
||||
[orientation]="'vertical'"
|
||||
[orientation]="remiProductInfoOrientation()"
|
||||
></remi-product-info>
|
||||
</ui-client-row-content>
|
||||
<ui-item-row-data> Shelfinfos... (TODO) </ui-item-row-data>
|
||||
<ui-item-row-data>
|
||||
<remi-product-shelf-meta-info
|
||||
[department]="i?.department"
|
||||
[shelfLabel]="i?.shelfLabel"
|
||||
[productGroupKey]="i?.product?.productGroup"
|
||||
[productGroupValue]="productGroupValue()"
|
||||
[assortment]="i?.assortment"
|
||||
[returnReason]="i?.returnReason"
|
||||
></remi-product-shelf-meta-info>
|
||||
</ui-item-row-data>
|
||||
<ui-item-row-data>
|
||||
<remi-product-stock-info
|
||||
[predefinedReturnQuantity]="predefinedReturnQuantity()"
|
||||
[remainingQuantityInStock]="i?.remainingQuantityInStock ?? 0"
|
||||
[stock]="s?.inStock ?? 0"
|
||||
[removedFromStock]="s?.removedFromStock ?? 0"
|
||||
[zob]="s?.minStockCategoryManagement ?? 0"
|
||||
[availableStock]="availableStock()"
|
||||
[stockToRemit]="stockToRemit()"
|
||||
[targetStock]="targetStock()"
|
||||
[zob]="stock()?.minStockCategoryManagement ?? 0"
|
||||
></remi-product-stock-info>
|
||||
</ui-item-row-data>
|
||||
|
||||
@if (!!predefinedReturnQuantity()) {
|
||||
<ui-item-row-data class="justify-end col-end-last">
|
||||
<button
|
||||
class="self-end"
|
||||
type="button"
|
||||
uiTextButton
|
||||
color="strong"
|
||||
(click)="
|
||||
openRemissionQuantityDialog();
|
||||
$event.stopPropagation();
|
||||
$event.preventDefault()
|
||||
"
|
||||
data-what="button"
|
||||
data-which="change-remission-quantity"
|
||||
>
|
||||
Remi Menge ändern
|
||||
</button>
|
||||
</ui-item-row-data>
|
||||
}
|
||||
</ui-client-row>
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
.ui-client-row {
|
||||
@apply isa-desktop-l:grid-cols-4;
|
||||
}
|
||||
|
||||
.col-end-last {
|
||||
grid-column-end: -1;
|
||||
}
|
||||
|
||||
@@ -1,35 +1,59 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { RemissionListItemComponent } from './remission-list-item.component';
|
||||
import { ReturnItem, ReturnSuggestion, StockInfo } from '@isa/remission/data-access';
|
||||
import { ProductInfoComponent, ProductStockInfoComponent } from '@isa/remission/shared/product';
|
||||
import {
|
||||
ReturnItem,
|
||||
ReturnSuggestion,
|
||||
StockInfo,
|
||||
RemissionListType,
|
||||
} from '@isa/remission/data-access';
|
||||
import {
|
||||
ProductInfoComponent,
|
||||
ProductStockInfoComponent,
|
||||
} from '@isa/remission/shared/product';
|
||||
import { MockComponent } from 'ng-mocks';
|
||||
|
||||
// --- Setup dynamic mocking for injectRemissionListType ---
|
||||
let remissionListTypeValue: RemissionListType = RemissionListType.Pflicht;
|
||||
jest.mock('../injects/inject-remission-list-type', () => ({
|
||||
injectRemissionListType: () => () => remissionListTypeValue,
|
||||
}));
|
||||
|
||||
describe('RemissionListItemComponent', () => {
|
||||
let component: RemissionListItemComponent;
|
||||
let fixture: ComponentFixture<RemissionListItemComponent>;
|
||||
const setRemissionListType = (type: RemissionListType) => {
|
||||
remissionListTypeValue = type;
|
||||
};
|
||||
|
||||
const createMockReturnItem = (overrides: Partial<ReturnItem> = {}): ReturnItem => ({
|
||||
id: 1,
|
||||
predefinedReturnQuantity: 5,
|
||||
...overrides,
|
||||
} as ReturnItem);
|
||||
const createMockReturnItem = (
|
||||
overrides: Partial<ReturnItem> = {},
|
||||
): ReturnItem =>
|
||||
({
|
||||
id: 1,
|
||||
predefinedReturnQuantity: 5,
|
||||
...overrides,
|
||||
}) as ReturnItem;
|
||||
|
||||
const createMockReturnSuggestion = (overrides: Partial<ReturnSuggestion> = {}): ReturnSuggestion => ({
|
||||
id: 1,
|
||||
returnItem: {
|
||||
data: {
|
||||
id: 1,
|
||||
predefinedReturnQuantity: 10,
|
||||
const createMockReturnSuggestion = (
|
||||
overrides: Partial<ReturnSuggestion> = {},
|
||||
): ReturnSuggestion =>
|
||||
({
|
||||
id: 1,
|
||||
returnItem: {
|
||||
data: {
|
||||
id: 1,
|
||||
predefinedReturnQuantity: 10,
|
||||
},
|
||||
},
|
||||
},
|
||||
...overrides,
|
||||
} as ReturnSuggestion);
|
||||
...overrides,
|
||||
}) as ReturnSuggestion;
|
||||
|
||||
const createMockStockInfo = (overrides: Partial<StockInfo> = {}): StockInfo => ({
|
||||
id: 1,
|
||||
quantity: 100,
|
||||
...overrides,
|
||||
} as StockInfo);
|
||||
const createMockStockInfo = (overrides: Partial<StockInfo> = {}): StockInfo =>
|
||||
({
|
||||
id: 1,
|
||||
quantity: 100,
|
||||
...overrides,
|
||||
}) as StockInfo;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
@@ -68,6 +92,8 @@ describe('RemissionListItemComponent', () => {
|
||||
|
||||
describe('predefinedReturnQuantity computed signal', () => {
|
||||
describe('with ReturnItem', () => {
|
||||
beforeEach(() => setRemissionListType(RemissionListType.Pflicht));
|
||||
|
||||
it('should return predefinedReturnQuantity when available', () => {
|
||||
const mockItem = createMockReturnItem({ predefinedReturnQuantity: 15 });
|
||||
fixture.componentRef.setInput('item', mockItem);
|
||||
@@ -78,7 +104,9 @@ describe('RemissionListItemComponent', () => {
|
||||
});
|
||||
|
||||
it('should return 0 when predefinedReturnQuantity is null', () => {
|
||||
const mockItem = createMockReturnItem({ predefinedReturnQuantity: null as any });
|
||||
const mockItem = createMockReturnItem({
|
||||
predefinedReturnQuantity: null as any,
|
||||
});
|
||||
fixture.componentRef.setInput('item', mockItem);
|
||||
fixture.componentRef.setInput('stock', createMockStockInfo());
|
||||
fixture.detectChanges();
|
||||
@@ -87,7 +115,9 @@ describe('RemissionListItemComponent', () => {
|
||||
});
|
||||
|
||||
it('should return 0 when predefinedReturnQuantity is undefined', () => {
|
||||
const mockItem = createMockReturnItem({ predefinedReturnQuantity: undefined });
|
||||
const mockItem = createMockReturnItem({
|
||||
predefinedReturnQuantity: undefined,
|
||||
});
|
||||
fixture.componentRef.setInput('item', mockItem);
|
||||
fixture.componentRef.setInput('stock', createMockStockInfo());
|
||||
fixture.detectChanges();
|
||||
@@ -106,6 +136,8 @@ describe('RemissionListItemComponent', () => {
|
||||
});
|
||||
|
||||
describe('with ReturnSuggestion', () => {
|
||||
beforeEach(() => setRemissionListType(RemissionListType.Abteilung));
|
||||
|
||||
it('should return predefinedReturnQuantity from returnItem.data when available', () => {
|
||||
const mockSuggestion = createMockReturnSuggestion({
|
||||
returnItem: {
|
||||
@@ -181,6 +213,7 @@ describe('RemissionListItemComponent', () => {
|
||||
|
||||
describe('Type detection', () => {
|
||||
it('should correctly identify ReturnSuggestion type', () => {
|
||||
setRemissionListType(RemissionListType.Abteilung);
|
||||
const mockSuggestion = createMockReturnSuggestion();
|
||||
fixture.componentRef.setInput('item', mockSuggestion);
|
||||
fixture.componentRef.setInput('stock', createMockStockInfo());
|
||||
@@ -192,6 +225,7 @@ describe('RemissionListItemComponent', () => {
|
||||
});
|
||||
|
||||
it('should correctly identify ReturnItem type', () => {
|
||||
setRemissionListType(RemissionListType.Pflicht);
|
||||
const mockItem = createMockReturnItem();
|
||||
fixture.componentRef.setInput('item', mockItem);
|
||||
fixture.componentRef.setInput('stock', createMockStockInfo());
|
||||
@@ -206,6 +240,7 @@ describe('RemissionListItemComponent', () => {
|
||||
|
||||
describe('Component reactivity', () => {
|
||||
it('should update predefinedReturnQuantity when input changes from ReturnItem to ReturnSuggestion', () => {
|
||||
setRemissionListType(RemissionListType.Pflicht);
|
||||
const mockItem = createMockReturnItem({ predefinedReturnQuantity: 5 });
|
||||
fixture.componentRef.setInput('item', mockItem);
|
||||
fixture.componentRef.setInput('stock', createMockStockInfo());
|
||||
@@ -213,6 +248,7 @@ describe('RemissionListItemComponent', () => {
|
||||
|
||||
expect(component.predefinedReturnQuantity()).toBe(5);
|
||||
|
||||
setRemissionListType(RemissionListType.Abteilung);
|
||||
const mockSuggestion = createMockReturnSuggestion({
|
||||
returnItem: {
|
||||
data: {
|
||||
@@ -228,6 +264,7 @@ describe('RemissionListItemComponent', () => {
|
||||
});
|
||||
|
||||
it('should update predefinedReturnQuantity when input changes from ReturnSuggestion to ReturnItem', () => {
|
||||
setRemissionListType(RemissionListType.Abteilung);
|
||||
const mockSuggestion = createMockReturnSuggestion({
|
||||
returnItem: {
|
||||
data: {
|
||||
@@ -242,6 +279,7 @@ describe('RemissionListItemComponent', () => {
|
||||
|
||||
expect(component.predefinedReturnQuantity()).toBe(30);
|
||||
|
||||
setRemissionListType(RemissionListType.Pflicht);
|
||||
const mockItem = createMockReturnItem({ predefinedReturnQuantity: 8 });
|
||||
fixture.componentRef.setInput('item', mockItem);
|
||||
fixture.detectChanges();
|
||||
@@ -251,6 +289,8 @@ describe('RemissionListItemComponent', () => {
|
||||
});
|
||||
|
||||
describe('Edge cases', () => {
|
||||
beforeEach(() => setRemissionListType(RemissionListType.Pflicht));
|
||||
|
||||
it('should handle negative predefinedReturnQuantity values', () => {
|
||||
const mockItem = createMockReturnItem({ predefinedReturnQuantity: -5 });
|
||||
fixture.componentRef.setInput('item', mockItem);
|
||||
@@ -261,7 +301,9 @@ describe('RemissionListItemComponent', () => {
|
||||
});
|
||||
|
||||
it('should handle very large predefinedReturnQuantity values', () => {
|
||||
const mockItem = createMockReturnItem({ predefinedReturnQuantity: 999999 });
|
||||
const mockItem = createMockReturnItem({
|
||||
predefinedReturnQuantity: 999999,
|
||||
});
|
||||
fixture.componentRef.setInput('item', mockItem);
|
||||
fixture.componentRef.setInput('stock', createMockStockInfo());
|
||||
fixture.detectChanges();
|
||||
@@ -279,6 +321,7 @@ describe('RemissionListItemComponent', () => {
|
||||
});
|
||||
|
||||
it('should handle deeply nested null values in ReturnSuggestion', () => {
|
||||
setRemissionListType(RemissionListType.Abteilung);
|
||||
const mockSuggestion = {
|
||||
id: 1,
|
||||
returnItem: {
|
||||
@@ -304,4 +347,4 @@ describe('RemissionListItemComponent', () => {
|
||||
expect(component.predefinedReturnQuantity()).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,49 +1,214 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
input,
|
||||
} from '@angular/core';
|
||||
import {
|
||||
ReturnItem,
|
||||
ReturnSuggestion,
|
||||
StockInfo,
|
||||
} from '@isa/remission/data-access';
|
||||
import {
|
||||
ProductInfoComponent,
|
||||
ProductStockInfoComponent,
|
||||
} from '@isa/remission/shared/product';
|
||||
import { ClientRowImports, ItemRowDataImports } from '@isa/ui/item-rows';
|
||||
|
||||
@Component({
|
||||
selector: 'remi-feature-remission-list-item',
|
||||
templateUrl: './remission-list-item.component.html',
|
||||
styleUrl: './remission-list-item.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
ProductInfoComponent,
|
||||
ProductStockInfoComponent,
|
||||
ClientRowImports,
|
||||
ItemRowDataImports,
|
||||
],
|
||||
})
|
||||
export class RemissionListItemComponent {
|
||||
item = input.required<ReturnItem | ReturnSuggestion>();
|
||||
stock = input.required<StockInfo>();
|
||||
|
||||
predefinedReturnQuantity = computed(() => {
|
||||
const item = this.item();
|
||||
|
||||
// ReturnSuggestion
|
||||
if ('returnItem' in item && item?.returnItem?.data) {
|
||||
return item.returnItem.data.predefinedReturnQuantity ?? 0;
|
||||
}
|
||||
|
||||
// ReturnItem
|
||||
if ('predefinedReturnQuantity' in item) {
|
||||
return item.predefinedReturnQuantity ?? 0;
|
||||
}
|
||||
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
inject,
|
||||
input,
|
||||
} from '@angular/core';
|
||||
import { Validators } from '@angular/forms';
|
||||
import {
|
||||
calculateAvailableStock,
|
||||
calculateStockToRemit,
|
||||
calculateTargetStock,
|
||||
RemissionListType,
|
||||
RemissionSelectionStore,
|
||||
ReturnItem,
|
||||
ReturnSuggestion,
|
||||
StockInfo,
|
||||
} from '@isa/remission/data-access';
|
||||
import {
|
||||
ProductInfoComponent,
|
||||
ProductShelfMetaInfoComponent,
|
||||
ProductStockInfoComponent,
|
||||
} from '@isa/remission/shared/product';
|
||||
import { TextButtonComponent } from '@isa/ui/buttons';
|
||||
import { injectTextInputDialog } from '@isa/ui/dialog';
|
||||
import { ClientRowImports, ItemRowDataImports } from '@isa/ui/item-rows';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { Breakpoint, breakpoint } from '@isa/ui/layout';
|
||||
import { injectRemissionListType } from '../injects/inject-remission-list-type';
|
||||
|
||||
/**
|
||||
* Component representing a single item in the remission list.
|
||||
*
|
||||
* Displays product information, stock details, and allows the user to change
|
||||
* the remission quantity via a dialog. Handles both `ReturnItem` and
|
||||
* `ReturnSuggestion` types, adapting logic based on the current remission list type.
|
||||
*
|
||||
* @remarks
|
||||
* - Uses OnPush change detection for performance.
|
||||
* - Relies on signals for local state and computed values.
|
||||
* - Follows workspace guidelines for type safety, clean code, and documentation.
|
||||
*
|
||||
* @see https://context7.com/angular/angular/20.0.0/llms.txt?topic=component
|
||||
*/
|
||||
@Component({
|
||||
selector: 'remi-feature-remission-list-item',
|
||||
templateUrl: './remission-list-item.component.html',
|
||||
styleUrl: './remission-list-item.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
ProductInfoComponent,
|
||||
ProductStockInfoComponent,
|
||||
ProductShelfMetaInfoComponent,
|
||||
TextButtonComponent,
|
||||
ClientRowImports,
|
||||
ItemRowDataImports,
|
||||
],
|
||||
})
|
||||
export class RemissionListItemComponent {
|
||||
/**
|
||||
* Dialog service for prompting the user to enter a remission quantity.
|
||||
* @private
|
||||
*/
|
||||
#dialog = injectTextInputDialog();
|
||||
|
||||
/**
|
||||
* Store for managing selected remission quantities.
|
||||
* @private
|
||||
*/
|
||||
#store = inject(RemissionSelectionStore);
|
||||
|
||||
/**
|
||||
* Signal indicating if the current layout is mobile (tablet breakpoint or below).
|
||||
*/
|
||||
mobileBreakpoint = breakpoint([Breakpoint.Tablet]);
|
||||
|
||||
/**
|
||||
* Signal providing the current remission list type (Abteilung or Pflicht).
|
||||
*/
|
||||
remissionListType = injectRemissionListType();
|
||||
|
||||
/**
|
||||
* The item to display in the list.
|
||||
* Can be either a ReturnItem or a ReturnSuggestion.
|
||||
*/
|
||||
item = input.required<ReturnItem | ReturnSuggestion>();
|
||||
|
||||
/**
|
||||
* Stock information for the item.
|
||||
*/
|
||||
stock = input.required<StockInfo>();
|
||||
|
||||
/**
|
||||
* Optional product group value for display or filtering.
|
||||
*/
|
||||
productGroupValue = input<string>('');
|
||||
|
||||
/**
|
||||
* Computes the orientation for the product info section based on breakpoint.
|
||||
* @returns 'horizontal' if mobile, otherwise 'vertical'
|
||||
*/
|
||||
remiProductInfoOrientation = computed(() => {
|
||||
return this.mobileBreakpoint() ? 'horizontal' : 'vertical';
|
||||
});
|
||||
|
||||
/**
|
||||
* Computes the remaining quantity in stock for the current item.
|
||||
*/
|
||||
remainingQuantityInStock = computed(
|
||||
() => this.item()?.remainingQuantityInStock,
|
||||
);
|
||||
|
||||
/**
|
||||
* Computes the predefined return quantity for the current item.
|
||||
* - For Abteilung (suggestion), uses the nested returnItem's predefined quantity.
|
||||
* - For Pflicht (item), uses the item's predefined quantity.
|
||||
* - Returns 0 if not available.
|
||||
*/
|
||||
predefinedReturnQuantity = computed(() => {
|
||||
const item = this.item();
|
||||
|
||||
// ReturnSuggestion
|
||||
if (this.remissionListType() === RemissionListType.Abteilung) {
|
||||
return (
|
||||
(item as ReturnSuggestion)?.returnItem?.data
|
||||
?.predefinedReturnQuantity ?? 0
|
||||
);
|
||||
}
|
||||
|
||||
// ReturnItem
|
||||
if (this.remissionListType() === RemissionListType.Pflicht) {
|
||||
return (item as ReturnItem)?.predefinedReturnQuantity ?? 0;
|
||||
}
|
||||
|
||||
return 0;
|
||||
});
|
||||
|
||||
/**
|
||||
* Computes the available stock for the item using stock and removedFromStock.
|
||||
* @returns The calculated available stock.
|
||||
*/
|
||||
availableStock = computed(() => {
|
||||
return calculateAvailableStock({
|
||||
stock: this.stock()?.inStock,
|
||||
removedFromStock: this.stock()?.removedFromStock,
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Computes the quantity to remit for the current item.
|
||||
* - Uses the selected quantity from the store if available.
|
||||
* - Otherwise, calculates based on available stock, predefined return quantity, and remaining quantity.
|
||||
* @returns The quantity to remit.
|
||||
*/
|
||||
stockToRemit = computed(() => {
|
||||
const remissionItemId = this.item()?.id;
|
||||
return (
|
||||
this.#store.selectedQuantity()?.[remissionItemId!] ??
|
||||
calculateStockToRemit({
|
||||
availableStock: this.availableStock(),
|
||||
predefinedReturnQuantity: this.predefinedReturnQuantity(),
|
||||
remainingQuantityInStock: this.remainingQuantityInStock(),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Computes the target stock after remission.
|
||||
* @returns The calculated target stock.
|
||||
*/
|
||||
targetStock = computed(() => {
|
||||
return calculateTargetStock({
|
||||
availableStock: this.availableStock(),
|
||||
stockToRemit: this.stockToRemit(),
|
||||
remainingQuantityInStock: this.remainingQuantityInStock(),
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Opens a dialog to allow the user to change the remission quantity for the item.
|
||||
* Validates the input and updates the remission quantity in the store if valid.
|
||||
*
|
||||
* @returns Promise<void>
|
||||
*/
|
||||
async openRemissionQuantityDialog(): Promise<void> {
|
||||
const dialogRef = this.#dialog({
|
||||
title: 'Remi-Menge ändern',
|
||||
data: {
|
||||
message: 'Wie viele Exemplare können remittiert werden?',
|
||||
inputLabel: 'Remi-Menge',
|
||||
inputValidation: [
|
||||
{
|
||||
errorKey: 'required',
|
||||
inputValidator: Validators.required,
|
||||
errorText: 'Bitte geben Sie eine Menge an.',
|
||||
},
|
||||
{
|
||||
errorKey: 'pattern',
|
||||
inputValidator: Validators.pattern(/^[1-9][0-9]*$/),
|
||||
errorText: 'Die Menge muss mindestens 1 sein.',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const result = await firstValueFrom(dialogRef.closed);
|
||||
const itemId = this.item()?.id;
|
||||
const quantity = Number(result?.inputValue);
|
||||
|
||||
if (itemId && quantity > 0) {
|
||||
this.#store.updateRemissionQuantity(itemId, this.item(), quantity);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,48 +1,48 @@
|
||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import {
|
||||
RemissionListType,
|
||||
RemissionSearchService,
|
||||
} from '@isa/remission/data-access';
|
||||
import {
|
||||
DropdownAppearance,
|
||||
DropdownButtonComponent,
|
||||
DropdownOptionComponent,
|
||||
} from '@isa/ui/input-controls';
|
||||
import { remissionListTypeRouteMapping } from './remission-list-type-route.mapping';
|
||||
import { injectRemissionListType } from '../injects/inject-remission-list-type';
|
||||
|
||||
@Component({
|
||||
selector: 'remi-feature-remission-list-select',
|
||||
templateUrl: './remission-list-select.component.html',
|
||||
styleUrl: './remission-list-select.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [DropdownButtonComponent, DropdownOptionComponent],
|
||||
})
|
||||
export class RemissionListSelectComponent {
|
||||
DropdownAppearance = DropdownAppearance;
|
||||
RemissionListCategory = RemissionListType;
|
||||
remissionSearchService = inject(RemissionSearchService);
|
||||
router = inject(Router);
|
||||
route = inject(ActivatedRoute);
|
||||
|
||||
remissionListTypes = this.remissionSearchService.remissionListType();
|
||||
selectedRemissionListType = injectRemissionListType();
|
||||
|
||||
async changeRemissionType(remissionTypeValue: RemissionListType | undefined) {
|
||||
console.log(remissionTypeValue, remissionListTypeRouteMapping);
|
||||
|
||||
if (
|
||||
!remissionTypeValue ||
|
||||
remissionTypeValue === RemissionListType.Koerperlos
|
||||
)
|
||||
return;
|
||||
|
||||
await this.router.navigate(
|
||||
[remissionListTypeRouteMapping[remissionTypeValue]],
|
||||
{
|
||||
relativeTo: this.route,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import {
|
||||
RemissionListType,
|
||||
RemissionSearchService,
|
||||
} from '@isa/remission/data-access';
|
||||
import {
|
||||
DropdownAppearance,
|
||||
DropdownButtonComponent,
|
||||
DropdownOptionComponent,
|
||||
} from '@isa/ui/input-controls';
|
||||
import { remissionListTypeRouteMapping } from './remission-list-type-route.mapping';
|
||||
import { injectRemissionListType } from '../injects/inject-remission-list-type';
|
||||
|
||||
@Component({
|
||||
selector: 'remi-feature-remission-list-select',
|
||||
templateUrl: './remission-list-select.component.html',
|
||||
styleUrl: './remission-list-select.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [DropdownButtonComponent, DropdownOptionComponent],
|
||||
})
|
||||
export class RemissionListSelectComponent {
|
||||
DropdownAppearance = DropdownAppearance;
|
||||
RemissionListCategory = RemissionListType;
|
||||
remissionSearchService = inject(RemissionSearchService);
|
||||
router = inject(Router);
|
||||
route = inject(ActivatedRoute);
|
||||
|
||||
remissionListTypes = this.remissionSearchService.remissionListType();
|
||||
selectedRemissionListType = injectRemissionListType();
|
||||
|
||||
async changeRemissionType(remissionTypeValue: RemissionListType | undefined) {
|
||||
console.log(remissionTypeValue, remissionListTypeRouteMapping);
|
||||
|
||||
if (
|
||||
!remissionTypeValue ||
|
||||
remissionTypeValue === RemissionListType.Koerperlos
|
||||
)
|
||||
return;
|
||||
|
||||
await this.router.navigate(
|
||||
[remissionListTypeRouteMapping[remissionTypeValue]],
|
||||
{
|
||||
relativeTo: this.route,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,35 +1,36 @@
|
||||
<remission-feature-remission-start-card></remission-feature-remission-start-card>
|
||||
|
||||
<remi-feature-remission-list-select></remi-feature-remission-list-select>
|
||||
|
||||
<filter-controls-panel (triggerSearch)="search()"></filter-controls-panel>
|
||||
|
||||
<span
|
||||
class="text-isa-neutral-900 isa-text-body-2-regular self-start"
|
||||
data-what="result-count"
|
||||
>
|
||||
{{ hits() }} Einträge
|
||||
</span>
|
||||
|
||||
<div class="flex flex-col gap-4 w-full items-center justify-center">
|
||||
@for (item of items(); track item.id) {
|
||||
@defer (on viewport) {
|
||||
<a [routerLink]="['../', 'return', item.id]" class="w-full">
|
||||
<remi-feature-remission-list-item
|
||||
#listElement
|
||||
[item]="item"
|
||||
[stock]="getStockForItem(item)"
|
||||
></remi-feature-remission-list-item>
|
||||
</a>
|
||||
} @placeholder {
|
||||
<div class="h-[7.75rem] w-full flex items-center justify-center">
|
||||
<ui-icon-button
|
||||
[pending]="true"
|
||||
[color]="'tertiary'"
|
||||
data-what="load-spinner"
|
||||
data-which="item-placeholder"
|
||||
></ui-icon-button>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
<remi-feature-remission-start-card></remi-feature-remission-start-card>
|
||||
|
||||
<remi-feature-remission-list-select></remi-feature-remission-list-select>
|
||||
|
||||
<filter-controls-panel (triggerSearch)="search()"></filter-controls-panel>
|
||||
|
||||
<span
|
||||
class="text-isa-neutral-900 isa-text-body-2-regular self-start"
|
||||
data-what="result-count"
|
||||
>
|
||||
{{ hits() }} Einträge
|
||||
</span>
|
||||
|
||||
<div class="flex flex-col gap-4 w-full items-center justify-center">
|
||||
@for (item of items(); track item.id) {
|
||||
@defer (on viewport) {
|
||||
<a [routerLink]="['../', 'return', item.id]" class="w-full">
|
||||
<remi-feature-remission-list-item
|
||||
#listElement
|
||||
[item]="item"
|
||||
[stock]="getStockForItem(item)"
|
||||
[productGroupValue]="getProductGroupValueForItem(item)"
|
||||
></remi-feature-remission-list-item>
|
||||
</a>
|
||||
} @placeholder {
|
||||
<div class="h-[7.75rem] w-full flex items-center justify-center">
|
||||
<ui-icon-button
|
||||
[pending]="true"
|
||||
[color]="'tertiary'"
|
||||
data-what="load-spinner"
|
||||
data-which="item-placeholder"
|
||||
></ui-icon-button>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -19,6 +19,7 @@ import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import {
|
||||
createRemissionInStockResource,
|
||||
createRemissionListResource,
|
||||
createRemissionProductGroupResource,
|
||||
} from './resources';
|
||||
import { injectRemissionListType } from './injects/inject-remission-list-type';
|
||||
import { RemissionListItemComponent } from './remission-list-item/remission-list-item.component';
|
||||
@@ -49,7 +50,7 @@ function querySettingsFactory() {
|
||||
* @see {@link https://angular.dev/style-guide} for Angular best practices.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'remission-feature-remission-list',
|
||||
selector: 'remi-feature-remission-list',
|
||||
templateUrl: './remission-list.component.html',
|
||||
styleUrl: './remission-list.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
@@ -134,6 +135,13 @@ export class RemissionListComponent {
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* Resource signal for fetching product group information based on current remission items.
|
||||
* Updates when the remission list changes.
|
||||
* @returns Product group resource state.
|
||||
*/
|
||||
productGroupResource = createRemissionProductGroupResource();
|
||||
|
||||
/**
|
||||
* Computed signal for the current remission list response.
|
||||
* @returns The latest remission list response or undefined.
|
||||
@@ -146,6 +154,12 @@ export class RemissionListComponent {
|
||||
*/
|
||||
inStockResponseValue = computed(() => this.inStockResource.value());
|
||||
|
||||
/**
|
||||
* Computed signal for the product group response.
|
||||
* @returns Array of KeyValueStringAndString or undefined.
|
||||
*/
|
||||
productGroupResponseValue = computed(() => this.productGroupResource.value());
|
||||
|
||||
/**
|
||||
* Computed signal for the remission items to display.
|
||||
* @returns Array of ReturnItem or ReturnSuggestion.
|
||||
@@ -188,4 +202,18 @@ export class RemissionListComponent {
|
||||
getStockForItem(item: ReturnItem | ReturnSuggestion): StockInfo | undefined {
|
||||
return this.stockInfoMap().get(Number(item?.product?.catalogProductNumber));
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the product group value for a given item.
|
||||
* @param item - The ReturnItem or ReturnSuggestion to look up.
|
||||
* @returns The product group value as a string, or undefined if not found.
|
||||
*/
|
||||
getProductGroupValueForItem(
|
||||
item: ReturnItem | ReturnSuggestion,
|
||||
): string | undefined {
|
||||
const productGroup = this.productGroupResponseValue()?.find(
|
||||
(group) => group.key === item?.product?.productGroup,
|
||||
);
|
||||
return productGroup ? productGroup.value : '';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { injectDialog } from '@isa/ui/dialog';
|
||||
import { SearchItemToRemitDialogComponent } from '@isa/remission/shared/search-item-to-remit-dialog';
|
||||
|
||||
@Component({
|
||||
selector: 'remission-feature-remission-start-card',
|
||||
selector: 'remi-feature-remission-start-card',
|
||||
templateUrl: './remission-start-card.component.html',
|
||||
styleUrl: './remission-start-card.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from './remission-list.resource';
|
||||
export * from './remission-instock.resource';
|
||||
export * from './remission-product-group.resource';
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { inject, resource } from '@angular/core';
|
||||
import { RemissionProductGroupService } from '@isa/remission/data-access';
|
||||
|
||||
export const createRemissionProductGroupResource = () => {
|
||||
const remissionProductGroupService = inject(RemissionProductGroupService);
|
||||
return resource({
|
||||
loader: ({ abortSignal }) =>
|
||||
remissionProductGroupService.fetchProductGroups(abortSignal),
|
||||
});
|
||||
};
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from './lib/product-info/product-info.component';
|
||||
export * from './lib/product-stock-info/product-stock-info.component';
|
||||
export * from './lib/product-shelf-meta-info/product-shelf-meta-info.component';
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
@if (department() || shelfLabel()) {
|
||||
<div
|
||||
class="product-shelf-meta-info-row"
|
||||
data-what="shelf-meta-info-row"
|
||||
data-which="shelf-infos"
|
||||
>
|
||||
<div
|
||||
class="h-6"
|
||||
data-what="shelf-meta-infos-icon"
|
||||
data-which="shelf-infos-icon"
|
||||
>
|
||||
<ng-icon size="1.5rem" name="isaFilialeLocation"></ng-icon>
|
||||
</div>
|
||||
<div
|
||||
class="isa-text-body-2-bold"
|
||||
data-what="shelf-meta-info-value"
|
||||
data-which="shelf-infos"
|
||||
>
|
||||
{{ shelfInfos() }}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (productGroupKey() || productGroupValue()) {
|
||||
<div
|
||||
class="product-shelf-meta-info-row"
|
||||
data-what="shelf-meta-info-row"
|
||||
data-which="product-group"
|
||||
>
|
||||
<div
|
||||
class="isa-text-body-2-bold"
|
||||
data-what="shelf-meta-infos-value"
|
||||
data-which="product-group"
|
||||
>
|
||||
{{ productGroup() }}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (returnReason()) {
|
||||
<div
|
||||
class="flex items-center gap-1"
|
||||
data-what="shelf-meta-info-row"
|
||||
data-which="return-reason"
|
||||
>
|
||||
<div
|
||||
class="isa-text-body-2-regular"
|
||||
data-what="shelf-meta-infos-label"
|
||||
data-which="return-reason"
|
||||
>
|
||||
Remigrund:
|
||||
</div>
|
||||
<div
|
||||
class="isa-text-body-2-regular"
|
||||
data-what="shelf-meta-infos-value"
|
||||
data-which="return-reason"
|
||||
>
|
||||
{{ returnReason() }}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (assortment()) {
|
||||
<div
|
||||
class="product-shelf-meta-info-row"
|
||||
data-what="shelf-meta-info-row"
|
||||
data-which="assortment"
|
||||
>
|
||||
<div
|
||||
class="isa-text-body-2-regular grid-flow-row"
|
||||
data-what="shelf-meta-infos-value"
|
||||
data-which="assortment"
|
||||
>
|
||||
{{ assortmentFormatted() }}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
:host {
|
||||
@apply grid grid-flow-row gap-2 text-isa-neutral-900;
|
||||
}
|
||||
|
||||
.product-shelf-meta-info-row {
|
||||
@apply flex items-center gap-2;
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { describe, it, beforeEach, expect } from 'vitest';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { ProductShelfMetaInfoComponent } from './product-shelf-meta-info.component';
|
||||
|
||||
describe('ProductShelfMetaInfoComponent', () => {
|
||||
let fixture: ComponentFixture<ProductShelfMetaInfoComponent>;
|
||||
let component: ProductShelfMetaInfoComponent;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [ProductShelfMetaInfoComponent],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(ProductShelfMetaInfoComponent);
|
||||
component = fixture.componentInstance;
|
||||
});
|
||||
|
||||
describe('Component Setup and Initialization', () => {
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('shelfInfos computed property', () => {
|
||||
it('should return "department / shelfLabel" when both are set', () => {
|
||||
fixture.componentRef.setInput('department', 'Reise');
|
||||
fixture.componentRef.setInput('shelfLabel', 'Europa');
|
||||
fixture.detectChanges();
|
||||
expect(component.shelfInfos()).toBe('Reise / Europa');
|
||||
});
|
||||
|
||||
it('should return only department when shelfLabel is empty', () => {
|
||||
fixture.componentRef.setInput('department', 'Reise');
|
||||
fixture.componentRef.setInput('shelfLabel', '');
|
||||
fixture.detectChanges();
|
||||
expect(component.shelfInfos()).toBe('Reise');
|
||||
});
|
||||
|
||||
it('should return only shelfLabel when department is empty', () => {
|
||||
fixture.componentRef.setInput('department', '');
|
||||
fixture.componentRef.setInput('shelfLabel', 'Europa');
|
||||
fixture.detectChanges();
|
||||
expect(component.shelfInfos()).toBe('Europa');
|
||||
});
|
||||
|
||||
it('should return empty string when both are empty', () => {
|
||||
fixture.componentRef.setInput('department', '');
|
||||
fixture.componentRef.setInput('shelfLabel', '');
|
||||
fixture.detectChanges();
|
||||
expect(component.shelfInfos()).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('productGroup computed property', () => {
|
||||
it('should return "key: value" when both are set', () => {
|
||||
fixture.componentRef.setInput('productGroupKey', 'PG1');
|
||||
fixture.componentRef.setInput('productGroupValue', 'Bücher');
|
||||
fixture.detectChanges();
|
||||
expect(component.productGroup()).toBe('PG1: Bücher');
|
||||
});
|
||||
|
||||
it('should return only key when value is empty', () => {
|
||||
fixture.componentRef.setInput('productGroupKey', 'PG1');
|
||||
fixture.componentRef.setInput('productGroupValue', '');
|
||||
fixture.detectChanges();
|
||||
expect(component.productGroup()).toBe('PG1');
|
||||
});
|
||||
|
||||
it('should return only value when key is empty', () => {
|
||||
fixture.componentRef.setInput('productGroupKey', '');
|
||||
fixture.componentRef.setInput('productGroupValue', 'Bücher');
|
||||
fixture.detectChanges();
|
||||
expect(component.productGroup()).toBe('Bücher');
|
||||
});
|
||||
|
||||
it('should return empty string when both are empty', () => {
|
||||
fixture.componentRef.setInput('productGroupKey', '');
|
||||
fixture.componentRef.setInput('productGroupValue', '');
|
||||
fixture.detectChanges();
|
||||
expect(component.productGroup()).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('assortmentFormatted computed property', () => {
|
||||
it('should format "Basissortiment|BPrämienartikel|n" as "B: Basissortiment, n: Prämienartikel"', () => {
|
||||
fixture.componentRef.setInput(
|
||||
'assortment',
|
||||
'Basissortiment|BPrämienartikel|n',
|
||||
);
|
||||
fixture.detectChanges();
|
||||
expect(component.assortmentFormatted()).toBe(
|
||||
'B: Basissortiment, n: Prämienartikel',
|
||||
);
|
||||
});
|
||||
|
||||
it('should format "Archivartikel|C" as "C: Archivartikel"', () => {
|
||||
fixture.componentRef.setInput('assortment', 'Archivartikel|C');
|
||||
fixture.detectChanges();
|
||||
expect(component.assortmentFormatted()).toBe('C: Archivartikel');
|
||||
});
|
||||
|
||||
it('should return the raw value if no delimiter is present', () => {
|
||||
fixture.componentRef.setInput('assortment', 'Basissortiment');
|
||||
fixture.detectChanges();
|
||||
expect(component.assortmentFormatted()).toBe('Basissortiment');
|
||||
});
|
||||
|
||||
it('should return empty string if assortment is empty', () => {
|
||||
fixture.componentRef.setInput('assortment', '');
|
||||
fixture.detectChanges();
|
||||
expect(component.assortmentFormatted()).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('returnReason input', () => {
|
||||
it('should expose returnReason input value', () => {
|
||||
fixture.componentRef.setInput('returnReason', 'Defekt');
|
||||
fixture.detectChanges();
|
||||
expect(component.returnReason()).toBe('Defekt');
|
||||
});
|
||||
|
||||
it('should return empty string if returnReason is not set', () => {
|
||||
fixture.componentRef.setInput('returnReason', '');
|
||||
fixture.detectChanges();
|
||||
expect(component.returnReason()).toBe('');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
input,
|
||||
} from '@angular/core';
|
||||
import { isaFilialeLocation } from '@isa/icons';
|
||||
import { NgIconComponent, provideIcons } from '@ng-icons/core';
|
||||
|
||||
/**
|
||||
* Displays meta information about a product shelf, including department, shelf label,
|
||||
* product group, assortment, and return reason. Designed for use with ReturnItem or
|
||||
* ReturnSuggestion data structures.
|
||||
*
|
||||
* @remarks
|
||||
* - Uses Angular signals for local state.
|
||||
* - Follows OnPush change detection for performance.
|
||||
* - Provides icon support via NgIconComponent.
|
||||
*
|
||||
* @example
|
||||
* <remi-product-shelf-meta-info
|
||||
* [department]="item.department"
|
||||
* [shelfLabel]="item.shelfLabel"
|
||||
* [productGroupKey]="item.productGroup"
|
||||
* [productGroupValue]="productGroupValue"
|
||||
* [assortment]="item.assortment"
|
||||
* [returnReason]="item.returnReason"
|
||||
* ></remi-product-shelf-meta-info>
|
||||
*/
|
||||
@Component({
|
||||
selector: 'remi-product-shelf-meta-info',
|
||||
templateUrl: './product-shelf-meta-info.component.html',
|
||||
styleUrls: ['./product-shelf-meta-info.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [NgIconComponent],
|
||||
providers: [provideIcons({ isaFilialeLocation })],
|
||||
})
|
||||
export class ProductShelfMetaInfoComponent {
|
||||
/**
|
||||
* Department name (e.g., "Reise").
|
||||
* Sourced from ReturnItem or ReturnSuggestion.
|
||||
*/
|
||||
department = input<string>('');
|
||||
|
||||
/**
|
||||
* Shelf label (e.g., "Europa").
|
||||
* Sourced from ReturnItem or ReturnSuggestion.
|
||||
*/
|
||||
shelfLabel = input<string>('');
|
||||
|
||||
/**
|
||||
* Product group key.
|
||||
* Sourced from ReturnItem or ReturnSuggestion (product.productGroup).
|
||||
*/
|
||||
productGroupKey = input<string>('');
|
||||
|
||||
/**
|
||||
* Product group value.
|
||||
* Sourced from fetchProductGroups (Key/Value).
|
||||
*/
|
||||
productGroupValue = input<string>('');
|
||||
|
||||
/**
|
||||
* Assortment string, possibly delimited (see {@link assortmentFormatted}).
|
||||
* Sourced from ReturnItem or ReturnSuggestion.
|
||||
*/
|
||||
assortment = input<string>('');
|
||||
|
||||
/**
|
||||
* Return reason.
|
||||
* Sourced from ReturnItem or ReturnSuggestion.
|
||||
*/
|
||||
returnReason = input<string>('');
|
||||
|
||||
/**
|
||||
* Computed shelf information string, combining department and shelf label.
|
||||
* Returns a string in the format "department / shelfLabel", omitting empty values.
|
||||
*
|
||||
* @returns {string} Formatted shelf info or empty string.
|
||||
*/
|
||||
shelfInfos = computed(() => {
|
||||
const [department, shelfLabel] = [this.department(), this.shelfLabel()];
|
||||
return [department, shelfLabel].filter(Boolean).join(' / ');
|
||||
});
|
||||
|
||||
/**
|
||||
* Computed product group string, combining key and value.
|
||||
* Returns a string in the format "key: value", omitting empty values.
|
||||
*
|
||||
* @returns {string} Formatted product group or empty string.
|
||||
*/
|
||||
productGroup = computed(() => {
|
||||
const [key, value] = [this.productGroupKey(), this.productGroupValue()];
|
||||
return [key, value].filter(Boolean).join(': ');
|
||||
});
|
||||
|
||||
/**
|
||||
* Formats an assortment string like "Label1|Key1Label2|Key2..."
|
||||
* into "Key1: Label1, Key2: Label2". Returns raw value if no delimiter is present.
|
||||
*
|
||||
* @example
|
||||
* // "Basissortiment|BPrämienartikel|n" → "B: Basissortiment, n: Prämienartikel"
|
||||
* // "Archivartikel|C" → "C: Archivartikel"
|
||||
* // "Basissortiment" → "Basissortiment"
|
||||
*
|
||||
* @returns {string} Formatted assortment string or raw value.
|
||||
*/
|
||||
assortmentFormatted = computed(() => {
|
||||
const raw = this.assortment();
|
||||
if (!raw) return '';
|
||||
const parts = raw.split('|');
|
||||
if (parts.length === 1) return raw;
|
||||
|
||||
const result: string[] = [];
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
const label = parts[i];
|
||||
const next = parts[i + 1];
|
||||
const key = next[0];
|
||||
result.push(`${key}: ${label}`);
|
||||
parts[i + 1] = next.slice(1);
|
||||
}
|
||||
return result.filter(Boolean).join(', ');
|
||||
});
|
||||
}
|
||||
@@ -3,7 +3,11 @@
|
||||
data-what="stock-info-row"
|
||||
data-which="current-stock"
|
||||
>
|
||||
<div data-what="stock-label" data-which="current-stock">
|
||||
<div
|
||||
class="isa-text-body-2-regular"
|
||||
data-what="stock-label"
|
||||
data-which="current-stock"
|
||||
>
|
||||
Aktueller Bestand
|
||||
</div>
|
||||
<div
|
||||
@@ -19,7 +23,13 @@
|
||||
data-what="stock-info-row"
|
||||
data-which="remit-amount"
|
||||
>
|
||||
<div data-what="stock-label" data-which="remit-amount">Remi Menge</div>
|
||||
<div
|
||||
class="isa-text-body-2-regular"
|
||||
data-what="stock-label"
|
||||
data-which="remit-amount"
|
||||
>
|
||||
Remi Menge
|
||||
</div>
|
||||
<div
|
||||
class="isa-text-body-2-bold"
|
||||
data-what="stock-value"
|
||||
@@ -33,7 +43,11 @@
|
||||
data-what="stock-info-row"
|
||||
data-which="remaining-stock"
|
||||
>
|
||||
<div data-what="stock-label" data-which="remaining-stock">
|
||||
<div
|
||||
class="isa-text-body-2-regular"
|
||||
data-what="stock-label"
|
||||
data-which="remaining-stock"
|
||||
>
|
||||
Übriger Bestand
|
||||
</div>
|
||||
<div
|
||||
@@ -45,9 +59,11 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="product-stock-info-row" data-what="stock-info-row" data-which="zob">
|
||||
<div data-what="stock-label" data-which="zob">ZOB</div>
|
||||
<div class="isa-text-body-2-regular" data-what="stock-label" data-which="zob">
|
||||
ZOB
|
||||
</div>
|
||||
<div
|
||||
class="isa-text-body-2-bold grid-flow-row"
|
||||
class="isa-text-body-2-regular grid-flow-row"
|
||||
data-what="stock-value"
|
||||
data-which="zob"
|
||||
>
|
||||
|
||||
@@ -5,6 +5,17 @@ describe('ProductStockInfoComponent', () => {
|
||||
let component: ProductStockInfoComponent;
|
||||
let fixture: ComponentFixture<ProductStockInfoComponent>;
|
||||
|
||||
const setInputs = (inputs: Record<string, unknown>) => {
|
||||
Object.entries(inputs).forEach(([key, value]) =>
|
||||
fixture.componentRef.setInput(key, value),
|
||||
);
|
||||
};
|
||||
|
||||
const getStockValue = (which: string) =>
|
||||
fixture.nativeElement
|
||||
.querySelector(`[data-what="stock-value"][data-which="${which}"]`)
|
||||
?.textContent?.trim();
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [ProductStockInfoComponent],
|
||||
@@ -18,114 +29,38 @@ describe('ProductStockInfoComponent', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should display the current stock', () => {
|
||||
// Arrange
|
||||
fixture.componentRef.setInput('stock', 42);
|
||||
|
||||
// Act
|
||||
it('should display the availableStock input', () => {
|
||||
setInputs({ availableStock: 42 });
|
||||
fixture.detectChanges();
|
||||
const value = fixture.nativeElement.querySelector(
|
||||
'[data-what="stock-value"][data-which="current-stock"]',
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(value?.textContent?.trim()).toBe('42x');
|
||||
expect(getStockValue('current-stock')).toBe('42x');
|
||||
});
|
||||
|
||||
it('should display the remit amount (computed)', () => {
|
||||
// Arrange
|
||||
fixture.componentRef.setInput('stock', 20);
|
||||
fixture.componentRef.setInput('removedFromStock', 5);
|
||||
fixture.componentRef.setInput('remainingQuantityInStock', 10);
|
||||
|
||||
// Act
|
||||
it('should display the stockToRemit input', () => {
|
||||
setInputs({ stockToRemit: 5 });
|
||||
fixture.detectChanges();
|
||||
const value = fixture.nativeElement.querySelector(
|
||||
'[data-what="stock-value"][data-which="remit-amount"]',
|
||||
);
|
||||
|
||||
// Assert
|
||||
// availableStock = 20 - 5 = 15; stockToRemit = 15 - 10 = 5
|
||||
expect(value?.textContent?.trim()).toBe('5x');
|
||||
expect(getStockValue('remit-amount')).toBe('5x');
|
||||
});
|
||||
|
||||
it('should display the remit amount as 0 when remainingQuantityInStock > availableStock', () => {
|
||||
// Arrange
|
||||
fixture.componentRef.setInput('stock', 5);
|
||||
fixture.componentRef.setInput('removedFromStock', 2);
|
||||
fixture.componentRef.setInput('remainingQuantityInStock', 10);
|
||||
|
||||
// Act
|
||||
it('should display the targetStock input', () => {
|
||||
setInputs({ targetStock: 10 });
|
||||
fixture.detectChanges();
|
||||
const value = fixture.nativeElement.querySelector(
|
||||
'[data-what="stock-value"][data-which="remit-amount"]',
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(value?.textContent?.trim()).toBe('0x');
|
||||
expect(getStockValue('remaining-stock')).toBe('10x');
|
||||
});
|
||||
|
||||
it('should display the remaining stock (targetStock, computed)', () => {
|
||||
// Arrange
|
||||
fixture.componentRef.setInput('stock', 20);
|
||||
fixture.componentRef.setInput('removedFromStock', 5);
|
||||
fixture.componentRef.setInput('predefinedReturnQuantity', 5);
|
||||
|
||||
// Act
|
||||
it('should display the zob input', () => {
|
||||
setInputs({ zob: 99 });
|
||||
fixture.detectChanges();
|
||||
const value = fixture.nativeElement.querySelector(
|
||||
'[data-what="stock-value"][data-which="remaining-stock"]',
|
||||
);
|
||||
|
||||
// Assert
|
||||
// availableStock = 20 - 5 = 15; targetStock = 15 - 5 = 10
|
||||
expect(value?.textContent?.trim()).toBe('10x');
|
||||
});
|
||||
|
||||
it('should display the remaining stock as 0 when predefinedReturnQuantity > availableStock', () => {
|
||||
// Arrange
|
||||
fixture.componentRef.setInput('stock', 8);
|
||||
fixture.componentRef.setInput('removedFromStock', 3);
|
||||
fixture.componentRef.setInput('predefinedReturnQuantity', 10);
|
||||
|
||||
// Act
|
||||
fixture.detectChanges();
|
||||
const value = fixture.nativeElement.querySelector(
|
||||
'[data-what="stock-value"][data-which="remaining-stock"]',
|
||||
);
|
||||
|
||||
// Assert
|
||||
// availableStock = 8 - 3 = 5; targetStock = 5 - 10 = -5 => 0
|
||||
expect(value?.textContent?.trim()).toBe('0x');
|
||||
});
|
||||
|
||||
it('should display the zob value', () => {
|
||||
// Arrange
|
||||
fixture.componentRef.setInput('zob', 99);
|
||||
|
||||
// Act
|
||||
fixture.detectChanges();
|
||||
const value = fixture.nativeElement.querySelector(
|
||||
'[data-what="stock-value"][data-which="zob"]',
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(value?.textContent?.trim()).toBe('99x');
|
||||
expect(getStockValue('zob')).toBe('99x');
|
||||
});
|
||||
|
||||
it('should render all labels with correct e2e attributes', () => {
|
||||
// Arrange
|
||||
const labels = [
|
||||
{ which: 'current-stock', text: 'Aktueller Bestand' },
|
||||
{ which: 'remit-amount', text: 'Remi Menge' },
|
||||
{ which: 'remaining-stock', text: 'Übriger Bestand' },
|
||||
{ which: 'zob', text: 'ZOB' },
|
||||
];
|
||||
|
||||
// Act
|
||||
fixture.detectChanges();
|
||||
|
||||
// Assert
|
||||
labels.forEach(({ which, text }) => {
|
||||
const label = fixture.nativeElement.querySelector(
|
||||
`[data-what="stock-label"][data-which="${which}"]`,
|
||||
@@ -134,212 +69,22 @@ describe('ProductStockInfoComponent', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should compute availableStock correctly (stock > removedFromStock)', () => {
|
||||
// Arrange
|
||||
fixture.componentRef.setInput('stock', 10);
|
||||
fixture.componentRef.setInput('removedFromStock', 3);
|
||||
|
||||
// Act
|
||||
const result = component.availableStock();
|
||||
|
||||
// Assert
|
||||
expect(result).toBe(7);
|
||||
});
|
||||
|
||||
it('should compute availableStock as 0 when removedFromStock > stock', () => {
|
||||
// Arrange
|
||||
fixture.componentRef.setInput('stock', 5);
|
||||
fixture.componentRef.setInput('removedFromStock', 10);
|
||||
|
||||
// Act
|
||||
const result = component.availableStock();
|
||||
|
||||
// Assert
|
||||
expect(result).toBe(0);
|
||||
});
|
||||
|
||||
it('should compute stockToRemit correctly (positive result)', () => {
|
||||
// Arrange
|
||||
fixture.componentRef.setInput('stock', 20);
|
||||
fixture.componentRef.setInput('removedFromStock', 5);
|
||||
fixture.componentRef.setInput('remainingQuantityInStock', 10);
|
||||
|
||||
// Act
|
||||
const result = component.stockToRemit();
|
||||
|
||||
// Assert
|
||||
// availableStock = 20 - 5 = 15; stockToRemit = 15 - 10 = 5
|
||||
expect(result).toBe(5);
|
||||
});
|
||||
|
||||
it('should compute stockToRemit as 0 when remainingQuantityInStock > availableStock', () => {
|
||||
// Arrange
|
||||
fixture.componentRef.setInput('stock', 5);
|
||||
fixture.componentRef.setInput('removedFromStock', 2);
|
||||
fixture.componentRef.setInput('remainingQuantityInStock', 10);
|
||||
|
||||
// Act
|
||||
const result = component.stockToRemit();
|
||||
|
||||
// Assert
|
||||
// availableStock = 5 - 2 = 3; stockToRemit = 3 - 10 = -7 => 0
|
||||
expect(result).toBe(0);
|
||||
});
|
||||
|
||||
it('should compute targetStock correctly (positive result)', () => {
|
||||
// Arrange
|
||||
fixture.componentRef.setInput('stock', 30);
|
||||
fixture.componentRef.setInput('removedFromStock', 5);
|
||||
fixture.componentRef.setInput('predefinedReturnQuantity', 10);
|
||||
|
||||
// Act
|
||||
const result = component.targetStock();
|
||||
|
||||
// Assert
|
||||
// availableStock = 30 - 5 = 25; targetStock = 25 - 10 = 15
|
||||
expect(result).toBe(15);
|
||||
});
|
||||
|
||||
it('should compute targetStock as 0 when predefinedReturnQuantity > availableStock', () => {
|
||||
// Arrange
|
||||
fixture.componentRef.setInput('stock', 8);
|
||||
fixture.componentRef.setInput('removedFromStock', 3);
|
||||
fixture.componentRef.setInput('predefinedReturnQuantity', 10);
|
||||
|
||||
// Act
|
||||
const result = component.targetStock();
|
||||
|
||||
// Assert
|
||||
// availableStock = 8 - 3 = 5; targetStock = 5 - 10 = -5 => 0
|
||||
expect(result).toBe(0);
|
||||
});
|
||||
|
||||
it('should compute targetStock using stockToRemit when remainingQuantityInStock is zero or falsy', () => {
|
||||
// Arrange
|
||||
fixture.componentRef.setInput('stock', 15);
|
||||
fixture.componentRef.setInput('removedFromStock', 5);
|
||||
fixture.componentRef.setInput('predefinedReturnQuantity', 0);
|
||||
fixture.componentRef.setInput('remainingQuantityInStock', 0);
|
||||
|
||||
// Act
|
||||
const result = component.targetStock();
|
||||
|
||||
// Assert
|
||||
// availableStock = 15 - 5 = 10
|
||||
// stockToRemit = 10 - 0 = 10
|
||||
// targetStock = 10 - 10 = 0
|
||||
expect(result).toBe(0);
|
||||
});
|
||||
|
||||
it('should compute targetStock using remainingQuantityInStock when it is set (non-zero)', () => {
|
||||
// Arrange
|
||||
fixture.componentRef.setInput('stock', 20);
|
||||
fixture.componentRef.setInput('removedFromStock', 5);
|
||||
fixture.componentRef.setInput('remainingQuantityInStock', 7);
|
||||
|
||||
// Act
|
||||
const result = component.targetStock();
|
||||
|
||||
// Assert
|
||||
// Should return remainingQuantityInStock directly
|
||||
expect(result).toBe(7);
|
||||
});
|
||||
|
||||
it('should compute targetStock as 0 if stockToRemit is greater than availableStock', () => {
|
||||
// Arrange
|
||||
fixture.componentRef.setInput('stock', 5);
|
||||
fixture.componentRef.setInput('removedFromStock', 2);
|
||||
fixture.componentRef.setInput('remainingQuantityInStock', 0);
|
||||
fixture.componentRef.setInput('predefinedReturnQuantity', 0);
|
||||
|
||||
// Act
|
||||
const result = component.targetStock();
|
||||
|
||||
// Assert
|
||||
// availableStock = 5 - 2 = 3
|
||||
// stockToRemit = 3 - 0 = 3
|
||||
// targetStock = 3 - 3 = 0
|
||||
expect(result).toBe(0);
|
||||
});
|
||||
|
||||
it('should compute stockToRemit as predefinedReturnQuantity if set (non-zero)', () => {
|
||||
// Arrange
|
||||
fixture.componentRef.setInput('stock', 10);
|
||||
fixture.componentRef.setInput('removedFromStock', 2);
|
||||
fixture.componentRef.setInput('predefinedReturnQuantity', 4);
|
||||
fixture.componentRef.setInput('remainingQuantityInStock', 5);
|
||||
|
||||
// Act
|
||||
const result = component.stockToRemit();
|
||||
|
||||
// Assert
|
||||
// Should return predefinedReturnQuantity directly
|
||||
expect(result).toBe(4);
|
||||
});
|
||||
|
||||
it('should compute stockToRemit as 0 if availableStock and remainingQuantityInStock are both zero', () => {
|
||||
// Arrange
|
||||
fixture.componentRef.setInput('stock', 0);
|
||||
fixture.componentRef.setInput('removedFromStock', 0);
|
||||
fixture.componentRef.setInput('predefinedReturnQuantity', 0);
|
||||
fixture.componentRef.setInput('remainingQuantityInStock', 0);
|
||||
|
||||
// Act
|
||||
const result = component.stockToRemit();
|
||||
|
||||
// Assert
|
||||
expect(result).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle all-zero inputs for computed properties', () => {
|
||||
// Arrange
|
||||
fixture.componentRef.setInput('stock', 0);
|
||||
fixture.componentRef.setInput('removedFromStock', 0);
|
||||
fixture.componentRef.setInput('remainingQuantityInStock', 0);
|
||||
fixture.componentRef.setInput('predefinedReturnQuantity', 0);
|
||||
|
||||
// Act & Assert
|
||||
expect(component.availableStock()).toBe(0);
|
||||
expect(component.stockToRemit()).toBe(0);
|
||||
expect(component.targetStock()).toBe(0);
|
||||
});
|
||||
|
||||
it('should display all values as 0x when all inputs are zero', () => {
|
||||
// Arrange
|
||||
fixture.componentRef.setInput('stock', 0);
|
||||
fixture.componentRef.setInput('removedFromStock', 0);
|
||||
fixture.componentRef.setInput('remainingQuantityInStock', 0);
|
||||
fixture.componentRef.setInput('predefinedReturnQuantity', 0);
|
||||
fixture.componentRef.setInput('zob', 0);
|
||||
|
||||
// Act
|
||||
setInputs({
|
||||
availableStock: 0,
|
||||
stockToRemit: 0,
|
||||
targetStock: 0,
|
||||
zob: 0,
|
||||
});
|
||||
fixture.detectChanges();
|
||||
|
||||
// Assert
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('[data-what="stock-value"][data-which="current-stock"]')?.textContent?.trim()
|
||||
).toBe('0x');
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('[data-what="stock-value"][data-which="remit-amount"]')?.textContent?.trim()
|
||||
).toBe('0x');
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('[data-what="stock-value"][data-which="remaining-stock"]')?.textContent?.trim()
|
||||
).toBe('0x');
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('[data-what="stock-value"][data-which="zob"]')?.textContent?.trim()
|
||||
).toBe('0x');
|
||||
['current-stock', 'remit-amount', 'remaining-stock', 'zob'].forEach(
|
||||
(which) => expect(getStockValue(which)).toBe('0x'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should display correct values when only zob is set', () => {
|
||||
// Arrange
|
||||
fixture.componentRef.setInput('zob', 123);
|
||||
|
||||
// Act
|
||||
it('should display correct zob value when only zob is set', () => {
|
||||
setInputs({ zob: 123 });
|
||||
fixture.detectChanges();
|
||||
|
||||
// Assert
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('[data-what="stock-value"][data-which="zob"]')?.textContent?.trim()
|
||||
).toBe('123x');
|
||||
expect(getStockValue('zob')).toBe('123x');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,25 +1,14 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
input,
|
||||
} from '@angular/core';
|
||||
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
|
||||
|
||||
/**
|
||||
* Displays and computes stock information for a product.
|
||||
*
|
||||
* ## Inputs
|
||||
* - `stock`: The initial stock quantity (from InStock Request, maps to `stockInfo.inStock`).
|
||||
* - `removedFromStock`: The quantity removed from stock (from InStock Request, maps to `stockInfo.removedFromStock`).
|
||||
* - `predefinedReturnQuantity`: The predefined return quantity (from Pflicht- or Abteilungsremission Request, maps to `ReturnItem.predefinedReturnQuantity` or `ReturnSuggestion.returnItem.data.predefinedReturnQuantity`).
|
||||
* - `remainingQuantityInStock`: The remaining quantity in stock (from Pflicht- or Abteilungsremission Request, maps to `ReturnItem.remainingQuantityInStock` or `ReturnSuggestion.remainingQuantityInStock`).
|
||||
* - `availableStock`: Current available stock after removals.
|
||||
* - `stockToRemit`: Remission quantity.
|
||||
* - `targetStock`: Remaining stock after predefined return.
|
||||
* - `zob`: Min Stock Category Management Information (from InStock Request, maps to `stockInfo.minStockCategoryManagement`).
|
||||
*
|
||||
* ## Computed Properties
|
||||
* - `availableStock`: Current available stock, calculated as `stock - removedFromStock`. Returns 0 if negative.
|
||||
* - `stockToRemit`: Remission quantity, calculated as `availableStock - remainingQuantityInStock`. Returns 0 if negative.
|
||||
* - `targetStock`: Remaining stock after predefined return, calculated as `availableStock - predefinedReturnQuantity`. Returns 0 if negative.
|
||||
*
|
||||
* @remarks
|
||||
* Implements OnPush change detection for performance.
|
||||
*/
|
||||
@@ -31,94 +20,23 @@ import {
|
||||
})
|
||||
export class ProductStockInfoComponent {
|
||||
/**
|
||||
* The initial stock quantity.
|
||||
* (InStock Request) → stockInfo.inStock
|
||||
* Current available stock after removals.
|
||||
*/
|
||||
stock = input<number>(0);
|
||||
availableStock = input<number>(0);
|
||||
|
||||
/**
|
||||
* The quantity removed from stock.
|
||||
* (InStock Request) → stockInfo.removedFromStock
|
||||
* Remission quantity.
|
||||
*/
|
||||
removedFromStock = input<number>(0);
|
||||
stockToRemit = input<number>(0);
|
||||
|
||||
/**
|
||||
* The predefined return quantity.
|
||||
* (Pflicht- oder Abteilungsremission Request) → ReturnItem.predefinedReturnQuantity | ReturnSuggestion.returnItem.data.predefinedReturnQuantity
|
||||
* Remaining stock after predefined return.
|
||||
*/
|
||||
predefinedReturnQuantity = input<number>(0);
|
||||
|
||||
/**
|
||||
* The remaining quantity in stock.
|
||||
* (Pflicht- oder Abteilungsremission Request) → ReturnItem.remainingQuantityInStock | ReturnSuggestion.remainingQuantityInStock
|
||||
*/
|
||||
remainingQuantityInStock = input<number>(0);
|
||||
targetStock = input<number>(0);
|
||||
|
||||
/**
|
||||
* Min Stock Category Management Information.
|
||||
* (InStock Request) → stockInfo.minStockCategoryManagement
|
||||
*/
|
||||
zob = input<number>(0);
|
||||
|
||||
/**
|
||||
* Current available stock.
|
||||
* Calculation: stock - removedFromStock
|
||||
* Returns 0 if result is negative.
|
||||
*
|
||||
* @remarks
|
||||
* Used as the base for further stock calculations.
|
||||
*/
|
||||
availableStock = computed(() => {
|
||||
const stock = this.stock();
|
||||
const removedFromStock = this.removedFromStock();
|
||||
const availableStock = stock - removedFromStock;
|
||||
return availableStock < 0 ? 0 : availableStock;
|
||||
});
|
||||
|
||||
/**
|
||||
* Quantity to remit (remission quantity).
|
||||
*
|
||||
* - If `predefinedReturnQuantity` is set (non-zero), returns that value.
|
||||
* - Otherwise, calculates as `availableStock - remainingQuantityInStock`.
|
||||
* - Returns 0 if the result is negative.
|
||||
*
|
||||
* @remarks
|
||||
* This value is used as an input for `targetStock`.
|
||||
*/
|
||||
stockToRemit = computed(() => {
|
||||
const stock = this.availableStock();
|
||||
const predefinedReturnQuantity = this.predefinedReturnQuantity();
|
||||
const remainingQuantityInStock = this.remainingQuantityInStock();
|
||||
|
||||
if (!predefinedReturnQuantity) {
|
||||
const stockToRemit = stock - (remainingQuantityInStock ?? 0);
|
||||
return stockToRemit < 0 ? 0 : stockToRemit;
|
||||
}
|
||||
|
||||
return predefinedReturnQuantity;
|
||||
});
|
||||
|
||||
/**
|
||||
* Target stock after remission.
|
||||
*
|
||||
* - If `remainingQuantityInStock` is set (non-zero), returns that value.
|
||||
* - Otherwise, calculates as `availableStock - stockToRemit`.
|
||||
* - Returns 0 if the result is negative.
|
||||
*
|
||||
* @remarks
|
||||
* Depends on `stockToRemit` for calculation.
|
||||
* Represents the expected stock after the remission process.
|
||||
*/
|
||||
targetStock = computed(() => {
|
||||
const stock = this.availableStock();
|
||||
const stockToRemit = this.stockToRemit();
|
||||
const remainingQuantityInStock = this.remainingQuantityInStock();
|
||||
|
||||
if (!remainingQuantityInStock) {
|
||||
const targetStock = stock - (stockToRemit ?? 0);
|
||||
return targetStock < 0 ? 0 : targetStock;
|
||||
}
|
||||
|
||||
return remainingQuantityInStock;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3,4 +3,5 @@ export * from './lib/dialog.component';
|
||||
export * from './lib/injects';
|
||||
export * from './lib/message-dialog/message-dialog.component';
|
||||
export * from './lib/confirmation-dialog/confirmation-dialog.component';
|
||||
export * from './lib/text-input-dialog/text-input-dialog.component';
|
||||
export * from './lib/tokens';
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
import { Dialog, DialogRef } from '@angular/cdk/dialog';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { injectDialog, injectMessageDialog } from './injects';
|
||||
import {
|
||||
injectDialog,
|
||||
injectMessageDialog,
|
||||
injectTextInputDialog,
|
||||
} from './injects';
|
||||
import { MessageDialogComponent } from './message-dialog/message-dialog.component';
|
||||
import { DialogComponent } from './dialog.component';
|
||||
import { DIALOG_CONTENT, DIALOG_TITLE } from './tokens';
|
||||
import { Component } from '@angular/core';
|
||||
import { DialogContentDirective } from './dialog-content.directive';
|
||||
import { TextInputDialogComponent } from './text-input-dialog/text-input-dialog.component';
|
||||
|
||||
// Test component extending DialogContentDirective for testing
|
||||
@Component({ template: '' })
|
||||
class TestDialogContentComponent extends DialogContentDirective<unknown, unknown> {}
|
||||
class TestDialogContentComponent extends DialogContentDirective<
|
||||
unknown,
|
||||
unknown
|
||||
> {}
|
||||
|
||||
describe('Dialog Injects', () => {
|
||||
// Mock DialogRef
|
||||
@@ -23,15 +31,13 @@ describe('Dialog Injects', () => {
|
||||
|
||||
// Mock Dialog service
|
||||
const mockDialog = {
|
||||
open: mockDialogOpen
|
||||
open: mockDialogOpen,
|
||||
};
|
||||
|
||||
// Setup test module before each test
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
{ provide: Dialog, useValue: mockDialog }
|
||||
],
|
||||
providers: [{ provide: Dialog, useValue: mockDialog }],
|
||||
});
|
||||
|
||||
// Reset mocks between tests
|
||||
@@ -43,12 +49,12 @@ describe('Dialog Injects', () => {
|
||||
// Arrange
|
||||
const componentType = TestDialogContentComponent;
|
||||
const title = 'Test Title';
|
||||
|
||||
|
||||
// Act
|
||||
const openDialog = TestBed.runInInjectionContext(() =>
|
||||
injectDialog(componentType, { title })
|
||||
const openDialog = TestBed.runInInjectionContext(() =>
|
||||
injectDialog(componentType, { title }),
|
||||
);
|
||||
|
||||
|
||||
// Assert
|
||||
expect(typeof openDialog).toBe('function');
|
||||
});
|
||||
@@ -58,13 +64,13 @@ describe('Dialog Injects', () => {
|
||||
const componentType = TestDialogContentComponent;
|
||||
const title = 'Test Title';
|
||||
const data = { message: 'Test Message' };
|
||||
|
||||
|
||||
// Act
|
||||
const openDialog = TestBed.runInInjectionContext(() =>
|
||||
injectDialog(componentType, { title })
|
||||
const openDialog = TestBed.runInInjectionContext(() =>
|
||||
injectDialog(componentType, { title }),
|
||||
);
|
||||
const result = openDialog({ data });
|
||||
|
||||
|
||||
// Assert
|
||||
expect(mockDialogOpen).toHaveBeenCalledWith(
|
||||
DialogComponent,
|
||||
@@ -73,7 +79,7 @@ describe('Dialog Injects', () => {
|
||||
minWidth: '30rem',
|
||||
hasBackdrop: true,
|
||||
disableClose: true,
|
||||
})
|
||||
}),
|
||||
);
|
||||
expect(result).toBe(mockDialogRef);
|
||||
});
|
||||
@@ -84,13 +90,13 @@ describe('Dialog Injects', () => {
|
||||
const defaultTitle = 'Default Title';
|
||||
const overrideTitle = 'Override Title';
|
||||
const data = { message: 'Test Message' };
|
||||
|
||||
|
||||
// Act
|
||||
const openDialog = TestBed.runInInjectionContext(() =>
|
||||
injectDialog(componentType, { title: defaultTitle })
|
||||
const openDialog = TestBed.runInInjectionContext(() =>
|
||||
injectDialog(componentType, { title: defaultTitle }),
|
||||
);
|
||||
openDialog({ title: overrideTitle, data });
|
||||
|
||||
|
||||
// Assert
|
||||
const callOptions = mockDialogOpen.mock.calls[0][1];
|
||||
const injector = callOptions.injector;
|
||||
@@ -102,13 +108,13 @@ describe('Dialog Injects', () => {
|
||||
const componentType = TestDialogContentComponent;
|
||||
const defaultTitle = 'Default Title';
|
||||
const data = { message: 'Test Message' };
|
||||
|
||||
|
||||
// Act
|
||||
const openDialog = TestBed.runInInjectionContext(() =>
|
||||
injectDialog(componentType, { title: defaultTitle })
|
||||
const openDialog = TestBed.runInInjectionContext(() =>
|
||||
injectDialog(componentType, { title: defaultTitle }),
|
||||
);
|
||||
openDialog({ data });
|
||||
|
||||
|
||||
// Assert
|
||||
const callOptions = mockDialogOpen.mock.calls[0][1];
|
||||
const injector = callOptions.injector;
|
||||
@@ -119,13 +125,13 @@ describe('Dialog Injects', () => {
|
||||
// Arrange
|
||||
const componentType = TestDialogContentComponent;
|
||||
const data = { message: 'Test Message' };
|
||||
|
||||
|
||||
// Act
|
||||
const openDialog = TestBed.runInInjectionContext(() =>
|
||||
injectDialog(componentType)
|
||||
const openDialog = TestBed.runInInjectionContext(() =>
|
||||
injectDialog(componentType),
|
||||
);
|
||||
openDialog({ data });
|
||||
|
||||
|
||||
// Assert
|
||||
const callOptions = mockDialogOpen.mock.calls[0][1];
|
||||
const injector = callOptions.injector;
|
||||
@@ -136,15 +142,36 @@ describe('Dialog Injects', () => {
|
||||
describe('injectMessageDialog', () => {
|
||||
it('should create a dialog injector for MessageDialogComponent', () => {
|
||||
// Act
|
||||
const openMessageDialog = TestBed.runInInjectionContext(() =>
|
||||
injectMessageDialog()
|
||||
const openMessageDialog = TestBed.runInInjectionContext(() =>
|
||||
injectMessageDialog(),
|
||||
);
|
||||
openMessageDialog({ data: { message: 'Test message' } });
|
||||
|
||||
|
||||
// Assert
|
||||
const callOptions = mockDialogOpen.mock.calls[0][1];
|
||||
const injector = callOptions.injector;
|
||||
expect(injector.get(DIALOG_CONTENT)).toBe(MessageDialogComponent);
|
||||
});
|
||||
});
|
||||
|
||||
describe('injectTextInputDialog', () => {
|
||||
it('should create a dialog injector for TextInputDialogComponent', () => {
|
||||
// Act
|
||||
const openTextInputDialog = TestBed.runInInjectionContext(() =>
|
||||
injectTextInputDialog(),
|
||||
);
|
||||
openTextInputDialog({
|
||||
data: {
|
||||
message: 'Test message',
|
||||
inputLabel: 'Enter value',
|
||||
inputDefaultValue: '',
|
||||
},
|
||||
});
|
||||
|
||||
// Assert
|
||||
const callOptions = mockDialogOpen.mock.calls[0][1];
|
||||
const injector = callOptions.injector;
|
||||
expect(injector.get(DIALOG_CONTENT)).toBe(TextInputDialogComponent);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import { DialogContentDirective } from './dialog-content.directive';
|
||||
import { DialogComponent } from './dialog.component';
|
||||
import { DIALOG_CONTENT, DIALOG_TITLE } from './tokens';
|
||||
import { MessageDialogComponent } from './message-dialog/message-dialog.component';
|
||||
import { TextInputDialogComponent } from './text-input-dialog/text-input-dialog.component';
|
||||
|
||||
export interface InjectDialogOptions {
|
||||
/** Optional title override for the dialog */
|
||||
@@ -106,3 +107,10 @@ export function injectDialog<C extends DialogContentDirective<any, any>>(
|
||||
* @returns A function to open a message dialog
|
||||
*/
|
||||
export const injectMessageDialog = () => injectDialog(MessageDialogComponent);
|
||||
|
||||
/**
|
||||
* Convenience function that returns a pre-configured TextInputDialog injector
|
||||
* @returns A function to open a text input dialog
|
||||
*/
|
||||
export const injectTextInputDialog = () =>
|
||||
injectDialog(TextInputDialogComponent);
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
<p class="isa-text-body-1-regular text-isa-neutral-600" data-what="message">
|
||||
{{ data.message }}
|
||||
</p>
|
||||
<div class="flex flex-col gap-8">
|
||||
<ui-text-field-container>
|
||||
<ui-text-field size="small" class="w-full">
|
||||
<input
|
||||
#inputRef
|
||||
uiInputControl
|
||||
class="isa-text-body-2-bold placeholder:isa-text-body-2-bold"
|
||||
[placeholder]="data?.inputLabel ?? ''"
|
||||
type="text"
|
||||
[formControl]="control"
|
||||
(cleared)="control.setValue('')"
|
||||
(blur)="control.updateValueAndValidity()"
|
||||
(keydown.enter)="close({ inputValue: control.value })"
|
||||
/>
|
||||
|
||||
<ui-text-field-clear></ui-text-field-clear>
|
||||
</ui-text-field>
|
||||
|
||||
@if (data?.inputValidation) {
|
||||
<ui-text-field-errors>
|
||||
@for (validation of data.inputValidation; track validation.errorKey) {
|
||||
@if (control.errors?.[validation.errorKey] && control.touched) {
|
||||
<span>{{ validation.errorText }}</span>
|
||||
}
|
||||
}
|
||||
</ui-text-field-errors>
|
||||
}
|
||||
</ui-text-field-container>
|
||||
|
||||
<div class="flex flex-row gap-2 w-full">
|
||||
<button
|
||||
class="grow"
|
||||
uiButton
|
||||
(click)="close({})"
|
||||
color="secondary"
|
||||
data-what="button"
|
||||
data-which="close"
|
||||
>
|
||||
{{ data.closeText || 'Verlassen' }}
|
||||
</button>
|
||||
<button
|
||||
class="grow"
|
||||
uiButton
|
||||
(click)="close({ inputValue: control.value })"
|
||||
color="primary"
|
||||
data-what="button"
|
||||
data-which="save"
|
||||
[disabled]="control.invalid"
|
||||
>
|
||||
{{ data.confirmText || 'Speichern' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,94 @@
|
||||
import { createComponentFactory, Spectator } from '@ngneat/spectator/jest';
|
||||
import {
|
||||
TextInputDialogComponent,
|
||||
TextInputDialogData,
|
||||
} from './text-input-dialog.component';
|
||||
import { DialogRef, DIALOG_DATA } from '@angular/cdk/dialog'; // Adjust if your tokens are elsewhere
|
||||
import { ButtonComponent } from '@isa/ui/buttons';
|
||||
import {
|
||||
InputControlDirective,
|
||||
TextFieldClearComponent,
|
||||
TextFieldComponent,
|
||||
TextFieldContainerComponent,
|
||||
TextFieldErrorsComponent,
|
||||
} from '@isa/ui/input-controls';
|
||||
import { DialogComponent } from '../dialog.component';
|
||||
|
||||
describe('TextInputDialogComponent', () => {
|
||||
let spectator: Spectator<TextInputDialogComponent>;
|
||||
let mockDialogRef: DialogRef<any>;
|
||||
const mockData: TextInputDialogData = {
|
||||
message: 'Please enter your name',
|
||||
inputLabel: 'Name',
|
||||
inputDefaultValue: 'John Doe',
|
||||
closeText: 'Cancel',
|
||||
confirmText: 'Save',
|
||||
};
|
||||
|
||||
const createComponent = createComponentFactory({
|
||||
component: TextInputDialogComponent,
|
||||
imports: [
|
||||
ButtonComponent,
|
||||
InputControlDirective,
|
||||
TextFieldClearComponent,
|
||||
TextFieldComponent,
|
||||
TextFieldContainerComponent,
|
||||
TextFieldErrorsComponent,
|
||||
],
|
||||
providers: [
|
||||
{ provide: DialogRef, useValue: { close: jest.fn() } },
|
||||
{ provide: DIALOG_DATA, useValue: mockData },
|
||||
{ provide: DialogComponent, useValue: {} },
|
||||
],
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
spectator = createComponent();
|
||||
mockDialogRef = spectator.inject(DialogRef);
|
||||
jest.clearAllMocks();
|
||||
expect(spectator.component).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should render the dialog message', () => {
|
||||
spectator = createComponent();
|
||||
expect(spectator.query('[data-what="message"]')).toHaveText(
|
||||
'Please enter your name',
|
||||
);
|
||||
});
|
||||
|
||||
it('should update the control value when input changes', () => {
|
||||
spectator = createComponent();
|
||||
const input = spectator.query('input') as HTMLInputElement;
|
||||
spectator.typeInElement('Jane', input);
|
||||
spectator.detectChanges();
|
||||
expect(spectator.component.control.value).toBe('Jane');
|
||||
});
|
||||
|
||||
it('should handle error case: inputValidation is undefined', () => {
|
||||
spectator = createComponent({
|
||||
providers: [
|
||||
{
|
||||
provide: DIALOG_DATA,
|
||||
useValue: { ...mockData, inputValidation: undefined },
|
||||
},
|
||||
],
|
||||
});
|
||||
spectator.detectChanges();
|
||||
const input = spectator.query('input');
|
||||
expect(input).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should handle error case: inputLabel is missing', () => {
|
||||
spectator = createComponent({
|
||||
providers: [
|
||||
{
|
||||
provide: DIALOG_DATA,
|
||||
useValue: { ...mockData, inputLabel: undefined },
|
||||
},
|
||||
],
|
||||
});
|
||||
spectator.detectChanges();
|
||||
const input = spectator.query('input');
|
||||
expect(input).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import { ChangeDetectionStrategy, Component } from '@angular/core';
|
||||
|
||||
import { ButtonComponent } from '@isa/ui/buttons';
|
||||
|
||||
import { DialogContentDirective } from '../dialog-content.directive';
|
||||
|
||||
import {
|
||||
ValidatorFn,
|
||||
FormControl,
|
||||
ReactiveFormsModule,
|
||||
AsyncValidatorFn,
|
||||
} from '@angular/forms';
|
||||
import {
|
||||
InputControlDirective,
|
||||
TextFieldClearComponent,
|
||||
TextFieldComponent,
|
||||
TextFieldContainerComponent,
|
||||
TextFieldErrorsComponent,
|
||||
} from '@isa/ui/input-controls';
|
||||
|
||||
/**
|
||||
* Input data for the message dialog
|
||||
*/
|
||||
export interface TextInputDialogData {
|
||||
/** The message text to display in the dialog */
|
||||
message: string;
|
||||
|
||||
/** The input field label to display */
|
||||
inputLabel?: string;
|
||||
|
||||
/** The input field type (e.g., 'text', 'email', 'password', '10') */
|
||||
inputDefaultValue?: string;
|
||||
|
||||
/** Optional validation for the input field */
|
||||
inputValidation?: TextInputValidation[];
|
||||
|
||||
/** Optional custom text for the close button (defaults to "Verlassen" or equivalent) */
|
||||
closeText?: string;
|
||||
|
||||
/** Optional custom text for the confirm button (defaults to "Speichern" or equivalent) */
|
||||
confirmText?: string;
|
||||
}
|
||||
|
||||
export interface TextInputValidation {
|
||||
/** The key to use for the validation error */
|
||||
errorKey: string;
|
||||
|
||||
/** The validation function to apply */
|
||||
inputValidator: ValidatorFn | AsyncValidatorFn;
|
||||
|
||||
/** Validation Error Message */
|
||||
errorText?: string;
|
||||
}
|
||||
|
||||
export interface TextInputDialogResult {
|
||||
/** The value entered in the input field */
|
||||
inputValue?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple message dialog component
|
||||
* Used for displaying informational messages to the user
|
||||
* Returns void when closed (no result)
|
||||
*/
|
||||
@Component({
|
||||
selector: 'ui-text-input-dialog',
|
||||
templateUrl: './text-input-dialog.component.html',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
ReactiveFormsModule,
|
||||
ButtonComponent,
|
||||
InputControlDirective,
|
||||
TextFieldClearComponent,
|
||||
TextFieldComponent,
|
||||
TextFieldContainerComponent,
|
||||
TextFieldErrorsComponent,
|
||||
],
|
||||
})
|
||||
export class TextInputDialogComponent extends DialogContentDirective<
|
||||
TextInputDialogData,
|
||||
TextInputDialogResult
|
||||
> {
|
||||
control = new FormControl<string>(
|
||||
this.data?.inputDefaultValue || '',
|
||||
(this.data?.inputValidation
|
||||
?.map((v) => v.inputValidator)
|
||||
.filter(Boolean) as ValidatorFn[]) ?? [],
|
||||
);
|
||||
}
|
||||
56666
package-lock.json
generated
56666
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user