Compare commits

...

31 Commits

Author SHA1 Message Date
Nino
3b0a63a53a fix(remission-data-access, remission-list, remission-add-item-flow): enforce mandatory list type for add-item flow
Remove addToDepartmentList method and ensure items added via search dialog
are always processed as mandatory remission items (ReturnItem) instead of
department suggestions (ReturnSuggestion). This prevents items from being
incorrectly added to department overflow lists when remission is already
started, maintaining data consistency in the WBS system.

Changes:
- Remove addToDepartmentList method from RemissionSearchService
- Update remitItems to use mandatory list type for add-item flow
- Simplify addToRemiList to only use mandatory remission endpoint
- Add addItemFlow parameter to control remission list type behavior

Refs: #4768, #5273, #5280
2025-09-02 14:40:26 +02:00
Nino Righi
327fdc745d Merged PR 1929: fix(remission-quantity-reason): correct dropdown placeholder and remove hardc...
fix(remission-quantity-reason): correct dropdown placeholder and remove hardcoded option

Change dropdown placeholder from "Rückgabegrund" to "Remigrund" for consistency
with application terminology. Remove hardcoded test option from reason dropdown
that was polluting the dropdown list. Extract initial item object to class
property for better maintainability and reusability.

Ref: #5293, #5299
2025-09-01 16:24:54 +00:00
Nino Righi
297ec9100d Merged PR 1928: fix(remission-filter-label): improve filter button label display and default text
fix(remission-filter-label): improve filter button label display and default text

Add default placeholder text "Abteilung auswählen" when no departments
are selected and implement dynamic label width to prevent text truncation.
The label now expands to full width when displaying placeholder text
and constrains to 8rem when showing selected values.

Ref: #5303
2025-09-01 16:24:23 +00:00
Nino Righi
298ab1acbe Merged PR 1927: fix(remission-data-access): remove automatic date defaulting in fetchRemissio...
fix(remission-data-access): remove automatic date defaulting in fetchRemissionReturnReceipts

Remove the automatic default of 7 days ago when no start date is provided
to the fetchRemissionReturnReceipts method. The service now passes the
start parameter directly to the API without modification, allowing the
API or schema to handle date defaults as intended.

This change improves the separation of concerns by moving date handling
logic out of the service layer and updates the corresponding test to
handle both defined and undefined start date scenarios.

Ref: #5256
2025-09-01 16:24:06 +00:00
Nino Righi
fe77a0ea8b Merged PR 1926: feat(libs-ui-dialog-feedback-dialog): add auto-close functionality with confi...
feat(libs-ui-dialog-feedback-dialog): add auto-close functionality with configurable delay

Implement automatic dialog closure after a configurable delay period.
The dialog now auto-closes by default after 1500ms, with options to
disable auto-close or customize the delay duration through the
FeedbackDialogData interface.

- Add autoClose and autoCloseDelay properties to FeedbackDialogData
- Implement auto-close logic using RxJS asapScheduler in constructor
- Add comprehensive test coverage for auto-close behavior
- Update JSDoc documentation for better clarity

Ref: #5297
2025-09-01 15:01:21 +00:00
Nino Righi
48f588f53b Merged PR 1925: fix(remission-shared-search-item-to-remit-dialog): display context-aware feed...
fix(remission-shared-search-item-to-remit-dialog): display context-aware feedback message

Update feedback dialog message to reflect the current remission state.
Shows "Wurde zum Warenbegleitschein hinzugefügt" when remission is already
started, otherwise shows "Wurde zur Remi Liste hinzugefügt".

This provides users with more accurate feedback about where their items
were added based on the current workflow state.

Ref: #5300
2025-09-01 15:00:54 +00:00
Nino Righi
7f4af304ac Merged PR 1924: feat(isa-app-shell): improve navigation link targeting for remission sub-routes
feat(isa-app-shell): improve navigation link targeting for remission sub-routes

Replace generic routerLinkActive with specific regex patterns for remission
navigation items to ensure accurate active state highlighting. This change:

- Uses sharedRegexRouterLinkActive for "Remission" sub-item to match specific routes
- Uses sharedRegexRouterLinkActive for "Warenbegleitscheine" sub-item
- Replaces broad routerLinkActive with precise regex patterns
- Ensures navigation accurately reflects current route state for remission workflows

The regex patterns specifically target `/[tabId]/remission/(mandatory|department)`
and `/[tabId]/remission/return-receipt` routes for better user experience.

Ref: #5304
2025-09-01 15:00:22 +00:00
Nino Righi
643b2b0e60 Merged PR 1923: feat(remission): remove Koerperlos remission list type
feat(remission): remove Koerperlos remission list type

Remove the 'Körperlose Remi' option from remission list types as it's no longer needed. This simplifies the remission type selection by:

- Removing Koerperlos from RemissionListType constant
- Eliminating disabled state logic in dropdown component
- Removing special handling in changeRemissionType method
- Fixing label text from 'Abteilungen' to 'Abteilung' for consistency

The dropdown now only shows the two active remission types: Pflichtremission and Abteilungsremission.

Ref: #5303
2025-09-01 14:59:57 +00:00
Nino Righi
cd1ff5f277 Merged PR 1922: feat(libs-shared-product-format): remove truncate class from format detail text
feat(libs-shared-product-format): remove truncate class from format detail text

Remove the 'truncate' class from the span element containing formatDetail()
to allow full text display without text clipping. This improves readability
when product format details contain longer descriptions.

Ref: #5301
2025-09-01 14:59:28 +00:00
Nino Righi
46c70cae3e Merged PR 1921: feat(remission-list-resource): remove client-side date sorting in favor of ba...
feat(remission-list-resource): remove client-side date sorting in favor of backend sorting

Remove commented-out date sorting logic from sortResponseResult function.
The sorting by creation date for Pflichtremission and by SORT number
for Abteilungsremission is now handled entirely by the backend,
eliminating the need for client-side date sorting operations.

This change improves performance by reducing client-side processing
and ensures consistent sorting behavior across the application.

Ref: #5295
2025-09-01 14:59:07 +00:00
Nino
d2dcf638e3 Merge tag '4.0-hotfix-release' 2025-08-14 16:47:48 +02:00
Nino
a4241cbd7a Merge branch 'release/4.0' 2025-08-14 16:40:46 +02:00
Nino Righi
dd3705f8bc Merged PR 1920: feat(remission-list): add navigation to default list and clear input on reload
feat(remission-list): add navigation to default list and clear input on reload

Add Router injection and injectTabId for navigation functionality.
Implement navigateToDefaultRemissionList method to redirect users
to the default remission list using the current activated tab ID.
Clear query input on reload trigger to prevent stale search results
from persisting across navigation.

Enhance emptySearchResultEffect to handle department list navigation
when no items are found and remission hasn't started yet.

Ref: #5273
2025-08-14 14:06:20 +00:00
Nino Righi
514715589b Merged PR 1919: feat(remission): add impediment management and UI enhancements for remission...
feat(remission): add impediment management and UI enhancements for remission list

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

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

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

Refs: #5275, #5038
2025-08-14 14:05:01 +00:00
Nino Righi
0740273dbc Merged PR 1917: feat(remission-data-access): enhance stock calculation to handle zero predefi...
feat(remission-data-access): enhance stock calculation to handle zero predefined quantities

Improve calculateStockToRemit and getStockToRemit functions to properly distinguish
between undefined and zero predefined return quantities. When predefinedReturnQuantity
is undefined, the system now falls back to approximation calculation (availableStock
minus remainingQuantityInStock). When predefinedReturnQuantity is explicitly set to 0,
the system respects this backend-calculated value.

Add comprehensive test coverage for edge cases including:
- Zero predefined return quantities for both Pflicht and Abteilung types
- Negative approximation calculations (clamped to 0)
- Null/undefined remainingQuantityInStock handling
- Missing returnItem scenarios for Abteilung type

Ref: #5280
2025-08-13 13:39:18 +00:00
Nino Righi
bbb9c5d39c Merged PR 1918: feat(libs-ui-label, remission-shared-product, storybook): add UI label compon...
feat(libs-ui-label, remission-shared-product, storybook): add UI label component for remission tags

- Create new @isa/ui/label library with primary/secondary appearances
- Integrate label component into ProductInfoComponent to display remission tags (Prio 1, Prio 2, Pflicht)
- Add conditional rendering based on RemissionItemTags enum with proper appearance mapping
- Include comprehensive unit tests using Vitest and Angular Testing Utilities
- Add Storybook stories for both label component and updated product info component
- Import label styles in main tailwind.scss

Ref: #5268
2025-08-13 13:38:13 +00:00
Nino Righi
f0bd957a07 Merged PR 1914: Rückmerge Release/4.0 -> Develop
Rückmerge Release/4.0 -> Develop
2025-08-13 09:52:15 +00:00
Nino Righi
e4f289c67d Merged PR 1915: fix(remission-list-resource): only apply default sorting when no orderBy spec...
fix(remission-list-resource): only apply default sorting when no orderBy specified

Replace default sort mechanism to respect explicit orderBy from QueryToken.
Previously, the resource always applied manual sorting regardless of whether
explicit ordering was requested, causing conflicts with user-defined sorting.

Now checks if queryToken.orderBy exists and has items before applying the
default sort behavior (manually-added items first, then by created date).

Refs: #5276
2025-08-13 09:51:25 +00:00
Nino Righi
2af16d92ea Merged PR 1916: fix(remission-helpers, remission-list-item): fix predefinedReturnQuantity han...
fix(remission-helpers, remission-list-item): fix predefinedReturnQuantity handling and enhance stock validation

Fix issue where predefinedReturnQuantity value of 0 was being treated differently
from undefined in mandatory remission (Pflichtremission). Now both 0 and undefined
are handled consistently by changing the initial value to undefined and using
truthy check instead of strict undefined comparison.

Additionally enhance hasStockToRemit validation by requiring both availableStock
and stockToRemit to be greater than 0, preventing invalid remission states when
no stock is available.

Changes:
- Change predefinedReturnQuantity initial value from 0 to undefined in getStockToRemit
- Remove nullish coalescing operator that forced 0 default for predefinedReturnQuantity
- Update calculateStockToRemit to use truthy check (!predefinedReturnQuantity)
  instead of strict undefined comparison
- Enhance hasStockToRemit computed property to validate both availableStock > 0
  and stockToRemit > 0
- Add comprehensive test coverage for all hasStockToRemit edge cases including
  negative values and zero combinations

Ref: #5269
2025-08-13 09:50:18 +00:00
Nino Righi
99e8e7cfe0 Merged PR 1913: feat(remission): refactor return receipt details and extract shared actions
feat(remission): refactor return receipt details and extract shared actions

Refactor remission return receipt details to use return-based data flow
instead of individual receipt fetching. Extract reusable action components
for better code organization and consistency.

- Remove deprecated fetchRemissionReturnReceipt method and schema
- Add helper functions for extracting data from return objects
- Replace receipt-specific components with return-based equivalents
- Create shared return-receipt-actions library with reusable components
- Update components to use modern Angular patterns (signals, computed)
- Improve data flow consistency across remission features
- Add comprehensive test coverage for new components
- Update eager loading support in fetch return functionality

The new architecture provides better data consistency and reduces
code duplication by centralizing receipt actions and data extraction
logic into reusable components.

Refs: #5242, #5138, #5232, #5241
2025-08-12 13:32:57 +00:00
Nino Righi
ac728f2dd9 Merged PR 1912: hotfix(isa-app-ui/shared-searchbox): improve component initialization and met...
hotfix(isa-app-ui/shared-searchbox): improve component initialization and method safety

Enhance searchbox component reliability by addressing initialization
issues and improving method safety across both shared and ui implementations.

Key changes:
- Fix potential null reference errors in cancel search functionality
- Improve method parameter typing with explicit defaults
- Add proper initialization for ControlValueAccessor callbacks
- Enhance component property initialization with explicit types
- Add hintCleared output event for better hint management

These changes resolve runtime errors and improve type safety
for the searchbox components used throughout the application.

Refs: #5245
2025-08-07 17:55:25 +00:00
Nino
2e012a124a chore(package-lock): Update Package Lock JSON 2025-08-07 14:21:57 +02:00
Nino Righi
d22e320294 Merged PR 1910: feat(remission-list, ui-tooltip): add info tooltip with performance optimization
feat(remission-list, ui-tooltip): add info tooltip with performance optimization

Add tooltip to department capacity info button with enhanced trigger management.
Optimize department list fetching to only load when search input or department
filter is active, improving initial load performance.

- Add tooltip directive to info button showing capacity details
- Implement conditional department list fetching based on input/filter presence
- Enhance tooltip directive with improved trigger management and positioning
- Update tooltip component to use modern Angular control flow syntax
- Add proper show/hide logic with trigger-specific behavior

Refs: #5255
2025-08-06 16:02:27 +00:00
Nino Righi
a0f24aac17 Merged PR 1909: fix(remission-data-access, remission-product-stock-info): improve stock infor...
fix(remission-data-access, remission-product-stock-info): improve stock information display and data handling

Enhance product stock info component with proper loading states.

- Add stockFetching input to ProductStockInfoComponent for loading states
- Update remission list components to properly handle stock fetching state
- Enhance type safety and documentation for better maintainability

The RemissionSearchService now provides clear documentation for all
methods including fetchList, fetchQuerySettings, and capacity fetching
operations. The ProductStockInfoComponent now properly displays loading
states during stock data retrieval.

Ref: #5243
2025-08-06 16:01:10 +00:00
Nino Righi
7ae484fc83 Merged PR 1908: feat(remission-shared-dialog): add dynamic dropdown label for return reason s...
feat(remission-shared-dialog): add dynamic dropdown label for return reason selection

Implement computed property to show selected reason value or default placeholder
text in the dropdown label. This provides better UX by displaying the current
selection instead of a static label.

- Add dropdownLabel computed property that returns selected reason or fallback
- Update template to use dynamic label binding instead of hardcoded text
- Enhances user feedback when reason is selected vs. when no selection is made

Ref: #5253
2025-08-06 15:58:44 +00:00
Nino Righi
0dcb31973f Merged PR 1907: feat(remission-list-item, ui-dialog): enhance quantity dialog with original v...
feat(remission-list-item, ui-dialog): enhance quantity dialog with original value display

Add support for displaying original remission quantity in the quantity change dialog.
This provides better context for users when modifying remission quantities by showing
both the current input and the original calculated value.

Changes:
- Add subMessage and subMessageValue inputs to NumberInputComponent and dialog interfaces
- Update RemissionListItemActionsComponent to pass original quantity context to dialog
- Modify RemissionListItemComponent to track quantity differences and pass stockToRemit value
- Add selectedQuantityDiffersFromStockToRemit computed property for UI state management
- Update component templates to display contextual information in quantity dialogs

Ref: #5204
2025-08-06 15:58:10 +00:00
Nino Righi
c2f393d249 Merged PR 1911: hotfix(isa-app-store, core-storage): prevent caching of erroneous user state
hotfix(isa-app-store, core-storage): prevent caching of erroneous user state

Remove shareReplay(1) operator from user state observable to ensure
fresh state retrieval on each request. This prevents the system from
retaining and reusing failed or invalid state data across multiple
operations.

The current implementation now makes two API calls (GET + POST) per
set operation to guarantee the latest state is always used, trading
performance for reliability in error scenarios.

Refs: #5270, #5249
2025-08-06 15:47:49 +00:00
Nino Righi
2dbf7dda37 Merged PR 1906: feat(remission-data-access, remission-start-dialog): refactor remission workf...
feat(remission-data-access, remission-start-dialog): refactor remission workflow to use createRemission API

Replace the startRemission method with separate createRemission and assignPackage operations.
The new implementation improves error handling and provides better separation of concerns
between return creation and package assignment steps.

Key changes:
- Add CreateRemission interface to models with support for validation error properties
- Replace startRemission with createRemission method that handles return and receipt creation
- Update service methods to return ResponseArgs objects with proper error handling
- Enhance dialog components with reactive error handling using Angular effects
- Add comprehensive server-side validation error display in form controls
- Separate package assignment into dedicated step with individual loading states
- Improve test coverage with proper mocking of new service methods

The refactored workflow provides better user feedback for validation errors and maintains
the existing two-step process while improving maintainability and error handling.

Ref: #5251
2025-08-05 10:42:45 +00:00
Nino Righi
cce15a2137 Merged PR 1905: feat(remission-data-access, remission-list-item): add remission item source t...
feat(remission-data-access, remission-list-item): add remission item source tracking and delete functionality

Add comprehensive remission item source management with the ability to delete
manually added items from return receipts. Introduces new RemissionItemSource
model to track item origins and refactors remission list item components for
better action management.

Key changes:
- Add RemissionItemSource model with 'manually-added' and 'DisposalListModule' types
- Extend ReturnItem and ReturnSuggestion interfaces with source property
- Implement deleteReturnItem service method with comprehensive error handling
- Create RemissionListItemActionsComponent for managing item-specific actions
- Add conditional display logic for delete buttons based on item source
- Refactor RemissionListItemSelectComponent with hasStockToRemit input validation
- Add deleteRemissionListItemInProgress state management across components
- Include comprehensive test coverage for new delete functionality

This enhancement enables users to remove manually added items from remission
lists while preserving system-generated entries, improving workflow flexibility
and data integrity.

Ref: 5259
2025-08-04 11:31:05 +00:00
Nino Righi
14a5a67a1e Merged PR 1904: feat(utils-ean-validation, remission-list): add EAN validation library and im...
feat(utils-ean-validation, remission-list): add EAN validation library and implement exact search

Create new EAN validation utility library with validator function and isEan helper.
Implement exact search functionality for remission lists that bypasses filters
when scanning EAN codes or performing exact searches.

Changes:
- Add new utils/ean-validation library with EAN regex validation
- Export eanValidator for Angular reactive forms integration
- Export isEan utility function for EAN validation checks
- Configure library with Vitest for testing
- Update remission list resource to support exact search mode
- Clear filters and orderBy when performing EAN-based searches
- Add data attributes to product info component for E2E testing

Ref: #5128
2025-08-01 13:22:41 +00:00
Nino Righi
0addf392b6 Merged PR 1901: hotfix(return-summary): disable navigation during return processing
hotfix(return-summary): disable navigation during return processing

Replace Router navigation with Location.back() for better UX and add
disabled states to prevent user actions during pending operations.

Changes:
- Replace navigateBack() method with direct Location.back() calls
- Add returnItemsAndPrintReciptPending input to ReturnSummaryItemComponent
- Disable edit and back buttons when return operation is pending
- Update parent component to pass pending state to child components
- Fix template binding to use computed pending status signal

This prevents users from navigating away during critical return
operations and provides consistent disabled states across the UI.

Ref: #5257
2025-07-31 16:41:59 +00:00
174 changed files with 41700 additions and 38534 deletions

View File

@@ -1,18 +1,18 @@
import { Injectable } from '@angular/core';
import { Logger, LogLevel } from '@core/logger';
import { Store } from '@ngrx/store';
import { debounceTime, switchMap, takeUntil } from 'rxjs/operators';
import { RootState } from './root.state';
import packageInfo from 'packageJson';
import { environment } from '../../environments/environment';
import { Subject } from 'rxjs';
import { AuthService } from '@core/auth';
import { injectStorage, UserStorageProvider } from '@isa/core/storage';
import { isEqual } from 'lodash';
import { Injectable } from "@angular/core";
import { Logger, LogLevel } from "@core/logger";
import { Store } from "@ngrx/store";
import { debounceTime, switchMap, takeUntil } from "rxjs/operators";
import { RootState } from "./root.state";
import packageInfo from "packageJson";
import { environment } from "../../environments/environment";
import { Subject } from "rxjs";
import { AuthService } from "@core/auth";
import { injectStorage, UserStorageProvider } from "@isa/core/storage";
import { isEqual } from "lodash";
@Injectable({ providedIn: 'root' })
@Injectable({ providedIn: "root" })
export class RootStateService {
static LOCAL_STORAGE_KEY = 'ISA_APP_INITIALSTATE';
static LOCAL_STORAGE_KEY = "ISA_APP_INITIALSTATE";
#storage = injectStorage(UserStorageProvider);
@@ -29,14 +29,17 @@ export class RootStateService {
);
}
window['clearUserState'] = () => {
window["clearUserState"] = () => {
this.clear();
};
}
async init() {
await this.load();
this._store.dispatch({ type: 'HYDRATE', payload: RootStateService.LoadFromLocalStorage() });
this._store.dispatch({
type: "HYDRATE",
payload: RootStateService.LoadFromLocalStorage(),
});
this.initSave();
}
@@ -50,14 +53,10 @@ export class RootStateService {
const data = {
...state,
version: packageInfo.version,
sub: this._authService.getClaimByKey('sub'),
sub: this._authService.getClaimByKey("sub"),
};
RootStateService.SaveToLocalStorageRaw(JSON.stringify(data));
return this.#storage.set('state', {
...state,
version: packageInfo.version,
sub: this._authService.getClaimByKey('sub'),
});
return this.#storage.set("state", data);
}),
)
.subscribe();
@@ -68,7 +67,7 @@ export class RootStateService {
*/
async load(): Promise<boolean> {
try {
const res = await this.#storage.get('state');
const res = await this.#storage.get("state");
const storageContent = RootStateService.LoadFromLocalStorageRaw();
@@ -88,7 +87,7 @@ export class RootStateService {
async clear() {
try {
this._cancelSave.next();
await this.#storage.clear('state');
await this.#storage.clear("state");
await new Promise((resolve) => setTimeout(resolve, 100));
RootStateService.RemoveFromLocalStorage();
await new Promise((resolve) => setTimeout(resolve, 100));
@@ -112,7 +111,7 @@ export class RootStateService {
try {
return JSON.parse(raw);
} catch (error) {
console.error('Error parsing local storage:', error);
console.error("Error parsing local storage:", error);
this.RemoveFromLocalStorage();
}
}

View File

@@ -16,20 +16,20 @@ import {
forwardRef,
Optional,
inject,
} from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
import { UiAutocompleteComponent } from '@ui/autocomplete';
import { UiFormControlDirective } from '@ui/form-control';
import { containsElement } from '@utils/common';
import { Subscription } from 'rxjs';
import { ScanAdapterService } from '@adapter/scan';
import { injectCancelSearch } from '@shared/services/cancel-subject';
import { EnvironmentService } from '@core/environment';
} from "@angular/core";
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from "@angular/forms";
import { UiAutocompleteComponent } from "@ui/autocomplete";
import { UiFormControlDirective } from "@ui/form-control";
import { containsElement } from "@utils/common";
import { Subscription } from "rxjs";
import { ScanAdapterService } from "@adapter/scan";
import { injectCancelSearch } from "@shared/services/cancel-subject";
import { EnvironmentService } from "@core/environment";
@Component({
selector: 'shared-searchbox',
templateUrl: 'searchbox.component.html',
styleUrls: ['searchbox.component.scss'],
selector: "shared-searchbox",
templateUrl: "searchbox.component.html",
styleUrls: ["searchbox.component.scss"],
providers: [
{
provide: NG_VALUE_ACCESSOR,
@@ -49,9 +49,9 @@ export class SearchboxComponent
cancelSearch = injectCancelSearch({ optional: true });
disabled: boolean;
type = 'text';
type = "text";
@ViewChild('input', { read: ElementRef, static: true })
@ViewChild("input", { read: ElementRef, static: true })
input: ElementRef;
@ContentChild(UiAutocompleteComponent)
@@ -61,9 +61,9 @@ export class SearchboxComponent
focusAfterViewInit = true;
@Input()
placeholder = '';
placeholder = "";
private _query = '';
private _query = "";
@Input()
get query() {
@@ -94,7 +94,7 @@ export class SearchboxComponent
scanner = false;
@Input()
hint = '';
hint = "";
@Input()
autocompleteValueSelector: (item: any) => string = (item: any) => item;
@@ -104,11 +104,11 @@ export class SearchboxComponent
}
clear(): void {
this.setQuery('');
this.setQuery("");
this.cancelSearch();
}
@HostBinding('class.autocomplete-opend')
@HostBinding("class.autocomplete-opend")
get autocompleteOpen() {
return this.autocomplete?.opend;
}
@@ -213,13 +213,13 @@ export class SearchboxComponent
}
clearHint() {
this.hint = '';
this.hint = "";
this.focused.emit(true);
this.cdr.markForCheck();
}
onKeyup(event: KeyboardEvent) {
if (event.key === 'Enter') {
if (event.key === "Enter") {
if (this.autocomplete?.opend && this.autocomplete?.activeItem) {
this.setQuery(this.autocomplete?.activeItem?.item);
this.autocomplete?.close();
@@ -227,7 +227,7 @@ export class SearchboxComponent
this.search.emit(this.query);
event.preventDefault();
} else if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
} else if (event.key === "ArrowUp" || event.key === "ArrowDown") {
this.handleArrowUpDownEvent(event);
}
}
@@ -242,7 +242,7 @@ export class SearchboxComponent
}
}
@HostListener('window:click', ['$event'])
@HostListener("window:click", ["$event"])
focusLost(event: MouseEvent) {
if (
this.autocomplete?.opend &&
@@ -256,9 +256,11 @@ export class SearchboxComponent
this.search.emit(this.query);
}
@HostListener('focusout', ['$event'])
@HostListener("focusout", ["$event"])
onBlur() {
this.onTouched();
if (typeof this.onTouched === "function") {
this.onTouched();
}
this.focused.emit(false);
this.cdr.markForCheck();
}

View File

@@ -323,7 +323,8 @@
'remission',
]"
(isActiveChange)="focusSearchBox()"
routerLinkActive="active"
sharedRegexRouterLinkActive="active"
sharedRegexRouterLinkActiveTest="^\/\d*\/remission\/(mandatory|department)"
>
<span class="side-menu-group-item-icon"> </span>
<span class="side-menu-group-item-label">Remission</span>
@@ -338,7 +339,8 @@
'return-receipt',
]"
(isActiveChange)="focusSearchBox()"
routerLinkActive="active"
sharedRegexRouterLinkActive="active"
sharedRegexRouterLinkActiveTest="^\/\d*\/remission\/return-receipt"
>
<span class="side-menu-group-item-icon"> </span>
<span class="side-menu-group-item-label">Warenbegleitscheine</span>

View File

@@ -18,6 +18,7 @@
@import "../../../libs/ui/search-bar/src/search-bar.scss";
@import "../../../libs/ui/skeleton-loader/src/skeleton-loader.scss";
@import "../../../libs/ui/tooltip/src/tooltip.scss";
@import "../../../libs/ui/label/src/label.scss";
.input-control {
@apply rounded border border-solid border-[#AEB7C1] px-4 py-[1.125rem] outline-none;

View File

@@ -16,20 +16,20 @@ import {
forwardRef,
Optional,
inject,
} from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
import { UiAutocompleteComponent } from '@ui/autocomplete';
import { UiFormControlDirective } from '@ui/form-control';
import { Subscription } from 'rxjs';
import { ScanAdapterService } from '@adapter/scan';
import { injectCancelSearch } from '@shared/services/cancel-subject';
import { containsElement } from '@utils/common';
import { EnvironmentService } from '@core/environment';
} from "@angular/core";
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from "@angular/forms";
import { UiAutocompleteComponent } from "@ui/autocomplete";
import { UiFormControlDirective } from "@ui/form-control";
import { Subscription } from "rxjs";
import { ScanAdapterService } from "@adapter/scan";
import { injectCancelSearch } from "@shared/services/cancel-subject";
import { containsElement } from "@utils/common";
import { EnvironmentService } from "@core/environment";
@Component({
selector: 'ui-searchbox',
templateUrl: 'searchbox.component.html',
styleUrls: ['searchbox.component.scss'],
selector: "ui-searchbox",
templateUrl: "searchbox.component.html",
styleUrls: ["searchbox.component.scss"],
providers: [
{
provide: NG_VALUE_ACCESSOR,
@@ -49,9 +49,9 @@ export class UiSearchboxNextComponent
private readonly _cancelSearch = injectCancelSearch({ optional: true });
disabled: boolean;
type = 'text';
type = "text";
@ViewChild('input', { read: ElementRef, static: true })
@ViewChild("input", { read: ElementRef, static: true })
input: ElementRef;
@ContentChild(UiAutocompleteComponent)
@@ -61,9 +61,9 @@ export class UiSearchboxNextComponent
focusAfterViewInit: boolean = true;
@Input()
placeholder: string = '';
placeholder: string = "";
private _query = '';
private _query = "";
@Input()
get query() {
@@ -94,7 +94,7 @@ export class UiSearchboxNextComponent
scanner = false;
@Input()
hint: string = '';
hint: string = "";
@Output()
hintCleared = new EventEmitter<void>();
@@ -107,11 +107,11 @@ export class UiSearchboxNextComponent
}
clear(): void {
this.setQuery('');
this.setQuery("");
this._cancelSearch();
}
@HostBinding('class.autocomplete-opend')
@HostBinding("class.autocomplete-opend")
get autocompleteOpen() {
return this.autocomplete?.opend;
}
@@ -212,14 +212,14 @@ export class UiSearchboxNextComponent
}
clearHint() {
this.hint = '';
this.hint = "";
this.focused.emit(true);
this.hintCleared.emit();
this.cdr.markForCheck();
}
onKeyup(event: KeyboardEvent) {
if (event.key === 'Enter') {
if (event.key === "Enter") {
if (this.autocomplete?.opend && this.autocomplete?.activeItem) {
this.setQuery(this.autocomplete?.activeItem?.item);
this.autocomplete?.close();
@@ -227,7 +227,7 @@ export class UiSearchboxNextComponent
this.search.emit(this.query);
event.preventDefault();
} else if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
} else if (event.key === "ArrowUp" || event.key === "ArrowDown") {
this.handleArrowUpDownEvent(event);
}
}
@@ -235,12 +235,14 @@ export class UiSearchboxNextComponent
handleArrowUpDownEvent(event: KeyboardEvent) {
this.autocomplete?.handleKeyboardEvent(event);
if (this.autocomplete?.activeItem) {
const query = this.autocompleteValueSelector(this.autocomplete.activeItem.item);
const query = this.autocompleteValueSelector(
this.autocomplete.activeItem.item,
);
this.setQuery(query, false, false);
}
}
@HostListener('window:click', ['$event'])
@HostListener("window:click", ["$event"])
focusLost(event: MouseEvent) {
if (
this.autocomplete?.opend &&
@@ -254,9 +256,11 @@ export class UiSearchboxNextComponent
this.search.emit(this.query);
}
@HostListener('focusout', ['$event'])
@HostListener("focusout", ["$event"])
onBlur() {
this.onTouched();
if (typeof this.onTouched === "function") {
this.onTouched();
}
this.focused.emit(false);
this.cdr.markForCheck();
}

View File

@@ -50,6 +50,7 @@ const meta: Meta<ProductInfoInputs> = {
value: 19.99,
},
},
tag: 'Prio 2',
},
orientation: 'horizontal',
},
@@ -95,6 +96,7 @@ export const Default: Story = {
value: 29.99,
},
},
tag: 'Prio 2',
},
},
};

View File

@@ -0,0 +1,39 @@
import { argsToTemplate, type Meta, type StoryObj } from '@storybook/angular';
import { Labeltype, LabelPriority, LabelComponent } from '@isa/ui/label';
type UiLabelInputs = {
type: Labeltype;
priority: LabelPriority;
};
const meta: Meta<UiLabelInputs> = {
component: LabelComponent,
title: 'ui/label/Label',
argTypes: {
type: {
control: { type: 'select' },
options: Object.values(Labeltype),
description: 'Determines the label type',
},
priority: {
control: { type: 'select' },
options: Object.values(LabelPriority),
description: 'Determines the label priority',
},
},
args: {
type: 'tag',
priority: 'high',
},
render: (args) => ({
props: args,
template: `<ui-label ${argsToTemplate(args)}>Prio 1</ui-label>`,
}),
};
export default meta;
type Story = StoryObj<LabelComponent>;
export const Default: Story = {
args: {},
};

View File

@@ -1,27 +1,42 @@
import { inject, Injectable } from '@angular/core';
import { StorageProvider } from './storage-provider';
import { UserStateService } from '@generated/swagger/isa-api';
import { firstValueFrom, map, shareReplay } from 'rxjs';
import { inject, Injectable } from "@angular/core";
import { StorageProvider } from "./storage-provider";
import { UserStateService } from "@generated/swagger/isa-api";
import { catchError, firstValueFrom, map, of } from "rxjs";
import { isEmpty } from "lodash";
@Injectable({ providedIn: 'root' })
@Injectable({ providedIn: "root" })
export class UserStorageProvider implements StorageProvider {
#userStateService = inject(UserStateService);
private state$ = this.#userStateService.UserStateGetUserState().pipe(
map((res) => {
if (res.result?.content) {
if (res?.result?.content) {
return JSON.parse(res.result.content);
}
return {};
}),
shareReplay(1),
catchError((err) => {
console.warn(
"No UserStateGetUserState found, returning empty object:",
err,
);
return of({}); // Return empty state fallback
}),
// shareReplay(1), #5249, #5270 Würde beim Fehlerfall den fehlerhaften Zustand behalten
// Aktuell wird nun jedes mal 2 mal der UserState aufgerufen (GET + POST)
// Damit bei der set Funktion immer der aktuelle Zustand verwendet wird
);
async set(key: string, value: unknown): Promise<void> {
async set(key: string, value: Record<string, unknown>): Promise<void> {
const current = await firstValueFrom(this.state$);
firstValueFrom(
const content =
current && !isEmpty(current)
? { ...current, [key]: value }
: { [key]: value };
await firstValueFrom(
this.#userStateService.UserStateSetUserState({
content: JSON.stringify({ ...current, [key]: value }),
content: JSON.stringify(content),
}),
);
}
@@ -32,7 +47,6 @@ export class UserStorageProvider implements StorageProvider {
}
async clear(key: string): Promise<void> {
const current = await firstValueFrom(this.state$);
delete current[key];
firstValueFrom(this.#userStateService.UserStateResetUserState());

View File

@@ -8,14 +8,7 @@ import {
signal,
viewChild,
} from '@angular/core';
import {
AbstractControl,
FormControl,
ReactiveFormsModule,
ValidationErrors,
ValidatorFn,
Validators,
} from '@angular/forms';
import { FormControl, ReactiveFormsModule, Validators } from '@angular/forms';
import {
Product,
ReturnProcessProductQuestion,
@@ -39,16 +32,7 @@ import { isaActionScanner } from '@isa/icons';
import { ScannerButtonComponent } from '@isa/shared/scanner';
import { ProductRouterLinkDirective } from '@isa/shared/product-router-link';
import { toSignal } from '@angular/core/rxjs-interop';
const eanValidator: ValidatorFn = (
control: AbstractControl,
): ValidationErrors | null => {
const value = control.value;
if (value && !/^[0-9]{13}$/.test(value)) {
return { invalidEan: true };
}
return null;
};
import { eanValidator } from '@isa/utils/ean-validation';
@Component({
selector: 'oms-feature-return-process-product-question',

View File

@@ -4,16 +4,14 @@ import { firstValueFrom } from 'rxjs';
import { injectTabId } from '@isa/core/tabs';
import { ReturnTaskListStore } from '@isa/oms/data-access';
import { ReturnReviewComponent } from '../return-review.component';
import { ConfirmationDialogComponent, injectDialog } from '@isa/ui/dialog';
import { injectConfirmationDialog } from '@isa/ui/dialog';
@Injectable({ providedIn: 'root' })
export class UncompletedTasksGuard
implements CanDeactivate<ReturnReviewComponent>
{
#returnTaskListStore = inject(ReturnTaskListStore);
#confirmationDialog = injectDialog(ConfirmationDialogComponent, {
title: 'Aufgaben erledigen',
});
#confirmationDialog = injectConfirmationDialog();
processId = injectTabId();
@@ -45,6 +43,7 @@ export class UncompletedTasksGuard
async openDialog(): Promise<boolean> {
const confirmDialogRef = this.#confirmationDialog({
title: 'Aufgaben erledigen',
data: {
message:
'Bitte schließen Sie die Aufgaben ab bevor Sie das die Rückgabe verlassen',

View File

@@ -35,6 +35,7 @@
name="isaActionEdit"
data-what="button"
data-which="edit-return-item"
(click)="navigateBack()"
[disabled]="returnItemsAndPrintReciptPending()"
(click)="location.back()"
></ui-icon-button>
</div>

View File

@@ -1,16 +1,16 @@
import { createRoutingFactory, Spectator } from '@ngneat/spectator/jest';
import { ReturnSummaryItemComponent } from './return-summary-item.component';
import { MockComponents, MockProvider } from 'ng-mocks';
import { ReturnProductInfoComponent } from '@isa/oms/shared/product-info';
import { createRoutingFactory, Spectator } from "@ngneat/spectator/jest";
import { ReturnSummaryItemComponent } from "./return-summary-item.component";
import { MockComponents, MockProvider } from "ng-mocks";
import { ReturnProductInfoComponent } from "@isa/oms/shared/product-info";
import {
Product,
ReturnProcess,
ReturnProcessQuestionKey,
ReturnProcessService,
} from '@isa/oms/data-access';
import { NgIcon } from '@ng-icons/core';
import { IconButtonComponent } from '@isa/ui/buttons';
import { Router } from '@angular/router';
} from "@isa/oms/data-access";
import { NgIcon } from "@ng-icons/core";
import { IconButtonComponent } from "@isa/ui/buttons";
import { Location } from "@angular/common";
/**
* Creates a mock ReturnProcess with default values that can be overridden
@@ -21,20 +21,20 @@ function createMockReturnProcess(
return {
id: 1,
processId: 1,
productCategory: 'Electronics',
productCategory: "Electronics",
answers: {},
receiptId: 123,
receiptItem: {
id: 321,
product: {
name: 'Test Product',
name: "Test Product",
},
},
...partial,
} as ReturnProcess;
}
describe('ReturnSummaryItemComponent', () => {
describe("ReturnSummaryItemComponent", () => {
let spectator: Spectator<ReturnSummaryItemComponent>;
let returnProcessService: jest.Mocked<ReturnProcessService>;
@@ -48,7 +48,10 @@ describe('ReturnSummaryItemComponent', () => {
providers: [
MockProvider(ReturnProcessService, {
getReturnInfo: jest.fn(),
eligibleForReturn: jest.fn().mockReturnValue({ state: 'eligible' }),
eligibleForReturn: jest.fn().mockReturnValue({ state: "eligible" }),
}),
MockProvider(Location, {
back: jest.fn(),
}),
],
shallow: true,
@@ -64,38 +67,38 @@ describe('ReturnSummaryItemComponent', () => {
spectator.detectChanges();
});
describe('Component Creation', () => {
it('should create the component', () => {
describe("Component Creation", () => {
it("should create the component", () => {
expect(spectator.component).toBeTruthy();
});
});
describe('Return Information Display', () => {
describe("Return Information Display", () => {
const mockReturnInfo = {
itemCondition: 'itemCondition',
returnDetails: { [ReturnProcessQuestionKey.CaseDamaged]: 'no' },
returnReason: 'returnReason',
itemCondition: "itemCondition",
returnDetails: { [ReturnProcessQuestionKey.CaseDamaged]: "no" },
returnReason: "returnReason",
otherProduct: {
ean: 'ean',
ean: "ean",
} as Product,
comment: 'comment',
comment: "comment",
};
beforeEach(() => {
jest
.spyOn(returnProcessService, 'getReturnInfo')
.spyOn(returnProcessService, "getReturnInfo")
.mockReturnValue(mockReturnInfo);
spectator.setInput('returnProcess', createMockReturnProcess({ id: 2 }));
spectator.setInput("returnProcess", createMockReturnProcess({ id: 2 }));
spectator.detectChanges();
});
it('should provide correct return information array', () => {
it("should provide correct return information array", () => {
// Arrange
const expectedInfos = [
'itemCondition',
'returnReason',
'Gehäuse beschädigt: no',
'Geliefert wurde: ean',
'comment',
"itemCondition",
"returnReason",
"Gehäuse beschädigt: no",
"Geliefert wurde: ean",
"comment",
];
// Act
@@ -105,14 +108,14 @@ describe('ReturnSummaryItemComponent', () => {
expect(actualInfos).toEqual(expectedInfos);
expect(actualInfos.length).toBe(5);
});
it('should render return info items with correct content', () => {
it("should render return info items with correct content", () => {
// Arrange
const expectedInfos = [
'itemCondition',
'returnReason',
'Gehäuse beschädigt: no',
'Geliefert wurde: ean',
'comment',
"itemCondition",
"returnReason",
"Gehäuse beschädigt: no",
"Geliefert wurde: ean",
"comment",
];
// Act
@@ -125,14 +128,14 @@ describe('ReturnSummaryItemComponent', () => {
expect(listItems.length).toBe(expectedInfos.length);
listItems.forEach((item, index) => {
expect(item).toHaveText(expectedInfos[index]);
expect(item).toHaveAttribute('data-info-index', index.toString());
expect(item).toHaveAttribute("data-info-index", index.toString());
});
});
it('should handle undefined return info gracefully', () => {
it("should handle undefined return info gracefully", () => {
// Arrange
returnProcessService.getReturnInfo.mockReturnValue(undefined);
spectator.setInput('returnProcess', createMockReturnProcess({ id: 3 }));
spectator.setInput("returnProcess", createMockReturnProcess({ id: 3 }));
spectator.detectChanges();
// Act
@@ -146,26 +149,26 @@ describe('ReturnSummaryItemComponent', () => {
expect(listItems.length).toBe(0);
});
describe('returnDetails mapping', () => {
it('should map multiple returnDetails keys to correct info strings', () => {
describe("returnDetails mapping", () => {
it("should map multiple returnDetails keys to correct info strings", () => {
const expected = [
'itemCondition',
'returnReason',
'Gehäuse beschädigt: Ja',
'Display beschädigt: Nein',
'Geliefert wurde: ean',
'comment',
"itemCondition",
"returnReason",
"Gehäuse beschädigt: Ja",
"Display beschädigt: Nein",
"Geliefert wurde: ean",
"comment",
];
// Arrange
const details = {
[ReturnProcessQuestionKey.CaseDamaged]: 'Ja',
[ReturnProcessQuestionKey.DisplayDamaged]: 'Nein',
[ReturnProcessQuestionKey.CaseDamaged]: "Ja",
[ReturnProcessQuestionKey.DisplayDamaged]: "Nein",
};
returnProcessService.getReturnInfo.mockReturnValue({
...mockReturnInfo,
returnDetails: details,
});
spectator.setInput('returnProcess', createMockReturnProcess({ id: 4 }));
spectator.setInput("returnProcess", createMockReturnProcess({ id: 4 }));
spectator.detectChanges();
// Act
@@ -173,31 +176,31 @@ describe('ReturnSummaryItemComponent', () => {
expect(infos).toEqual(expected);
});
it('should not include returnDetails if empty', () => {
it("should not include returnDetails if empty", () => {
// Arrange
returnProcessService.getReturnInfo.mockReturnValue({
...mockReturnInfo,
returnDetails: {},
});
spectator.setInput('returnProcess', createMockReturnProcess({ id: 5 }));
spectator.setInput("returnProcess", createMockReturnProcess({ id: 5 }));
spectator.detectChanges();
// Act
const infos = spectator.component.returnInfos();
// Assert
expect(infos.some((info) => info.includes('Gehäuse beschädigt'))).toBe(
expect(infos.some((info) => info.includes("Gehäuse beschädigt"))).toBe(
false,
);
expect(infos.some((info) => info.includes('Zubehör fehlt'))).toBe(
expect(infos.some((info) => info.includes("Zubehör fehlt"))).toBe(
false,
);
});
});
});
describe('Navigation', () => {
it('should render edit button with correct attributes', () => {
describe("Navigation", () => {
it("should render edit button with correct attributes", () => {
// Assert
const editButton = spectator.query(
'[data-what="button"][data-which="edit-return-item"]',
@@ -205,7 +208,7 @@ describe('ReturnSummaryItemComponent', () => {
expect(editButton).toExist();
});
it('should navigate back when edit button is clicked', () => {
it("should navigate back when edit button is clicked", () => {
// Arrange
const editButton = spectator.query(
'[data-what="button"][data-which="edit-return-item"]',
@@ -217,25 +220,20 @@ describe('ReturnSummaryItemComponent', () => {
}
// Assert
expect(spectator.inject(Router).navigate).toHaveBeenCalledWith(
['..'],
expect.objectContaining({
relativeTo: expect.anything(),
}),
);
expect(spectator.inject(Location).back).toHaveBeenCalled();
});
});
it('should render the product info component', () => {
it("should render the product info component", () => {
const productInfo = spectator.query(ReturnProductInfoComponent);
expect(productInfo).toExist();
});
it('should compute eligibility state as eligible', () => {
it("should compute eligibility state as eligible", () => {
(returnProcessService.eligibleForReturn as jest.Mock).mockReturnValue({
state: 'eligible',
state: "eligible",
});
spectator.detectChanges();
expect(spectator.component.eligibleForReturn()?.state).toBe('eligible');
expect(spectator.component.eligibleForReturn()?.state).toBe("eligible");
});
});

View File

@@ -4,13 +4,13 @@ import {
computed,
inject,
input,
} from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
} from "@angular/core";
import { Location } from "@angular/common";
import {
isaActionChevronRight,
isaActionClose,
isaActionEdit,
} from '@isa/icons';
} from "@isa/icons";
import {
EligibleForReturn,
EligibleForReturnState,
@@ -18,10 +18,10 @@ import {
ReturnProcessService,
ProductCategory,
returnDetailsMapping,
} from '@isa/oms/data-access';
import { ReturnProductInfoComponent } from '@isa/oms/shared/product-info';
import { IconButtonComponent } from '@isa/ui/buttons';
import { NgIcon, provideIcons } from '@ng-icons/core';
} from "@isa/oms/data-access";
import { ReturnProductInfoComponent } from "@isa/oms/shared/product-info";
import { IconButtonComponent } from "@isa/ui/buttons";
import { NgIcon, provideIcons } from "@ng-icons/core";
/**
* Displays a single item in the return process summary, showing product details
@@ -47,30 +47,34 @@ import { NgIcon, provideIcons } from '@ng-icons/core';
* ```
*/
@Component({
selector: 'oms-feature-return-summary-item',
templateUrl: './return-summary-item.component.html',
styleUrls: ['./return-summary-item.component.scss'],
selector: "oms-feature-return-summary-item",
templateUrl: "./return-summary-item.component.html",
styleUrls: ["./return-summary-item.component.scss"],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ReturnProductInfoComponent, NgIcon, IconButtonComponent],
providers: [
provideIcons({ isaActionChevronRight, isaActionEdit, isaActionClose }),
],
host: {
'data-what': 'list-item',
'data-which': 'return-process-item',
'[attr.data-receipt-id]': 'returnProcess()?.receiptId',
'[attr.data-return-item-id]': 'returnProcess()?.returnItem?.id',
"data-what": "list-item",
"data-which": "return-process-item",
"[attr.data-receipt-id]": "returnProcess()?.receiptId",
"[attr.data-return-item-id]": "returnProcess()?.returnItem?.id",
},
})
export class ReturnSummaryItemComponent {
EligibleForReturnState = EligibleForReturnState;
#returnProcessService = inject(ReturnProcessService);
#router = inject(Router);
#activatedRoute = inject(ActivatedRoute);
/** Angular Location service for navigation */
location = inject(Location);
/** The return process object containing all information about the return */
returnProcess = input.required<ReturnProcess>();
/** The status of the return items and print receipt operation */
returnItemsAndPrintReciptPending = input<boolean>(false);
/**
* Computes whether the current return process is eligible for return.
*
@@ -149,8 +153,4 @@ export class ReturnSummaryItemComponent {
// remove duplicates
return Array.from(new Set(result));
});
navigateBack() {
this.#router.navigate(['..'], { relativeTo: this.#activatedRoute });
}
}

View File

@@ -3,6 +3,7 @@
color="tertiary"
size="small"
class="px-[0.875rem] py-1 min-w-0 bg-white gap-1 absolute top-0 left-0"
[disabled]="returnItemsAndPrintReciptStatusPending()"
(click)="location.back()"
>
<ng-icon name="isaActionChevronLeft" size="1.5rem" class="-ml-2"></ng-icon>
@@ -28,19 +29,22 @@
data-which="return-process-item"
[attr.data-item-id]="item.id"
[attr.data-item-category]="item.productCategory"
[returnItemsAndPrintReciptPending]="
returnItemsAndPrintReciptStatusPending()
"
></oms-feature-return-summary-item>
}
</div>
<div class="mt-6 text-center">
@if (returnItemsAndPrintReciptStatus() !== 'success') {
@if (returnItemsAndPrintReciptStatus() !== "success") {
<button
type="button"
size="large"
uiButton
color="brand"
(click)="returnItemsAndPrintRecipt()"
[pending]="returnItemsAndPrintReciptStatus() === 'pending'"
[disabled]="returnItemsAndPrintReciptStatus() === 'pending'"
[pending]="returnItemsAndPrintReciptStatusPending()"
[disabled]="returnItemsAndPrintReciptStatusPending()"
data-what="button"
data-which="return-and-print"
>

View File

@@ -78,9 +78,17 @@ export class ReturnSummaryComponent {
>(undefined);
/**
* Handles the return and print process for multiple items.
* Computed signal to determine if the return items and print receipt operation is pending.
*
* This method:
* This signal checks the current status of the returnItemsAndPrintReciptStatus signal
* and returns true if the status is 'pending', otherwise false.
*
* @returns {boolean} True if the operation is pending, false otherwise
*/
returnItemsAndPrintReciptStatusPending = computed(() => {
return this.returnItemsAndPrintReciptStatus() === 'pending';
});
/**
* 1. Checks if a return process is already in progress
* 2. Sets status to pending while processing
* 3. Calls the ReturnProcessService to complete the return

View File

@@ -5,7 +5,17 @@ import {
import { RemissionListType } from '@isa/remission/data-access';
describe('calculateStockToRemit', () => {
it('should return predefinedReturnQuantity when provided', () => {
it('should return predefinedReturnQuantity when provided (even if 0) - #5280 Fix', () => {
const result = calculateStockToRemit({
availableStock: 10,
predefinedReturnQuantity: 0,
remainingQuantityInStock: 2,
});
expect(result).toBe(0);
});
it('should return predefinedReturnQuantity when provided with positive value', () => {
const result = calculateStockToRemit({
availableStock: 10,
predefinedReturnQuantity: 5,
@@ -15,7 +25,7 @@ describe('calculateStockToRemit', () => {
expect(result).toBe(5);
});
it('should calculate availableStock minus remainingQuantityInStock when no predefinedReturnQuantity', () => {
it('should calculate availableStock minus remainingQuantityInStock when no predefinedReturnQuantity - #5269 Fix', () => {
const result = calculateStockToRemit({
availableStock: 10,
remainingQuantityInStock: 3,
@@ -23,6 +33,34 @@ describe('calculateStockToRemit', () => {
expect(result).toBe(7);
});
it('should return 0 when approximation calculation would be negative - #5269 Fix', () => {
const result = calculateStockToRemit({
availableStock: 5,
remainingQuantityInStock: 8,
});
expect(result).toBe(0);
});
it('should handle undefined remainingQuantityInStock when no predefinedReturnQuantity - #5269 Fix', () => {
const result = calculateStockToRemit({
availableStock: 10,
remainingQuantityInStock: undefined,
});
expect(result).toBe(10);
});
it('should handle null remainingQuantityInStock when no predefinedReturnQuantity - #5269 Fix', () => {
const result = calculateStockToRemit({
availableStock: 10,
// @ts-ignore - Testing runtime behavior with null
remainingQuantityInStock: null,
});
expect(result).toBe(10);
});
});
describe('getStockToRemit', () => {
@@ -41,6 +79,35 @@ describe('getStockToRemit', () => {
expect(result).toBe(5);
});
it('should handle Pflicht remission list type with zero predefined return quantity - #5280 Fix', () => {
const remissionItem = {
remainingQuantityInStock: 2,
predefinedReturnQuantity: 0,
} as any;
const result = getStockToRemit({
remissionItem,
remissionListType: RemissionListType.Pflicht,
availableStock: 10,
});
expect(result).toBe(0);
});
it('should handle Pflicht remission list type without predefined return quantity - #5269 Fix', () => {
const remissionItem = {
remainingQuantityInStock: 3,
} as any;
const result = getStockToRemit({
remissionItem,
remissionListType: RemissionListType.Pflicht,
availableStock: 10,
});
expect(result).toBe(7);
});
it('should handle Abteilung remission list type with return suggestion', () => {
const remissionItem = {
remainingQuantityInStock: 1,
@@ -59,4 +126,54 @@ describe('getStockToRemit', () => {
expect(result).toBe(8);
});
it('should handle Abteilung remission list type with zero return suggestion - #5280 Fix', () => {
const remissionItem = {
remainingQuantityInStock: 1,
returnItem: {
data: {
predefinedReturnQuantity: 0,
},
},
} as any;
const result = getStockToRemit({
remissionItem,
remissionListType: RemissionListType.Abteilung,
availableStock: 10,
});
expect(result).toBe(0);
});
it('should handle Abteilung remission list type without return suggestion - #5269 Fix', () => {
const remissionItem = {
remainingQuantityInStock: 2,
returnItem: {
data: {},
},
} as any;
const result = getStockToRemit({
remissionItem,
remissionListType: RemissionListType.Abteilung,
availableStock: 10,
});
expect(result).toBe(8);
});
it('should handle Abteilung remission list type with missing returnItem - #5269 Fix', () => {
const remissionItem = {
remainingQuantityInStock: 1,
} as any;
const result = getStockToRemit({
remissionItem,
remissionListType: RemissionListType.Abteilung,
availableStock: 10,
});
expect(result).toBe(9);
});
});

View File

@@ -24,11 +24,11 @@ export const getStockToRemit = ({
availableStock: number;
}): number => {
const remainingQuantityInStock = remissionItem?.remainingQuantityInStock;
let predefinedReturnQuantity: number | undefined = 0;
let predefinedReturnQuantity: number | undefined = undefined;
if (remissionListType === RemissionListType.Pflicht) {
predefinedReturnQuantity =
(remissionItem as ReturnItem)?.predefinedReturnQuantity ?? 0;
predefinedReturnQuantity = (remissionItem as ReturnItem)
?.predefinedReturnQuantity;
}
if (remissionListType === RemissionListType.Abteilung) {
@@ -62,10 +62,12 @@ export const calculateStockToRemit = ({
predefinedReturnQuantity?: number;
remainingQuantityInStock?: number;
}): number => {
// #5269 Fix - Mache Näherungskalkulation, wenn kein predefinedReturnQuantity Wert vom Backend kommt
if (predefinedReturnQuantity === undefined) {
const stockToRemit = availableStock - (remainingQuantityInStock ?? 0);
return stockToRemit < 0 ? 0 : stockToRemit;
}
// #5280 Fix - Ansonsten nehme immer den kalkulierten Wert vom Backend her auch wenn dieser 0 ist
return predefinedReturnQuantity;
};

View File

@@ -0,0 +1,37 @@
import { Return } from '../models';
/**
* Extracts all package numbers from all receipts in a return.
* Only includes package numbers from receipts that have loaded data and where the package data exists.
*
* @param returnData - The return object containing receipts
* @returns Comma-separated string of all package numbers from all receipts, or empty string if no packages found
*
* @example
* ```typescript
* const packageNumbers = getPackageNumbersFromReturn(returnData);
* console.log(`Package numbers: ${packageNumbers}`); // "PKG-001, PKG-002, PKG-003"
* ```
*/
export const getPackageNumbersFromReturn = (returnData: Return): string => {
if (!returnData?.receipts || returnData.receipts.length === 0) {
return '';
}
const allPackageNumbers = returnData.receipts.reduce<string[]>(
(packageNumbers, receipt) => {
const receiptPackages = receipt.data?.packages || [];
// Extract package numbers from loaded packages, filtering out packages without data or packageNumber
const receiptPackageNumbers = receiptPackages
.filter((pkg) => pkg.data?.packageNumber)
.map((pkg) => pkg.data!.packageNumber!);
packageNumbers.push(...receiptPackageNumbers);
return packageNumbers;
},
[],
);
return allPackageNumbers.join(', ');
};

View File

@@ -0,0 +1,20 @@
import { Return } from '../models';
/**
* Helper function to calculate the total item quantity from all receipts in a return.
* If no receipts are present, returns 0.
* @param {Return} returnData - The return object containing receipts
* @return {number} Total item quantity from all receipts
*/
export const getReceiptItemQuantityFromReturn = (
returnData: Return,
): number => {
if (!returnData?.receipts || returnData.receipts.length === 0) {
return 0;
}
return returnData.receipts.reduce((totalItems, receipt) => {
const items = receipt.data?.items;
return totalItems + (items ? items.length : 0);
}, 0);
};

View File

@@ -0,0 +1,35 @@
import { Return } from '../models';
import { ReceiptItem } from '../models';
/**
* Extracts all receipt item data from all receipts in a return.
* Only includes items from receipts that have loaded data and where the item data exists.
*
* @param returnData - The return object containing receipts
* @returns Array of all receipt item data from all receipts, or empty array if no items found
*
* @example
* ```typescript
* const items = getReceiptItemsFromReturn(returnData);
* console.log(`Found ${items.length} receipt items across all receipts`);
* ```
*/
export const getReceiptItemsFromReturn = (
returnData: Return,
): ReceiptItem[] => {
if (!returnData?.receipts || returnData.receipts.length === 0) {
return [];
}
return returnData.receipts.reduce<ReceiptItem[]>((items, receipt) => {
const receiptItems = receipt.data?.items || [];
// Extract only the actual ReceiptItem data, filtering out items without data
const itemData = receiptItems
.filter((item) => item.data !== undefined)
.map((item) => item.data!);
items.push(...itemData);
return items;
}, []);
};

View File

@@ -0,0 +1,21 @@
import { Return } from '../models';
/**
* Helper function to extract and format receipt numbers from a return object.
* Returns "Keine Belege vorhanden" if no receipts, otherwise returns formatted receipt numbers.
*
* @param {Return} returnData - The return object containing receipts
* @returns {string} The formatted receipt numbers or message
*/
export const getReceiptNumberFromReturn = (returnData: Return): string => {
if (!returnData?.receipts || returnData.receipts.length === 0) {
return 'Keine Belege vorhanden';
}
const receiptNumbers = returnData.receipts
.map((receipt) => receipt.data?.receiptNumber)
.filter((receiptNumber) => receiptNumber && receiptNumber.length >= 12)
.map((receiptNumber) => receiptNumber!.substring(6, 12));
return receiptNumbers.length > 0 ? receiptNumbers.join(', ') : '';
};

View File

@@ -0,0 +1,73 @@
import { getReceiptStatusFromReturn } from './get-receipt-status-from-return.helper';
import { ReceiptCompleteStatus, Return } from '../models';
describe('getReceiptStatusFromReturn', () => {
it('should return Offen when no receipts exist', () => {
// Arrange
const returnData: Return = {
receipts: [] as any,
} as Return;
// Act
const result = getReceiptStatusFromReturn(returnData);
// Assert
expect(result).toBe(ReceiptCompleteStatus.Offen);
});
it('should return Offen when receipts array is undefined', () => {
// Arrange
const returnData: Return = {} as Return;
// Act
const result = getReceiptStatusFromReturn(returnData);
// Assert
expect(result).toBe(ReceiptCompleteStatus.Offen);
});
it('should return Abgeschlossen when at least one receipt is completed', () => {
// Arrange
const returnData: Return = {
receipts: [
{ data: { completed: 'Offen' } },
{ data: { completed: 'Abgeschlossen' } },
],
} as Return;
// Act
const result = getReceiptStatusFromReturn(returnData);
// Assert
expect(result).toBe(ReceiptCompleteStatus.Abgeschlossen);
});
it('should return Abgeschlossen when all receipts are incomplete', () => {
// Arrange
const returnData: Return = {
receipts: [
{ data: { completed: 'Abgeschlossen' } },
{ data: { completed: 'Abgeschlossen' } },
],
} as Return;
// Act
const result = getReceiptStatusFromReturn(returnData);
// Assert
expect(result).toBe(ReceiptCompleteStatus.Abgeschlossen);
});
it('should return Offen when receipt data is undefined', () => {
// Arrange
const returnData: Return = {
receipts: [{ data: undefined }, {}],
} as Return;
// Act
const result = getReceiptStatusFromReturn(returnData);
// Assert
expect(result).toBe(ReceiptCompleteStatus.Offen);
});
});

View File

@@ -0,0 +1,28 @@
import {
ReceiptCompleteStatus,
ReceiptCompleteStatusValue,
Return,
} from '../models';
/**
* Helper function to determine the receipt status from a return object.
* Returns 'Offen' if no receipts or all are incomplete, otherwise returns 'Abgeschlossen'.
*
* @param {Return} returnData - The return object containing receipts
* @returns {ReceiptCompleteStatusValue} The completion status of the return
*/
export const getReceiptStatusFromReturn = (
returnData: Return,
): ReceiptCompleteStatusValue => {
if (!returnData?.receipts || returnData.receipts.length === 0) {
return ReceiptCompleteStatus.Offen;
}
const hasCompletedReceipt = returnData.receipts.some(
(receipt) => receipt.data?.completed,
);
return hasCompletedReceipt
? ReceiptCompleteStatus.Abgeschlossen
: ReceiptCompleteStatus.Offen;
};

View File

@@ -2,3 +2,8 @@ export * from './calc-available-stock.helper';
export * from './calc-stock-to-remit.helper';
export * from './calc-target-stock.helper';
export * from './calc-capacity.helper';
export * from './get-receipt-status-from-return.helper';
export * from './get-receipt-item-quantity-from-return.helper';
export * from './get-receipt-number-from-return.helper';
export * from './get-receipt-items-from-return.helper';
export * from './get-package-numbers-from-return.helper';

View File

@@ -0,0 +1,20 @@
/**
* Interface representing the data required to create a remission.
*/
export interface CreateRemission {
/**
* The unique identifier of the return group.
*/
returnId: number;
/**
* The unique identifier of the receipt.
*/
receiptId: number;
/**
* Map of property names to error messages for validation failures
* Keys represent property names, values contain validation error messages
*/
invalidProperties?: Record<string, string>;
}

View File

@@ -15,3 +15,6 @@ export * from './supplier';
export * from './receipt-return-tuple';
export * from './receipt-return-suggestion-tuple';
export * from './value-tuple-sting-and-integer';
export * from './create-remission';
export * from './remission-item-source';
export * from './receipt-complete-status';

View File

@@ -0,0 +1,8 @@
export const ReceiptCompleteStatus = {
Offen: 'Offen',
Abgeschlossen: 'Abgeschlossen',
} as const;
export type ReceiptCompleteStatusKey = keyof typeof ReceiptCompleteStatus;
export type ReceiptCompleteStatusValue =
(typeof ReceiptCompleteStatus)[ReceiptCompleteStatusKey];

View File

@@ -0,0 +1,8 @@
export const RemissionItemSource = {
ManuallyAdded: 'manually-added',
DisposalListModule: 'DisposalListModule',
} as const;
export type RemissionItemSourceKey = keyof typeof RemissionItemSource;
export type RemissionItemSourceValue =
(typeof RemissionItemSource)[RemissionItemSourceKey];

View File

@@ -1,7 +1,6 @@
export const RemissionListType = {
Pflicht: 'Pflichtremission',
Abteilung: 'Abteilungsremission',
Koerperlos: 'Körperlose Remi',
} as const;
export type RemissionListTypeKey = keyof typeof RemissionListType;

View File

@@ -1,9 +1,11 @@
import { ReturnItemDTO } from '@generated/swagger/inventory-api';
import { Product } from './product';
import { Price } from './price';
import { RemissionItemSourceValue } from './remission-item-source';
export interface ReturnItem extends ReturnItemDTO {
product: Product;
retailPrice: Price;
quantity: number;
source: RemissionItemSourceValue;
}

View File

@@ -1,9 +1,11 @@
import { ReturnSuggestionDTO } from '@generated/swagger/inventory-api';
import { Product } from './product';
import { Price } from './price';
import { RemissionItemSourceValue } from './remission-item-source';
export interface ReturnSuggestion extends ReturnSuggestionDTO {
product: Product;
retailPrice: Price;
quantity: number;
source: RemissionItemSourceValue;
}

View File

@@ -1,51 +0,0 @@
import { z } from 'zod';
/**
* Zod schema for validating remission return receipt fetch parameters.
* Ensures both receiptId and returnId are valid numbers.
*
* @constant
* @type {z.ZodObject}
*
* @example
* const params = FetchRemissionReturnReceiptSchema.parse({
* receiptId: '123',
* returnId: '456'
* });
* // Result: { receiptId: 123, returnId: 456 }
*/
export const FetchRemissionReturnReceiptSchema = z.object({
/**
* The receipt identifier - coerced to number for flexibility.
*/
receiptId: z.coerce.number(),
/**
* The return identifier - coerced to number for flexibility.
*/
returnId: z.coerce.number(),
});
/**
* Type representing the parsed output of FetchRemissionReturnReceiptSchema.
* Contains validated and coerced receiptId and returnId as numbers.
*
* @typedef {Object} FetchRemissionReturnReceipt
* @property {number} receiptId - The validated receipt identifier
* @property {number} returnId - The validated return identifier
*/
export type FetchRemissionReturnReceipt = z.infer<
typeof FetchRemissionReturnReceiptSchema
>;
/**
* Type representing the input parameters for FetchRemissionReturnReceiptSchema.
* Accepts string or number values that can be coerced to numbers.
*
* @typedef {Object} FetchRemissionReturnParams
* @property {string | number} receiptId - The receipt identifier (can be string or number)
* @property {string | number} returnId - The return identifier (can be string or number)
*/
export type FetchRemissionReturnParams = z.input<
typeof FetchRemissionReturnReceiptSchema
>;

View File

@@ -0,0 +1,10 @@
import { z } from 'zod';
export const FetchReturnSchema = z.object({
returnId: z.coerce.number(),
eagerLoading: z.coerce.number().optional(),
});
export type FetchReturn = z.infer<typeof FetchReturnSchema>;
export type FetchReturnParams = z.input<typeof FetchReturnSchema>;

View File

@@ -4,8 +4,9 @@ export * from './assign-package.schema';
export * from './create-receipt.schema';
export * from './create-return.schema';
export * from './fetch-query-settings.schema';
export * from './fetch-remission-return-receipt.schema';
export * from './fetch-remission-return-receipts.schema';
export * from './fetch-stock-in-stock.schema';
export * from './query-token.schema';
export * from './fetch-required-capacity.schema';
export * from './fetch-return.schema';
export * from './update-item-impediment.schema';

View File

@@ -0,0 +1,8 @@
import { z } from 'zod';
export const UpdateItemImpedimentSchema = z.object({
itemId: z.number(),
comment: z.string(),
});
export type UpdateItemImpediment = z.infer<typeof UpdateItemImpedimentSchema>;

View File

@@ -1,6 +1,10 @@
import { inject, Injectable } from '@angular/core';
import { ReturnService } from '@generated/swagger/inventory-api';
import { ResponseArgsError, takeUntilAborted } from '@isa/common/data-access';
import {
ResponseArgs,
ResponseArgsError,
takeUntilAborted,
} from '@isa/common/data-access';
import { subDays } from 'date-fns';
import { firstValueFrom } from 'rxjs';
import { RemissionStockService } from './remission-stock.service';
@@ -14,16 +18,21 @@ import {
CreateReceipt,
CreateReturn,
CreateReturnSchema,
FetchRemissionReturnParams,
FetchRemissionReturnReceiptSchema,
FetchRemissionReturnReceiptsParams,
FetchRemissionReturnReceiptsSchema,
FetchReturnParams,
FetchReturnSchema,
UpdateItemImpediment,
UpdateItemImpedimentSchema,
} from '../schemas';
import {
CreateRemission,
Receipt,
ReceiptReturnSuggestionTuple,
ReceiptReturnTuple,
RemissionListType,
ReturnItem,
ReturnSuggestion,
} from '../models';
import { logger } from '@isa/core/logging';
import { RemissionSupplierService } from './remission-supplier.service';
@@ -55,18 +64,13 @@ export class RemissionReturnReceiptService {
#logger = logger(() => ({ service: 'RemissionReturnReceiptService' }));
/**
* Fetches all completed remission return receipts for the assigned stock.
* Returns receipts marked as completed within the last 7 days.
* Fetches remission return receipts based on the provided parameters.
* Validates parameters using FetchRemissionReturnReceiptsSchema before making the request.
*
* @async
* @param {FetchRemissionReturnReceiptsParams} params - The parameters for fetching the receipts
* @param {AbortSignal} [abortSignal] - Optional signal to abort the request
* @returns {Promise<Return[]>} Array of completed return objects with receipts
* @returns {Promise<Return[]>} An array of remission return receipts
* @throws {ResponseArgsError} When the API request fails
*
* @example
* const controller = new AbortController();
* const completedReturns = await service
* .fetchCompletedRemissionReturnReceipts(controller.signal);
*/
async fetchRemissionReturnReceipts(
params: FetchRemissionReturnReceiptsParams,
@@ -77,22 +81,19 @@ export class RemissionReturnReceiptService {
const { start, returncompleted } =
FetchRemissionReturnReceiptsSchema.parse(params);
// Default to 7 days ago if no start date is provided
const startDate = start ?? subDays(new Date(), 7);
const assignedStock =
await this.#remissionStockService.fetchAssignedStock(abortSignal);
this.#logger.info('Fetching completed returns from API', () => ({
stockId: assignedStock.id,
startDate: startDate.toISOString(),
startDate: start?.toISOString(),
}));
let req$ = this.#returnService.ReturnQueryReturns({
stockId: assignedStock.id,
queryToken: {
filter: { returncompleted: returncompleted ? 'true' : 'false' },
start: startDate.toISOString(),
start: start?.toISOString(),
eagerLoading: 3,
},
});
@@ -120,43 +121,97 @@ export class RemissionReturnReceiptService {
return returns;
}
// /**
// * Fetches a specific remission return receipt by receipt and return IDs.
// * Validates parameters using FetchRemissionReturnReceiptSchema before making the request.
// *
// * @async
// * @param {FetchRemissionReturnParams} params - The receipt and return identifiers
// * @param {FetchRemissionReturnParams} params.receiptId - ID of the receipt to fetch
// * @param {FetchRemissionReturnParams} params.returnId - ID of the return containing the receipt
// * @param {AbortSignal} [abortSignal] - Optional signal to abort the request
// * @returns {Promise<Receipt | undefined>} The receipt object if found, undefined otherwise
// * @throws {ResponseArgsError} When the API request fails
// * @throws {z.ZodError} When parameter validation fails
// *
// * @example
// * const receipt = await service.fetchRemissionReturnReceipt({
// * receiptId: '123',
// * returnId: '456'
// * });
// */
// async fetchRemissionReturnReceipt(
// params: FetchRemissionReturnParams,
// abortSignal?: AbortSignal,
// ): Promise<Receipt | undefined> {
// this.#logger.debug('Fetching remission return receipt', () => ({ params }));
// const { receiptId, returnId } =
// FetchRemissionReturnReceiptSchema.parse(params);
// this.#logger.info('Fetching return receipt from API', () => ({
// receiptId,
// returnId,
// }));
// let req$ = this.#returnService.ReturnGetReturnReceipt({
// receiptId,
// returnId,
// eagerLoading: 2,
// });
// if (abortSignal) {
// this.#logger.debug('Request configured with abort signal');
// req$ = req$.pipe(takeUntilAborted(abortSignal));
// }
// const res = await firstValueFrom(req$);
// if (res?.error) {
// this.#logger.error(
// 'Failed to fetch return receipt',
// new Error(res.message || 'Unknown error'),
// );
// throw new ResponseArgsError(res);
// }
// const receipt = res?.result as Receipt | undefined;
// this.#logger.debug('Successfully fetched return receipt', () => ({
// found: !!receipt,
// }));
// return receipt;
// }
/**
* Fetches a specific remission return receipt by receipt and return IDs.
* Validates parameters using FetchRemissionReturnReceiptSchema before making the request.
* Fetches a remission return by its ID.
* Validates parameters using FetchReturnSchema before making the request.
*
* @async
* @param {FetchRemissionReturnParams} params - The receipt and return identifiers
* @param {FetchRemissionReturnParams} params.receiptId - ID of the receipt to fetch
* @param {FetchRemissionReturnParams} params.returnId - ID of the return containing the receipt
* @param {FetchReturnParams} params - The parameters for fetching the return
* @param {AbortSignal} [abortSignal] - Optional signal to abort the request
* @returns {Promise<Receipt | undefined>} The receipt object if found, undefined otherwise
* @returns {Promise<Return | undefined>} The return object if found, undefined otherwise
* @throws {ResponseArgsError} When the API request fails
* @throws {z.ZodError} When parameter validation fails
*
* @example
* const receipt = await service.fetchRemissionReturnReceipt({
* receiptId: '123',
* returnId: '456'
* });
* const returnData = await service.fetchReturn({ returnId: 123 });
*/
async fetchRemissionReturnReceipt(
params: FetchRemissionReturnParams,
async fetchReturn(
params: FetchReturnParams,
abortSignal?: AbortSignal,
): Promise<Receipt | undefined> {
this.#logger.debug('Fetching remission return receipt', () => ({ params }));
): Promise<Return | undefined> {
this.#logger.debug('Fetching remission return', () => ({ params }));
const { receiptId, returnId } =
FetchRemissionReturnReceiptSchema.parse(params);
const { returnId, eagerLoading = 2 } = FetchReturnSchema.parse(params);
this.#logger.info('Fetching return receipt from API', () => ({
receiptId,
this.#logger.info('Fetching return from API', () => ({
returnId,
}));
let req$ = this.#returnService.ReturnGetReturnReceipt({
receiptId,
let req$ = this.#returnService.ReturnGetReturn({
returnId,
eagerLoading: 2,
eagerLoading,
});
if (abortSignal) {
@@ -168,38 +223,40 @@ export class RemissionReturnReceiptService {
if (res?.error) {
this.#logger.error(
'Failed to fetch return receipt',
'Failed to fetch return',
new Error(res.message || 'Unknown error'),
);
throw new ResponseArgsError(res);
}
const receipt = res?.result as Receipt | undefined;
this.#logger.debug('Successfully fetched return receipt', () => ({
found: !!receipt,
const returnData = res?.result as Return | undefined;
this.#logger.debug('Successfully fetched return', () => ({
found: !!returnData,
}));
return receipt;
return returnData;
}
/**
* Creates a new remission return with an optional receipt number.
* Uses CreateReturnSchema to validate parameters before making the request.
* Creates a new remission return with the specified parameters.
* Validates parameters using CreateReturnSchema before making the request.
*
* @async
* @param {CreateReturn} params - The parameters for creating the return
* @param {AbortSignal} [abortSignal] - Optional signal to abort the request
* @returns {Promise<Return | undefined>} The created return object if successful, undefined otherwise
* @returns {Promise<ResponseArgs<Return> | undefined>} The created return object if successful, undefined otherwise
* @throws {ResponseArgsError} When the API request fails
* @throws {z.ZodError} When parameter validation fails
*
* @example
* const newReturn = await service.createReturn({ returnGroup: 'group1' });
* const returnResponse = await service.createReturn({
* returnGroup: 'group1',
* });
*/
async createReturn(
params: CreateReturn,
abortSignal?: AbortSignal,
): Promise<Return | undefined> {
): Promise<ResponseArgs<Return> | undefined> {
this.#logger.debug('Create remission return', () => ({ params }));
const suppliers =
@@ -245,27 +302,27 @@ export class RemissionReturnReceiptService {
throw new ResponseArgsError(res);
}
const createdReturn = res?.result as Return | undefined;
const returnResponse = res as ResponseArgs<Return> | undefined;
this.#logger.debug('Successfully created return', () => ({
found: !!createdReturn,
found: !!returnResponse,
}));
return createdReturn;
return returnResponse;
}
/**
* Creates a new remission return receipt with the specified parameters.
* Validates parameters using CreateReceiptSchema before making the request.
* Validates parameters using CreateReceipt before making the request.
*
* @async
* @param {CreateReceipt} params - The parameters for creating the receipt
* @param {AbortSignal} [abortSignal] - Optional signal to abort the request
* @returns {Promise<Receipt | undefined>} The created receipt object if successful, undefined otherwise
* @returns {Promise<ResponseArgs<Receipt> | undefined>} The created receipt object if successful, undefined otherwise
* @throws {ResponseArgsError} When the API request fails
* @throws {z.ZodError} When parameter validation fails
*
* @example
* const receipt = await service.createReceipt({
* const receiptResponse = await service.createReceipt({
* returnId: 123,
* receiptNumber: 'ABC-123',
* });
@@ -273,7 +330,7 @@ export class RemissionReturnReceiptService {
async createReceipt(
params: CreateReceipt,
abortSignal?: AbortSignal,
): Promise<Receipt | undefined> {
): Promise<ResponseArgs<Receipt> | undefined> {
this.#logger.debug('Create remission return receipt', () => ({ params }));
const stock =
@@ -318,22 +375,22 @@ export class RemissionReturnReceiptService {
throw new ResponseArgsError(res);
}
const receipt = res?.result as Receipt | undefined;
const receiptResponse = res as ResponseArgs<Receipt> | undefined;
this.#logger.debug('Successfully created return receipt', () => ({
found: !!receipt,
found: !!receiptResponse,
}));
return receipt;
return receiptResponse;
}
/**
* Assigns a package number to an existing return receipt.
* Validates parameters using AssignPackageSchema before making the request.
* Assigns a package to the specified return receipt.
* Validates parameters using AssignPackage before making the request.
*
* @async
* @param {AssignPackage} params - The parameters for assigning the package number
* @param {AssignPackage} params - The parameters for assigning the package
* @param {AbortSignal} [abortSignal] - Optional signal to abort the request
* @returns {Promise<Receipt | undefined>} The updated receipt object if successful, undefined otherwise
* @returns {Promise<ResponseArgs<Receipt> | undefined>} The updated receipt object if successful, undefined otherwise
* @throws {ResponseArgsError} When the API request fails
* @throws {z.ZodError} When parameter validation fails
*
@@ -347,7 +404,7 @@ export class RemissionReturnReceiptService {
async assignPackage(
params: AssignPackage,
abortSignal?: AbortSignal,
): Promise<Receipt | undefined> {
): Promise<ResponseArgs<Receipt> | undefined> {
this.#logger.debug('Assign package to return receipt', () => ({ params }));
const { returnId, receiptId, packageNumber } = params;
@@ -381,12 +438,14 @@ export class RemissionReturnReceiptService {
throw new ResponseArgsError(res);
}
const receipt = res?.result as Receipt | undefined;
this.#logger.debug('Successfully assigned package', () => ({
found: !!receipt,
}));
const receiptWithAssignedPackageResponse = res as
| ResponseArgs<Receipt>
| undefined;
return receipt;
this.#logger.debug('Successfully assigned package', () => ({
found: !!receiptWithAssignedPackageResponse,
}));
return receiptWithAssignedPackageResponse;
}
async removeReturnItemFromReturnReceipt(params: {
@@ -407,6 +466,121 @@ export class RemissionReturnReceiptService {
}
}
/**
* Cancels a return receipt and the associated return.
* Validates parameters before making the request.
*
* @async
* @param {Object} params - The parameters for the cancellation
* @param {number} params.returnId - ID of the return to cancel
* @param {number} params.receiptId - ID of the receipt to cancel
* @return {Promise<void>} Resolves when the cancellation is successful
* @throws {ResponseArgsError} When the API request fails
*/
async cancelReturnReceipt(params: {
returnId: number;
receiptId: number;
}): Promise<void> {
const res = await firstValueFrom(
this.#returnService.ReturnCancelReturnReceipt(params),
);
if (res?.error) {
this.#logger.error(
'Failed to cancel return receipt',
new Error(res.message || 'Unknown error'),
);
throw new ResponseArgsError(res);
}
}
/**
* Completes a single return receipt and the associated return.
* Validates parameters before making the request.
*
* @async
* @returns {Promise<Return>} The completed return object
* @throws {ResponseArgsError} When the API request fails
*/
async cancelReturn(params: { returnId: number }): Promise<void> {
const res = await firstValueFrom(
this.#returnService.ReturnCancelReturn(params),
);
if (res?.error) {
this.#logger.error(
'Failed to cancel return',
new Error(res.message || 'Unknown error'),
);
throw new ResponseArgsError(res);
}
}
async deleteReturnItem(params: { itemId: number }) {
this.#logger.debug('Deleting return item', () => ({ params }));
const res = await firstValueFrom(
this.#returnService.ReturnDeleteReturnItem(params),
);
if (res?.error) {
this.#logger.error(
'Failed to delete return item',
new Error(res.message || 'Unknown error'),
);
throw new ResponseArgsError(res);
}
return res?.result as ReturnItem;
}
async updateReturnItemImpediment(params: UpdateItemImpediment) {
this.#logger.debug('Update return item impediment', () => ({ params }));
const { itemId, comment } = UpdateItemImpedimentSchema.parse(params);
const res = await firstValueFrom(
this.#returnService.ReturnReturnItemImpediment({
itemId,
data: {
comment,
},
}),
);
if (res?.error) {
this.#logger.error(
'Failed to update return item impediment',
new Error(res.message || 'Unknown error'),
);
throw new ResponseArgsError(res);
}
return res?.result as ReturnItem;
}
async updateReturnSuggestionImpediment(params: UpdateItemImpediment) {
this.#logger.debug('Update return suggestion impediment', () => ({
params,
}));
const { itemId, comment } = UpdateItemImpedimentSchema.parse(params);
const res = await firstValueFrom(
this.#returnService.ReturnReturnSuggestionImpediment({
itemId,
data: {
comment,
},
}),
);
if (res?.error) {
this.#logger.error(
'Failed to update return suggestion impediment',
new Error(res.message || 'Unknown error'),
);
throw new ResponseArgsError(res);
}
return res?.result as ReturnSuggestion;
}
async completeReturnReceipt({
returnId,
receiptId,
@@ -458,6 +632,30 @@ export class RemissionReturnReceiptService {
return res?.result as Return;
}
async completeReturnGroup(params: { returnGroup: string }) {
this.#logger.debug('Completing return group', () => ({
returnId: params.returnGroup,
}));
const res = await firstValueFrom(
this.#returnService.ReturnFinalizeReturnGroup(params),
);
if (res?.error) {
this.#logger.error(
'Failed to complete return group',
new Error(res.message || 'Unknown error'),
);
throw new ResponseArgsError(res);
}
this.#logger.info('Successfully completed return group', () => ({
returnId: params.returnGroup,
}));
return res?.result as Return[];
}
async completeReturnReceiptAndReturn(params: {
returnId: number;
receiptId: number;
@@ -627,76 +825,69 @@ export class RemissionReturnReceiptService {
}
/**
* Starts a new remission process by creating a return and receipt.
* Validates parameters using FetchRemissionReturnReceiptSchema before making the request.
* Warenbegleitschein eröffnen
* Creates a remission by generating a return and receipt.
* Validates parameters using CreateRemissionSchema before making the request.
*
* @async
* @param {Object} params - The parameters for starting the remission
* @param {string | undefined} params.returnGroup - Optional group identifier for the return
* @param {string | undefined} params.receiptNumber - Optional receipt number
* @param {string} params.packageNumber - The package number to assign
* @returns {Promise<FetchRemissionReturnParams | undefined>} The created return and receipt identifiers if successful, undefined otherwise
* @param {CreateRemission} params - The parameters for creating the remission
* @returns {Promise<CreateRemission | undefined>} The created remission object if successful, undefined otherwise
* @throws {ResponseArgsError} When the API request fails
* @throws {z.ZodError} When parameter validation fails
*
* @example
* const remission = await service.startRemission({
* returnGroup: 'group1',
* receiptNumber: 'ABC-123',
* packageNumber: 'PKG-789',
* const remission = await service.createRemission({
* returnId: 123,
* receiptId: 456,
* });
*/
async startRemission({
async createRemission({
returnGroup,
receiptNumber,
packageNumber,
}: {
returnGroup: string | undefined;
receiptNumber: string | undefined;
packageNumber: string;
}): Promise<FetchRemissionReturnParams | undefined> {
this.#logger.debug('Starting remission', () => ({
}): Promise<CreateRemission | undefined> {
this.#logger.debug('Create remission', () => ({
returnGroup,
receiptNumber,
packageNumber,
}));
// Warenbegleitschein eröffnen
const createdReturn: Return | undefined = await this.createReturn({
returnGroup,
});
const createdReturn: ResponseArgs<Return> | undefined =
await this.createReturn({
returnGroup,
});
if (!createdReturn) {
if (!createdReturn || !createdReturn.result) {
this.#logger.error('Failed to create return for remission');
return;
}
// Warenbegleitschein eröffnen
const createdReceipt: Receipt | undefined = await this.createReceipt({
returnId: createdReturn.id,
receiptNumber,
});
const createdReceipt: ResponseArgs<Receipt> | undefined =
await this.createReceipt({
returnId: createdReturn.result.id,
receiptNumber,
});
if (!createdReceipt) {
if (!createdReceipt || !createdReceipt.result) {
this.#logger.error('Failed to create return receipt');
return;
}
// Wannennummer zuweisen
await this.assignPackage({
returnId: createdReturn.id,
receiptId: createdReceipt.id,
packageNumber,
});
const invalidProperties = {
...createdReturn.invalidProperties,
...createdReceipt.invalidProperties,
};
this.#logger.info('Successfully started remission', () => ({
returnId: createdReturn.id,
receiptId: createdReceipt.id,
this.#logger.info('Successfully created remission', () => ({
returnId: createdReturn.result.id,
receiptId: createdReceipt.result.id,
}));
return {
returnId: createdReturn.id,
receiptId: createdReceipt.id,
returnId: createdReturn.result.id,
receiptId: createdReceipt.result.id,
invalidProperties,
};
}

View File

@@ -248,8 +248,6 @@ export class RemissionSearchService {
* const response = await service.fetchList({
* assignedStockId: 'stock123',
* supplierId: 'supplier456',
* take: 250,
* skip: 0,
* orderBy: 'itemName'
* });
* console.log(`Total items: ${response.totalCount}`);
@@ -267,8 +265,6 @@ export class RemissionSearchService {
this.#logger.info('Fetching remission list from API', () => ({
stockId: parsed.assignedStockId,
supplierId: parsed.supplierId,
take: 250,
skip: parsed.skip,
}));
let req$ = this.#remiService.RemiPflichtremissionsartikel({
@@ -278,8 +274,6 @@ export class RemissionSearchService {
filter: parsed.filter,
input: parsed.input,
orderBy: parsed.orderBy,
take: 250,
skip: parsed.skip,
},
});
@@ -325,13 +319,10 @@ export class RemissionSearchService {
* const departmentResponse = await service.fetchDepartmentList({
* assignedStockId: 'stock123',
* supplierId: 'supplier456',
* take: 250,
* skip: 0
* });
*
* @todo After fetching, StockInStock should be called in the old DomainRemissionService
*/
// TODO: Im alten DomainRemissionService wird danach StockInStock abgerufen
async fetchDepartmentList(
params: RemissionQueryTokenInput,
abortSignal?: AbortSignal,
@@ -344,8 +335,6 @@ export class RemissionSearchService {
this.#logger.info('Fetching department remission list from API', () => ({
stockId: parsed.assignedStockId,
supplierId: parsed.supplierId,
take: 250,
skip: parsed.skip,
}));
let req$ = this.#remiService.RemiUeberlauf({
@@ -355,8 +344,6 @@ export class RemissionSearchService {
filter: parsed.filter,
input: parsed.input,
orderBy: parsed.orderBy,
take: 250,
skip: parsed.skip,
},
});
@@ -436,7 +423,10 @@ export class RemissionSearchService {
const req$ = this.#remiService.RemiCreateReturnItem({
data: items.map((i) => ({
product: i.item.product,
product: {
...i.item.product,
catalogProductNumber: String(i.item.id),
},
assortment: 'Basissortiment|B',
predefinedReturnQuantity: i.quantity,
retailPrice: i.item.catalogAvailability.price,
@@ -456,38 +446,4 @@ export class RemissionSearchService {
return res.successful?.map((r) => r.value) as ReturnItem[];
}
async addToDepartmentList(
items: { item: Item; quantity: number; reason: string }[],
abortSignal?: AbortSignal,
): Promise<ReturnSuggestion[]> {
const stock = await this.#remiStockService.fetchAssignedStock(abortSignal);
if (!stock) {
this.#logger.error('No assigned stock found for remission items');
throw new Error('No assigned stock found');
}
const req$ = this.#remiService.RemiCreateReturnSuggestions({
data: items.map((i) => ({
product: i.item.product,
assortment: 'Basissortiment|B',
predefinedReturnQuantity: i.quantity,
retailPrice: i.item.catalogAvailability.price,
source: 'manually-added',
returnReason: i.reason,
stock: { id: stock.id },
})),
});
const res = await firstValueFrom(req$);
if (res.error) {
const error = new ResponseArgsError(res);
this.#logger.error('Failed to add to department list', error);
throw error;
}
return res.successful?.map((r) => r.value) as ReturnSuggestion[];
}
}

View File

@@ -8,7 +8,7 @@ describe('RemissionStore', () => {
beforeEach(() => {
const mockRemissionReturnReceiptService = {
fetchRemissionReturnReceipt: jest.fn(),
fetchReturn: jest.fn(),
};
TestBed.configureTestingModule({

View File

@@ -72,52 +72,29 @@ export const RemissionStore = signalStore(
remissionReturnReceiptService = inject(RemissionReturnReceiptService),
) => ({
/**
* Private resource for fetching the current remission receipt.
*
* This resource automatically tracks changes to returnId and receiptId from the store
* and refetches the receipt data when either value changes. The resource returns
* undefined when either ID is not set, preventing unnecessary HTTP requests.
*
* The resource uses the injected RemissionReturnReceiptService to fetch receipt data
* and supports request cancellation via AbortSignal for proper cleanup.
*
* @private
* @returns A resource instance that manages the receipt data fetching lifecycle
*
* @example
* ```typescript
* // Access the resource through computed signals
* const receipt = computed(() => store._receiptResource.value());
* const status = computed(() => store._receiptResource.status());
* const error = computed(() => store._receiptResource.error());
*
* // Manually reload the resource
* store._receiptResource.reload();
* ```
*
* @see {@link https://angular.dev/guide/signals/resource} Angular Resource API documentation
* Resource for fetching the receipt data based on the current receiptId.
* This resource is automatically reloaded when the receiptId changes.
* @returnId is undefined, the resource will not fetch any data.
* @returnId is set, it fetches the receipt data from the service.
*/
_receiptResource: resource({
_fetchReturnResource: resource({
params: () => ({
returnId: store.returnId(),
receiptId: store.receiptId(),
}),
loader: async ({ params, abortSignal }) => {
const { receiptId, returnId } = params;
const { returnId } = params;
if (!receiptId || !returnId) {
if (!returnId) {
return undefined;
}
const receipt =
await remissionReturnReceiptService.fetchRemissionReturnReceipt(
{
returnId,
receiptId,
},
abortSignal,
);
return receipt;
const returnData = await remissionReturnReceiptService.fetchReturn(
{
returnId,
},
abortSignal,
);
return returnData;
},
}),
}),
@@ -126,7 +103,7 @@ export const RemissionStore = signalStore(
remissionStarted: computed(
() => store.returnId() !== undefined && store.receiptId() !== undefined,
),
receipt: computed(() => store._receiptResource.value()),
returnData: computed(() => store._fetchReturnResource.value()),
})),
withMethods((store) => ({
/**
@@ -158,15 +135,44 @@ export const RemissionStore = signalStore(
returnId,
receiptId,
});
store._receiptResource.reload();
store._fetchReturnResource.reload();
store.storeState();
},
/**
* Reloads the receipt resource.
* This method should be called when the receipt data needs to be refreshed.
* Reloads the return resource to fetch the latest data.
* This is useful when the return data might have changed and needs to be refreshed.
*
* @example
* ```typescript
* remissionStore.reloadReturn();
* ```
*/
reloadReceipt() {
store._receiptResource.reload();
reloadReturn() {
store._fetchReturnResource.reload();
},
/**
* Checks if the current remission matches the provided returnId and receiptId.
* This is useful for determining if the current remission is active in the context of a component.
*
* @param returnId - The return ID to check against the current remission
* @param receiptId - The receipt ID to check against the current remission
* @returns {boolean} True if the current remission matches the provided IDs, false otherwise
*
* @example
* ```typescript
* const isCurrent = remissionStore.isCurrentRemission(123, 456);
* ```
*/
isCurrentRemission({
returnId,
receiptId,
}: {
returnId: number | undefined;
receiptId: number | undefined;
}): boolean {
return store.returnId() === returnId && store.receiptId() === receiptId;
},
/**
@@ -273,15 +279,15 @@ export const RemissionStore = signalStore(
},
/**
* Resets the remission store to its initial state.
* Clears all selected items, quantities, and resets return/receipt IDs.
* Clears the remission store state, resetting all values to their initial state.
* This is useful for starting a new remission process or clearing the current state.
*
* @example
* ```typescript
* remissionStore.resetRemission();
* remissionStore.clearState();
* ```
*/
finishRemission() {
clearState() {
patchState(store, initialState);
store.storeState();
},

View File

@@ -5,16 +5,16 @@
>
</filter-input-menu-button>
@if (displayCapacityValues()) {
@if (selectedDepartments()) {
<ui-toolbar class="ui-toolbar-rounded">
<span class="isa-text-body-2-regular"
><span class="isa-text-body-2-bold"
<span class="flex gap-1 isa-text-body-2-regular"
><span *uiSkeletonLoader="capacityFetching()" class="isa-text-body-2-bold"
>{{ leistung() }}/{{ maxLeistung() }}</span
>
Leistung</span
>
<span class="isa-text-body-2-regular"
><span class="isa-text-body-2-bold"
<span class="flex gap-1 isa-text-body-2-regular"
><span *uiSkeletonLoader="capacityFetching()" class="isa-text-body-2-bold"
>{{ stapel() }}/{{ maxStapel() }}</span
>
Stapel</span
@@ -23,7 +23,6 @@
class="w-6 h-6 flex items-center justify-center text-isa-accent-blue"
uiTooltip
[title]="'Stapel/Leistungsplätze'"
[content]="''"
[triggerOn]="['click', 'hover']"
>
<ng-icon size="1.5rem" name="isaOtherInfo"></ng-icon>

View File

@@ -18,6 +18,7 @@ import { ToolbarComponent } from '@isa/ui/toolbar';
import { TooltipDirective } from '@isa/ui/tooltip';
import { NgIconComponent, provideIcons } from '@ng-icons/core';
import { createRemissionCapacityResource } from '../resources';
import { SkeletonLoaderDirective } from '@isa/ui/skeleton-loader';
@Component({
selector: 'remi-feature-remission-list-department-elements',
@@ -30,6 +31,7 @@ import { createRemissionCapacityResource } from '../resources';
ToolbarComponent,
TooltipDirective,
NgIconComponent,
SkeletonLoaderDirective,
],
})
export class RemissionListDepartmentElementsComponent {
@@ -59,7 +61,7 @@ export class RemissionListDepartmentElementsComponent {
if (input?.type === InputType.Checkbox && input?.selected?.length > 0) {
return input?.selected?.filter((selected) => !!selected).join(', ');
}
return;
return 'Abteilung auswählen';
});
/**
@@ -75,12 +77,19 @@ export class RemissionListDepartmentElementsComponent {
};
});
/**
* Computed signal to get the current value of the capacity resource.
* @returns {Array} The current capacity values or an empty array if not available.
*/
capacityResourceValue = computed(() => this.capacityResource.value());
displayCapacityValues = computed(() => {
const value = this.capacityResourceValue();
return !!value && value?.length > 0;
});
/**
* Computed signal to check if the capacity resource is currently fetching data.
* @returns {boolean} True if the resource is loading, false otherwise.
*/
capacityFetching = computed(
() => this.capacityResource.status() === 'loading',
);
leistungValues = computed(() => {
const value = this.capacityResourceValue();

View File

@@ -0,0 +1,30 @@
@if (displayRemoveManuallyAddedItemButton()) {
<button
class="self-end"
type="button"
uiTextButton
color="strong"
(click)="deleteItemFromList()"
[disabled]="inProgress()"
[pending]="inProgress()"
data-what="button"
data-which="remove-remission-item"
>
Entfernen
</button>
}
@if (displayChangeQuantityButton()) {
<button
class="self-end"
type="button"
uiTextButton
color="strong"
(click)="openRemissionQuantityDialog()"
[disabled]="inProgress()"
data-what="button"
data-which="change-remission-quantity"
>
Remi Menge ändern
</button>
}

View File

@@ -0,0 +1,3 @@
:host {
@apply self-end flex flex-row h-full;
}

View File

@@ -0,0 +1,216 @@
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
input,
model,
} from '@angular/core';
import { FormsModule, Validators } from '@angular/forms';
import { logger } from '@isa/core/logging';
import {
RemissionItem,
RemissionItemSource,
RemissionListType,
RemissionReturnReceiptService,
RemissionStore,
} from '@isa/remission/data-access';
import { TextButtonComponent } from '@isa/ui/buttons';
import { injectFeedbackDialog, injectNumberInputDialog } from '@isa/ui/dialog';
import { firstValueFrom } from 'rxjs';
import { injectRemissionListType } from '../injects/inject-remission-list-type';
@Component({
selector: 'remi-feature-remission-list-item-actions',
templateUrl: './remission-list-item-actions.component.html',
styleUrl: './remission-list-item-actions.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [FormsModule, TextButtonComponent],
})
export class RemissionListItemActionsComponent {
/**
* Dialog service for prompting the user to enter a remission quantity.
* @private
*/
#dialog = injectNumberInputDialog();
/**
* Dialog service for providing feedback to the user.
* @private
*/
#feedbackDialog = injectFeedbackDialog();
/**
* Logger instance for logging component events and errors.
* @private
*/
#logger = logger(() => ({
component: 'RemissionListItemActionsComponent',
}));
/**
* Store for managing selected remission quantities.
* @private
*/
#store = inject(RemissionStore);
/**
* Signal indicating whether remission has started.
* Used to determine if the item can be selected or not.
*/
remissionListType = injectRemissionListType();
/**
* Service for handling remission return receipts.
* @private
*/
#remissionReturnReceiptService = inject(RemissionReturnReceiptService);
/**
* The item to display in the list.
* Can be either a ReturnItem or a ReturnSuggestion.
*/
item = input.required<RemissionItem>();
/**
* The stock to remit for the current item.
* This is used to determine if the remission quantity can be changed.
* @default 0
*/
stockToRemit = input.required<number>();
/**
* ModelSignal indicating whether remission items are currently being processed.
* Used to prevent multiple submissions or actions.
* @default false
*/
inProgress = model<boolean>();
/**
* Signal indicating whether remission has started.
* Used to determine if the item can be selected or not.
*/
remissionStarted = computed(() => this.#store.remissionStarted());
/**
* Input signal indicating whether the selected quantity differs from the stock to remit.
* This is used to determine if the remission quantity can be changed.
*/
selectedQuantityDiffersFromStockToRemit = input<boolean>(true);
/**
* Computes whether to display the button for changing remission quantity.
* Only displays if remission has started and there is stock to remit.
*/
displayChangeQuantityButton = computed(
() => this.remissionStarted() && this.stockToRemit() > 0,
);
/**
* Computes whether to display the button for removing manually added items.
* Only displays if the item's source is 'manually-added'.
*/
displayRemoveManuallyAddedItemButton = computed(
() => this.item()?.source === RemissionItemSource.ManuallyAdded,
);
/**
* Opens a dialog to change the remission quantity for the current item.
* Prompts the user to enter a new quantity and updates the store with the new value
* if valid.
* If the item is not found, it updates the impediment with a comment.
*/
async openRemissionQuantityDialog(): Promise<void> {
const dialogRef = this.#dialog({
title: 'Remi-Menge ändern',
displayClose: true,
data: {
message: 'Wie viele Exemplare können remittiert werden?',
subMessage: this.selectedQuantityDiffersFromStockToRemit()
? 'Originale Remi-Menge:'
: undefined,
subMessageValue: this.selectedQuantityDiffersFromStockToRemit()
? `${this.stockToRemit()}x`
: undefined,
inputLabel: 'Remi-Menge',
closeText: 'Produkt nicht gefunden',
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);
// Dialog Close
if (!result) {
return;
}
const itemId = this.item()?.id;
const quantity = result?.inputValue;
if (itemId && quantity !== undefined && quantity > 0) {
// Speichern CTA
this.#store.updateRemissionQuantity(itemId, this.item(), quantity);
this.#feedbackDialog({
data: { message: 'Remi-Menge wurde geändert' },
});
} else if (itemId) {
// Produkt nicht gefunden CTA
try {
this.inProgress.set(true);
if (this.remissionListType() === RemissionListType.Pflicht) {
await this.#remissionReturnReceiptService.updateReturnItemImpediment({
itemId,
comment: 'Produkt nicht gefunden',
});
}
if (this.remissionListType() === RemissionListType.Abteilung) {
await this.#remissionReturnReceiptService.updateReturnSuggestionImpediment(
{
itemId,
comment: 'Produkt nicht gefunden',
},
);
}
} catch (error) {
this.#logger.error('Failed to update impediment', error);
}
this.inProgress.set(false);
}
}
/**
* Deletes the current item from the remission list.
* Only proceeds if the item has an ID and no other deletion is in progress.
* Calls the service to delete the item and handles any errors.
*/
async deleteItemFromList() {
const itemId = this.item()?.id;
if (!itemId || this.inProgress()) {
return;
}
this.inProgress.set(true);
try {
await this.#remissionReturnReceiptService.deleteReturnItem({ itemId });
} catch (error) {
this.#logger.error('Failed to delete return item', error);
}
this.inProgress.set(false);
}
}

View File

@@ -1,10 +1,12 @@
<ui-checkbox appearance="bullet">
<input
type="checkbox"
[ngModel]="itemSelected()"
(ngModelChange)="setSelected($event)"
(click)="$event.stopPropagation()"
data-what="remission-item-selection-checkbox"
[attr.data-which]="item()?.product?.ean"
/>
</ui-checkbox>
@if (displayCheckbox()) {
<ui-checkbox appearance="bullet">
<input
type="checkbox"
[ngModel]="itemSelected()"
(ngModelChange)="setSelected($event)"
(click)="$event.stopPropagation()"
data-what="remission-item-selection-checkbox"
[attr.data-which]="item()?.product?.ean"
/>
</ui-checkbox>
}

View File

@@ -7,7 +7,6 @@ import {
} from '@angular/core';
import { FormsModule } from '@angular/forms';
import { RemissionItem, RemissionStore } from '@isa/remission/data-access';
import { TextButtonComponent } from '@isa/ui/buttons';
import { CheckboxComponent } from '@isa/ui/input-controls';
@Component({
@@ -15,7 +14,7 @@ import { CheckboxComponent } from '@isa/ui/input-controls';
templateUrl: './remission-list-item-select.component.html',
styleUrl: './remission-list-item-select.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [FormsModule, TextButtonComponent, CheckboxComponent],
imports: [FormsModule, CheckboxComponent],
})
export class RemissionListItemSelectComponent {
/**
@@ -30,6 +29,26 @@ export class RemissionListItemSelectComponent {
*/
item = input.required<RemissionItem>();
/**
* Signal indicating whether the item has stock to remit.
* This is used to conditionally display the select component.
*/
hasStockToRemit = input.required<boolean>();
/**
* Signal indicating whether remission has started.
* Used to determine if the item can be selected or not.
*/
remissionStarted = computed(() => this.#store.remissionStarted());
/**
* Computes whether to display the checkbox for selecting the item.
* Only displays if remission has started and there is stock to remit.
*/
displayCheckbox = computed(
() => this.remissionStarted() && this.hasStockToRemit(),
);
/**
* Computes whether the current item is selected in the remission store.
* Checks if the item's ID exists in the selected items collection.

View File

@@ -5,10 +5,11 @@
[item]="i"
[orientation]="remiProductInfoOrientation()"
></remi-product-info>
@if (displayActions() && !desktopBreakpoint()) {
@if (!desktopBreakpoint()) {
<remi-feature-remission-list-item-select
class="self-start mt-4"
[item]="i"
[hasStockToRemit]="hasStockToRemit()"
></remi-feature-remission-list-item-select>
}
</ui-client-row-content>
@@ -27,35 +28,37 @@
[availableStock]="availableStock()"
[stockToRemit]="selectedStockToRemit() ?? stockToRemit()"
[targetStock]="targetStock()"
[stockFetching]="stockFetching()"
[zob]="stock()?.minStockCategoryManagement ?? 0"
></remi-product-stock-info>
</ui-item-row-data>
@if (displayActions()) {
<ui-item-row-data class="justify-end desktop:justify-between col-end-last">
@if (desktopBreakpoint()) {
<remi-feature-remission-list-item-select
class="self-end mt-4"
[item]="i"
>
</remi-feature-remission-list-item-select>
}
<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>
@if (displayImpediment()) {
<ui-item-row-data
class="w-fit"
[class.row-start-second]="desktopBreakpoint()"
>
<ui-label [type]="Labeltype.Notice">{{ impediment() }}</ui-label>
</ui-item-row-data>
}
<ui-item-row-data class="justify-end desktop:justify-between col-end-last">
@if (desktopBreakpoint()) {
<remi-feature-remission-list-item-select
class="self-end mt-4"
[item]="i"
[hasStockToRemit]="hasStockToRemit()"
>
</remi-feature-remission-list-item-select>
}
<remi-feature-remission-list-item-actions
[item]="i"
[stockToRemit]="stockToRemit()"
[selectedQuantityDiffersFromStockToRemit]="
selectedQuantityDiffersFromStockToRemit()
"
(inProgressChange)="inProgress.set($event)"
></remi-feature-remission-list-item-actions>
</ui-item-row-data>
</ui-client-row>

View File

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

View File

@@ -12,11 +12,13 @@ import {
import {
ProductInfoComponent,
ProductStockInfoComponent,
ProductShelfMetaInfoComponent,
} from '@isa/remission/shared/product';
import { MockComponent } from 'ng-mocks';
import { RemissionListItemSelectComponent } from './remission-list-item-select.component';
import { RemissionListItemActionsComponent } from './remission-list-item-actions.component';
import { LabelComponent } from '@isa/ui/label';
import { signal } from '@angular/core';
import { of } from 'rxjs';
// --- Setup dynamic mocking for injectRemissionListType ---
let remissionListTypeValue: RemissionListType = RemissionListType.Pflicht;
@@ -24,6 +26,15 @@ jest.mock('../injects/inject-remission-list-type', () => ({
injectRemissionListType: () => () => remissionListTypeValue,
}));
// Mock the breakpoint function
jest.mock('@isa/ui/layout', () => ({
breakpoint: jest.fn(() => jest.fn(() => true)), // Default to desktop
Breakpoint: {
DekstopL: 'DekstopL',
DekstopXL: 'DekstopXL',
},
}));
// Mock the calculation functions to have predictable behavior
jest.mock('@isa/remission/data-access', () => ({
...jest.requireActual('@isa/remission/data-access'),
@@ -34,20 +45,9 @@ jest.mock('@isa/remission/data-access', () => ({
// Mock the RemissionStore
const mockRemissionStore = {
remissionStarted: signal(true),
selectedQuantity: signal({}),
updateRemissionQuantity: jest.fn(),
};
// Mock the dialog services
const mockNumberInputDialog = jest.fn();
const mockFeedbackDialog = jest.fn();
jest.mock('@isa/ui/dialog', () => ({
injectNumberInputDialog: () => mockNumberInputDialog,
injectFeedbackDialog: () => mockFeedbackDialog,
}));
describe('RemissionListItemComponent', () => {
let component: RemissionListItemComponent;
let fixture: ComponentFixture<RemissionListItemComponent>;
@@ -92,7 +92,10 @@ describe('RemissionListItemComponent', () => {
RemissionListItemComponent,
MockComponent(ProductInfoComponent),
MockComponent(ProductStockInfoComponent),
MockComponent(ProductShelfMetaInfoComponent),
MockComponent(RemissionListItemSelectComponent),
MockComponent(RemissionListItemActionsComponent),
MockComponent(LabelComponent),
],
providers: [
provideHttpClient(),
@@ -109,7 +112,6 @@ describe('RemissionListItemComponent', () => {
// Reset mocks before each test
jest.clearAllMocks();
mockRemissionStore.selectedQuantity.set({});
mockRemissionStore.remissionStarted.set(true);
// Reset the mocked functions to return default values
const {
@@ -142,9 +144,89 @@ describe('RemissionListItemComponent', () => {
fixture.detectChanges();
expect(component.stock()).toBeDefined();
});
it('should have productGroupValue with default empty string', () => {
fixture.componentRef.setInput('item', createMockReturnItem());
fixture.componentRef.setInput('stock', createMockStockInfo());
fixture.detectChanges();
expect(component.productGroupValue()).toBe('');
});
it('should accept productGroupValue input', () => {
const testValue = 'Test Group';
fixture.componentRef.setInput('item', createMockReturnItem());
fixture.componentRef.setInput('stock', createMockStockInfo());
fixture.componentRef.setInput('productGroupValue', testValue);
fixture.detectChanges();
expect(component.productGroupValue()).toBe(testValue);
});
it('should have stockFetching input with false default', () => {
fixture.componentRef.setInput('item', createMockReturnItem());
fixture.componentRef.setInput('stock', createMockStockInfo());
fixture.detectChanges();
expect(component.stockFetching()).toBe(false);
});
it('should accept stockFetching input value', () => {
fixture.componentRef.setInput('item', createMockReturnItem());
fixture.componentRef.setInput('stock', createMockStockInfo());
fixture.componentRef.setInput('stockFetching', true);
fixture.detectChanges();
expect(component.stockFetching()).toBe(true);
});
it('should have inProgress model with undefined default', () => {
fixture.componentRef.setInput('item', createMockReturnItem());
fixture.componentRef.setInput('stock', createMockStockInfo());
fixture.detectChanges();
expect(component.inProgress()).toBeUndefined();
});
it('should accept inProgress model value', () => {
fixture.componentRef.setInput('item', createMockReturnItem());
fixture.componentRef.setInput('stock', createMockStockInfo());
fixture.componentRef.setInput('inProgress', true);
fixture.detectChanges();
expect(component.inProgress()).toBe(true);
});
});
describe('computed properties', () => {
describe('desktopBreakpoint', () => {
it('should be defined and accessible', () => {
fixture.componentRef.setInput('item', createMockReturnItem());
fixture.componentRef.setInput('stock', createMockStockInfo());
fixture.detectChanges();
expect(component.desktopBreakpoint).toBeDefined();
expect(typeof component.desktopBreakpoint()).toBe('boolean');
});
});
describe('remissionListType', () => {
it('should return injected remission list type', () => {
setRemissionListType(RemissionListType.Abteilung);
fixture.componentRef.setInput('item', createMockReturnItem());
fixture.componentRef.setInput('stock', createMockStockInfo());
fixture.detectChanges();
expect(component.remissionListType()).toBe(RemissionListType.Abteilung);
});
it('should update when remission list type changes', () => {
setRemissionListType(RemissionListType.Pflicht);
fixture.componentRef.setInput('item', createMockReturnItem());
fixture.componentRef.setInput('stock', createMockStockInfo());
fixture.detectChanges();
expect(component.remissionListType()).toBe(RemissionListType.Pflicht);
setRemissionListType(RemissionListType.Abteilung);
expect(component.remissionListType()).toBe(RemissionListType.Abteilung);
});
});
describe('availableStock', () => {
it('should calculate available stock correctly', () => {
const {
@@ -189,11 +271,19 @@ describe('RemissionListItemComponent', () => {
});
describe('targetStock', () => {
it('should calculate target stock correctly', () => {
const { calculateTargetStock } = require('@isa/remission/data-access');
it('should calculate target stock with remainingQuantityInStock when selected quantity matches stock to remit', () => {
const {
calculateTargetStock,
getStockToRemit,
} = require('@isa/remission/data-access');
calculateTargetStock.mockReturnValue(75);
getStockToRemit.mockReturnValue(25);
mockRemissionStore.selectedQuantity.set({ 1: 25 }); // Same as stockToRemit
const mockItem = createMockReturnItem({ remainingQuantityInStock: 15 });
const mockItem = createMockReturnItem({
id: 1,
remainingQuantityInStock: 15,
});
fixture.componentRef.setInput('item', mockItem);
fixture.componentRef.setInput('stock', createMockStockInfo());
fixture.detectChanges();
@@ -201,7 +291,56 @@ describe('RemissionListItemComponent', () => {
expect(component.targetStock()).toBe(75);
expect(calculateTargetStock).toHaveBeenCalledWith({
availableStock: 100, // default mock value
stockToRemit: 0, // default mock value
stockToRemit: 25,
remainingQuantityInStock: 15,
});
});
it('should calculate target stock without remainingQuantityInStock when selected quantity differs from stock to remit', () => {
const {
calculateTargetStock,
getStockToRemit,
} = require('@isa/remission/data-access');
calculateTargetStock.mockReturnValue(80);
getStockToRemit.mockReturnValue(25);
mockRemissionStore.selectedQuantity.set({ 1: 20 }); // Different from stockToRemit
const mockItem = createMockReturnItem({
id: 1,
remainingQuantityInStock: 15,
});
fixture.componentRef.setInput('item', mockItem);
fixture.componentRef.setInput('stock', createMockStockInfo());
fixture.detectChanges();
expect(component.targetStock()).toBe(80);
expect(calculateTargetStock).toHaveBeenCalledWith({
availableStock: 100, // default mock value
stockToRemit: 20, // selected quantity, not calculated stockToRemit
});
});
it('should calculate target stock with remainingQuantityInStock when no selected quantity exists', () => {
const {
calculateTargetStock,
getStockToRemit,
} = require('@isa/remission/data-access');
calculateTargetStock.mockReturnValue(75);
getStockToRemit.mockReturnValue(25);
mockRemissionStore.selectedQuantity.set({}); // No selected quantity
const mockItem = createMockReturnItem({
id: 1,
remainingQuantityInStock: 15,
});
fixture.componentRef.setInput('item', mockItem);
fixture.componentRef.setInput('stock', createMockStockInfo());
fixture.detectChanges();
expect(component.targetStock()).toBe(75);
expect(calculateTargetStock).toHaveBeenCalledWith({
availableStock: 100, // default mock value
stockToRemit: 25, // calculated stockToRemit
remainingQuantityInStock: 15,
});
});
@@ -253,41 +392,291 @@ describe('RemissionListItemComponent', () => {
});
});
describe('displayActions', () => {
it('should return true when stockToRemit > 0 and remission started', () => {
describe('selectedQuantityDiffersFromStockToRemit', () => {
it('should return true when selected quantity differs from stock to remit', () => {
const { getStockToRemit } = require('@isa/remission/data-access');
getStockToRemit.mockReturnValue(10);
mockRemissionStore.selectedQuantity.set({ 1: 15 });
const mockItem = createMockReturnItem({ id: 1 });
fixture.componentRef.setInput('item', mockItem);
fixture.componentRef.setInput('stock', createMockStockInfo());
fixture.detectChanges();
expect(component.selectedQuantityDiffersFromStockToRemit()).toBe(true);
});
it('should return false when selected quantity equals stock to remit', () => {
const { getStockToRemit } = require('@isa/remission/data-access');
getStockToRemit.mockReturnValue(15);
mockRemissionStore.selectedQuantity.set({ 1: 15 });
const mockItem = createMockReturnItem({ id: 1 });
fixture.componentRef.setInput('item', mockItem);
fixture.componentRef.setInput('stock', createMockStockInfo());
fixture.detectChanges();
expect(component.selectedQuantityDiffersFromStockToRemit()).toBe(false);
});
it('should return false when no selected quantity exists', () => {
const { getStockToRemit } = require('@isa/remission/data-access');
getStockToRemit.mockReturnValue(10);
mockRemissionStore.selectedQuantity.set({});
const mockItem = createMockReturnItem({ id: 1 });
fixture.componentRef.setInput('item', mockItem);
fixture.componentRef.setInput('stock', createMockStockInfo());
fixture.detectChanges();
expect(component.selectedQuantityDiffersFromStockToRemit()).toBe(false);
});
});
describe('hasStockToRemit', () => {
it('should return true when both availableStock > 0 and stockToRemit > 0', () => {
const {
getStockToRemit,
calculateAvailableStock,
} = require('@isa/remission/data-access');
getStockToRemit.mockReturnValue(5);
mockRemissionStore.remissionStarted.set(true);
calculateAvailableStock.mockReturnValue(10);
fixture.componentRef.setInput('item', createMockReturnItem());
fixture.componentRef.setInput('stock', createMockStockInfo());
fixture.detectChanges();
expect(component.displayActions()).toBe(true);
expect(component.hasStockToRemit()).toBe(true);
});
it('should return false when stockToRemit is 0', () => {
const { getStockToRemit } = require('@isa/remission/data-access');
it('should return false when stockToRemit is 0 even if availableStock > 0', () => {
const {
getStockToRemit,
calculateAvailableStock,
} = require('@isa/remission/data-access');
getStockToRemit.mockReturnValue(0);
mockRemissionStore.remissionStarted.set(true);
calculateAvailableStock.mockReturnValue(10);
fixture.componentRef.setInput('item', createMockReturnItem());
fixture.componentRef.setInput('stock', createMockStockInfo());
fixture.detectChanges();
expect(component.displayActions()).toBe(false);
expect(component.hasStockToRemit()).toBe(false);
});
it('should return false when remission has not started', () => {
const { getStockToRemit } = require('@isa/remission/data-access');
getStockToRemit.mockReturnValue(5);
mockRemissionStore.remissionStarted.set(false);
it('should return false when stockToRemit is negative even if availableStock > 0', () => {
const {
getStockToRemit,
calculateAvailableStock,
} = require('@isa/remission/data-access');
getStockToRemit.mockReturnValue(-1);
calculateAvailableStock.mockReturnValue(10);
fixture.componentRef.setInput('item', createMockReturnItem());
fixture.componentRef.setInput('stock', createMockStockInfo());
fixture.detectChanges();
expect(component.displayActions()).toBe(false);
expect(component.hasStockToRemit()).toBe(false);
});
it('should return false when availableStock is 0 even if stockToRemit > 0', () => {
const {
getStockToRemit,
calculateAvailableStock,
} = require('@isa/remission/data-access');
getStockToRemit.mockReturnValue(5);
calculateAvailableStock.mockReturnValue(0);
fixture.componentRef.setInput('item', createMockReturnItem());
fixture.componentRef.setInput('stock', createMockStockInfo());
fixture.detectChanges();
expect(component.hasStockToRemit()).toBe(false);
});
it('should return false when availableStock is negative even if stockToRemit > 0', () => {
const {
getStockToRemit,
calculateAvailableStock,
} = require('@isa/remission/data-access');
getStockToRemit.mockReturnValue(5);
calculateAvailableStock.mockReturnValue(-1);
fixture.componentRef.setInput('item', createMockReturnItem());
fixture.componentRef.setInput('stock', createMockStockInfo());
fixture.detectChanges();
expect(component.hasStockToRemit()).toBe(false);
});
it('should return false when both availableStock and stockToRemit are 0', () => {
const {
getStockToRemit,
calculateAvailableStock,
} = require('@isa/remission/data-access');
getStockToRemit.mockReturnValue(0);
calculateAvailableStock.mockReturnValue(0);
fixture.componentRef.setInput('item', createMockReturnItem());
fixture.componentRef.setInput('stock', createMockStockInfo());
fixture.detectChanges();
expect(component.hasStockToRemit()).toBe(false);
});
it('should return false when both availableStock and stockToRemit are negative', () => {
const {
getStockToRemit,
calculateAvailableStock,
} = require('@isa/remission/data-access');
getStockToRemit.mockReturnValue(-1);
calculateAvailableStock.mockReturnValue(-2);
fixture.componentRef.setInput('item', createMockReturnItem());
fixture.componentRef.setInput('stock', createMockStockInfo());
fixture.detectChanges();
expect(component.hasStockToRemit()).toBe(false);
});
});
describe('remiProductInfoOrientation', () => {
it('should return a valid orientation value', () => {
fixture.componentRef.setInput('item', createMockReturnItem());
fixture.componentRef.setInput('stock', createMockStockInfo());
fixture.detectChanges();
const orientation = component.remiProductInfoOrientation();
expect(['horizontal', 'vertical']).toContain(orientation);
});
it('should depend on desktop breakpoint', () => {
fixture.componentRef.setInput('item', createMockReturnItem());
fixture.componentRef.setInput('stock', createMockStockInfo());
fixture.detectChanges();
// The function should compute based on the breakpoint
const orientation = component.remiProductInfoOrientation();
expect(typeof orientation).toBe('string');
expect(['horizontal', 'vertical']).toContain(orientation);
});
});
describe('displayImpediment', () => {
it('should return truthy when item has impediment', () => {
const mockItem = createMockReturnItem({
impediment: {
comment: 'Test impediment',
attempts: 2,
},
});
fixture.componentRef.setInput('item', mockItem);
fixture.componentRef.setInput('stock', createMockStockInfo());
fixture.detectChanges();
expect(component.displayImpediment()).toBeTruthy();
});
it('should return truthy when item is descendant of enabled impediment', () => {
const mockItem = createMockReturnItem({
descendantOf: {
enabled: true,
},
});
fixture.componentRef.setInput('item', mockItem);
fixture.componentRef.setInput('stock', createMockStockInfo());
fixture.detectChanges();
expect(component.displayImpediment()).toBeTruthy();
});
it('should return falsy when item has no impediment and is not descendant of enabled impediment', () => {
const mockItem = createMockReturnItem({
impediment: undefined,
descendantOf: {
enabled: false,
},
});
fixture.componentRef.setInput('item', mockItem);
fixture.componentRef.setInput('stock', createMockStockInfo());
fixture.detectChanges();
expect(component.displayImpediment()).toBeFalsy();
});
it('should return falsy when item has no impediment and no descendantOf property', () => {
const mockItem = createMockReturnItem({
impediment: undefined,
});
fixture.componentRef.setInput('item', mockItem);
fixture.componentRef.setInput('stock', createMockStockInfo());
fixture.detectChanges();
expect(component.displayImpediment()).toBeFalsy();
});
});
describe('impediment', () => {
it('should return impediment comment when available', () => {
const mockItem = createMockReturnItem({
impediment: {
comment: 'Custom impediment message',
attempts: 3,
},
});
fixture.componentRef.setInput('item', mockItem);
fixture.componentRef.setInput('stock', createMockStockInfo());
fixture.detectChanges();
expect(component.impediment()).toBe('Custom impediment message (3)');
});
it('should return default "Restmenge" when no comment provided', () => {
const mockItem = createMockReturnItem({
impediment: {
attempts: 2,
},
});
fixture.componentRef.setInput('item', mockItem);
fixture.componentRef.setInput('stock', createMockStockInfo());
fixture.detectChanges();
expect(component.impediment()).toBe('Restmenge (2)');
});
it('should return only comment when no attempts provided', () => {
const mockItem = createMockReturnItem({
impediment: {
comment: 'Custom message',
},
});
fixture.componentRef.setInput('item', mockItem);
fixture.componentRef.setInput('stock', createMockStockInfo());
fixture.detectChanges();
expect(component.impediment()).toBe('Custom message');
});
it('should return default "Restmenge" when impediment is empty object', () => {
const mockItem = createMockReturnItem({
impediment: {},
});
fixture.componentRef.setInput('item', mockItem);
fixture.componentRef.setInput('stock', createMockStockInfo());
fixture.detectChanges();
expect(component.impediment()).toBe('Restmenge');
});
it('should return "Restmenge" when impediment is undefined', () => {
const mockItem = createMockReturnItem({
impediment: undefined,
});
fixture.componentRef.setInput('item', mockItem);
fixture.componentRef.setInput('stock', createMockStockInfo());
fixture.detectChanges();
expect(component.impediment()).toBe('Restmenge');
});
});
});
@@ -331,58 +720,4 @@ describe('RemissionListItemComponent', () => {
});
});
});
describe('Dialog interactions', () => {
it('should open remission quantity dialog and update store on valid input', async () => {
const mockDialogRef = {
closed: of({ inputValue: 10 }), // Return Observable instead of object with toPromise
};
mockNumberInputDialog.mockReturnValue(mockDialogRef);
const mockItem = createMockReturnItem({ id: 1 });
fixture.componentRef.setInput('item', mockItem);
fixture.componentRef.setInput('stock', createMockStockInfo());
fixture.detectChanges();
await component.openRemissionQuantityDialog();
expect(mockNumberInputDialog).toHaveBeenCalledWith({
title: 'Remi-Menge ändern',
data: {
message: 'Wie viele Exemplare können remittiert werden?',
inputLabel: 'Remi-Menge',
inputValidation: expect.arrayContaining([
expect.objectContaining({ errorKey: 'required' }),
expect.objectContaining({ errorKey: 'pattern' }),
]),
},
});
expect(mockRemissionStore.updateRemissionQuantity).toHaveBeenCalledWith(
1,
mockItem,
10,
);
expect(mockFeedbackDialog).toHaveBeenCalledWith({
data: { message: 'Remi-Menge wurde geändert' },
});
});
it('should not update store when dialog is cancelled', async () => {
const mockDialogRef = {
closed: of(null), // Return Observable with null result
};
mockNumberInputDialog.mockReturnValue(mockDialogRef);
const mockItem = createMockReturnItem({ id: 1 });
fixture.componentRef.setInput('item', mockItem);
fixture.componentRef.setInput('stock', createMockStockInfo());
fixture.detectChanges();
await component.openRemissionQuantityDialog();
expect(mockRemissionStore.updateRemissionQuantity).not.toHaveBeenCalled();
expect(mockFeedbackDialog).not.toHaveBeenCalled();
});
});
});

View File

@@ -4,8 +4,9 @@ import {
computed,
inject,
input,
model,
} from '@angular/core';
import { FormsModule, Validators } from '@angular/forms';
import { FormsModule } from '@angular/forms';
import {
calculateAvailableStock,
calculateTargetStock,
@@ -20,13 +21,12 @@ import {
ProductShelfMetaInfoComponent,
ProductStockInfoComponent,
} from '@isa/remission/shared/product';
import { TextButtonComponent } from '@isa/ui/buttons';
import { injectFeedbackDialog, injectNumberInputDialog } 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';
import { RemissionListItemSelectComponent } from './remission-list-item-select.component';
import { RemissionListItemActionsComponent } from './remission-list-item-actions.component';
import { LabelComponent, Labeltype } from '@isa/ui/label';
/**
* Component representing a single item in the remission list.
@@ -52,24 +52,19 @@ import { RemissionListItemSelectComponent } from './remission-list-item-select.c
ProductInfoComponent,
ProductStockInfoComponent,
ProductShelfMetaInfoComponent,
TextButtonComponent,
ClientRowImports,
ItemRowDataImports,
RemissionListItemSelectComponent,
RemissionListItemActionsComponent,
LabelComponent,
],
})
export class RemissionListItemComponent {
/**
* Dialog service for prompting the user to enter a remission quantity.
* @private
* Type of label to display for the item.
* Defaults to 'tag', can be changed to 'notice' or other types as needed.
*/
#dialog = injectNumberInputDialog();
/**
* Dialog service for providing feedback to the user.
* @private
*/
#feedbackDialog = injectFeedbackDialog();
Labeltype = Labeltype;
/**
* Store for managing selected remission quantities.
@@ -99,6 +94,21 @@ export class RemissionListItemComponent {
*/
stock = input.required<StockInfo>();
/**
* InputSignal indicating whether the stock information is currently being fetched.
* Used to show loading states in the UI.
* @default false
*
*/
stockFetching = input<boolean>(false);
/**
* ModelSignal indicating whether remission items are currently being processed.
* Used to prevent multiple submissions or actions.
* @default false
*/
inProgress = model<boolean>();
/**
* Optional product group value for display or filtering.
*/
@@ -120,12 +130,13 @@ export class RemissionListItemComponent {
);
/**
* Computes whether to display action buttons based on stock to remit and remission status.
* @returns true if stock to remit is greater than 0 and remission has started
* Computes whether the item has stock to remit.
* Returns true if stockToRemit and availableStock are greater than 0.
* #5269 Added availableStock check
*/
displayActions = computed<boolean>(() => {
return this.stockToRemit() > 0 && this.#store.remissionStarted();
});
hasStockToRemit = computed(
() => this.availableStock() > 0 && this.stockToRemit() > 0,
);
/**
* Computes the available stock for the item using stock and removedFromStock.
@@ -146,6 +157,16 @@ export class RemissionListItemComponent {
() => this.#store.selectedQuantity()?.[this.item().id!],
);
/**
* Computes whether the selected quantity equals the stock to remit.
* This is used to determine if the remission quantity can be changed.
*/
selectedQuantityDiffersFromStockToRemit = computed(
() =>
this.selectedStockToRemit() !== undefined &&
this.selectedStockToRemit() !== this.stockToRemit(),
);
/**
* Computes the stock to remit based on the remission item and available stock.
* Uses the getStockToRemit helper function.
@@ -160,53 +181,42 @@ export class RemissionListItemComponent {
/**
* Computes the target stock after remission.
* @returns The calculated target stock.
* Uses the calculateTargetStock helper function.
* Takes into account the selected quantity and remaining quantity in stock.
*/
targetStock = computed(() =>
calculateTargetStock({
targetStock = computed(() => {
if (this.selectedQuantityDiffersFromStockToRemit()) {
return calculateTargetStock({
availableStock: this.availableStock(),
stockToRemit: this.selectedStockToRemit(),
});
}
return calculateTargetStock({
availableStock: this.availableStock(),
stockToRemit: this.stockToRemit(),
remainingQuantityInStock: this.remainingQuantityInStock(),
}),
});
});
/**
* Computes whether to display the impediment for the item.
* Displays if the item is a descendant of an enabled impediment or if it has its own impediment.
*/
displayImpediment = computed(
() =>
(this.item() as ReturnItem)?.descendantOf?.enabled ||
this.item()?.impediment,
);
/**
* Opens a dialog to change the remission quantity for the current item.
* Prompts the user for a new quantity and updates the store if valid.
* Displays feedback dialog upon successful update.
*
* @returns A promise that resolves when the dialog is closed.
* Computes the impediment comment and attempts for display.
* If no impediment comment is provided, defaults to 'Restmenge'.
* Appends the number of attempts if available.
*/
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 = result?.inputValue;
if (itemId && quantity !== undefined && quantity > 0) {
this.#store.updateRemissionQuantity(itemId, this.item(), quantity);
this.#feedbackDialog({
data: { message: 'Remi-Menge wurde geändert' },
});
}
}
impediment = computed(() => {
const comment = this.item()?.impediment?.comment ?? 'Restmenge';
const attempts = this.item()?.impediment?.attempts;
return `${comment}${attempts ? ` (${attempts})` : ''}`;
});
}

View File

@@ -12,7 +12,6 @@
@for (kv of remissionListTypes; track kv.key) {
<ui-dropdown-option
[attr.data-what]="`remission-list-option-${kv.value}`"
[disabled]="kv.value === RemissionListCategory.Koerperlos"
[value]="kv.value"
>{{ kv.value }}</ui-dropdown-option
>

View File

@@ -35,11 +35,7 @@ export class RemissionListSelectComponent {
selectedRemissionListType = injectRemissionListType();
async changeRemissionType(remissionTypeValue: RemissionListType | undefined) {
if (
!remissionTypeValue ||
remissionTypeValue === RemissionListType.Koerperlos
)
return;
if (!remissionTypeValue) return;
await this.router.navigate(
[remissionListTypeRouteMapping[remissionTypeValue]],
@@ -57,7 +53,7 @@ export class RemissionListSelectComponent {
}
if (type === RemissionListType.Abteilung) {
return 'Abteilungen';
return 'Abteilung';
}
return;

View File

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

View File

@@ -7,7 +7,7 @@ import {
untracked,
signal,
} from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { ActivatedRoute, Router } from '@angular/router';
import {
provideFilter,
withQuerySettingsFactory,
@@ -48,6 +48,7 @@ import { RemissionReturnCardComponent } from './remission-return-card/remission-
import { logger } from '@isa/core/logging';
import { RemissionProcessedHintComponent } from './remission-processed-hint/remission-processed-hint.component';
import { RemissionListDepartmentElementsComponent } from './remission-list-department-elements/remission-list-department-elements.component';
import { injectTabId } from '@isa/core/tabs';
function querySettingsFactory() {
return inject(ActivatedRoute).snapshot.data['querySettings'];
@@ -103,6 +104,17 @@ export class RemissionListComponent {
*/
route = inject(ActivatedRoute);
/**
* Router instance for navigation.
*/
router = inject(Router);
/**
* Injects the current activated tab ID as a signal.
* This is used to determine if the current remission matches the active tab.
*/
activatedTabId = injectTabId();
searchItemToRemitDialog = injectDialog(SearchItemToRemitDialogComponent);
/**
@@ -149,6 +161,12 @@ export class RemissionListComponent {
return this.selectedRemissionListType() === RemissionListType.Abteilung;
});
/**
* Signal indicating whether a remission list item deletion is in progress.
* Used to disable actions while deletion is happening.
*/
listItemActionInProgress = signal(false);
/**
* Resource signal for fetching the remission list based on current filters.
* @returns Remission list resource state.
@@ -196,6 +214,12 @@ export class RemissionListComponent {
*/
inStockResponseValue = computed(() => this.inStockResource.value());
/**
* Computed signal indicating whether the in-stock resource is currently fetching data.
* @returns True if fetching, false otherwise.
*/
inStockFetching = computed(() => this.inStockResource.status() === 'loading');
/**
* Computed signal for the product group response.
* @returns Array of KeyValueStringAndString or undefined.
@@ -310,6 +334,19 @@ export class RemissionListComponent {
return productGroup ? productGroup.value : '';
}
/**
* Handles the deletion of a remission list item.
* Updates the in-progress state and reloads the list and receipt upon completion.
*
* @param inProgress - Whether the deletion is currently in progress
*/
onListItemActionInProgress(inProgress: boolean) {
this.listItemActionInProgress.set(inProgress);
if (!inProgress) {
this.reloadListAndReturnData();
}
}
/**
* Computed signal that determines if the current search was triggered by user interaction.
* Returns true for user-initiated actions (input, filter changes, sort changes, scanning)
@@ -327,6 +364,12 @@ export class RemissionListComponent {
);
});
/**
* Effect that handles the case when there are no items in the remission list after a search.
* If the search was triggered by the user, it opens a dialog to search for items to remit.
* If remission has already started, it adds the found items to the remission store and remits them.
* If not, it navigates to the default remission list.
*/
emptySearchResultEffect = effect(() => {
const status = this.remissionResource.status();
@@ -358,9 +401,12 @@ export class RemissionListComponent {
this.#store.selectRemissionItem(item.id, item);
}
}
await this.remitItems();
await this.remitItems({ addItemFlow: true });
} else if (this.isDepartment()) {
return await this.navigateToDefaultRemissionList();
}
this.reloadListAndReceipt();
this.reloadListAndReturnData();
this.searchTrigger.set('reload');
}
});
@@ -368,14 +414,25 @@ export class RemissionListComponent {
});
// TODO: Improvement - In Separate Komponente zusammen mit Remi-Button Auslagern
async remitItems() {
/**
* Initiates the process to remit selected items.
* If remission is already started, items are added directly to the remission.
* If not, navigates to the default remission list.
* @param options - Options for remitting items, including whether it's part of an add-item flow.
* @returns A promise that resolves when the operation is complete.
*/
async remitItems(options: { addItemFlow: boolean } = { addItemFlow: false }) {
if (this.remitItemsInProgress()) {
return;
}
this.remitItemsInProgress.set(true);
try {
const remissionListType = this.selectedRemissionListType();
// #5273, #5280 Fix - Bei gestarteter Remission dürfen Items die über den AddItemDialog hinzugefügt und direkt remittiert werden, nur als ReturnItem (statt ReturnSuggestion) zum WBS hinzugefügt werden
const remissionListType = options.addItemFlow
? RemissionListType.Pflicht
: this.selectedRemissionListType();
const selected = this.#store.selectedItems();
const quantities = this.#store.selectedQuantity();
@@ -402,13 +459,13 @@ export class RemissionListComponent {
quantity: stockToRemit,
inStock,
},
type: this.selectedRemissionListType(),
type: remissionListType,
});
}
}
this.remitItemsState.set('success');
this.reloadListAndReceipt();
this.reloadListAndReturnData();
} catch (error) {
this.#logger.error('Failed to remit items', error);
this.remitItemsError.set(
@@ -424,11 +481,20 @@ export class RemissionListComponent {
}
/**
* Reloads the remission list and receipt data.
* Reloads the remission list and return data.
* This method is used to refresh the displayed data after changes.
*/
reloadListAndReceipt() {
reloadListAndReturnData() {
this.remissionResource.reload();
this.#store.reloadReceipt();
this.#store.reloadReturn();
}
/**
* Navigates to the default remission list based on the current activated tab ID.
* This method is used to redirect the user to the remission list after completing or starting a remission.
* @returns {Promise<void>} A promise that resolves when navigation is complete
*/
async navigateToDefaultRemissionList() {
await this.router.navigate(['/', this.activatedTabId(), 'remission']);
}
}

View File

@@ -6,7 +6,11 @@ import {
effect,
} from '@angular/core';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { RemissionStore } from '@isa/remission/data-access';
import {
getReceiptItemQuantityFromReturn,
getReceiptNumberFromReturn,
RemissionStore,
} from '@isa/remission/data-access';
import { ButtonComponent } from '@isa/ui/buttons';
@Component({
@@ -24,20 +28,20 @@ export class RemissionReturnCardComponent {
receiptId = computed(() => this.#remissionStore.receiptId());
receiptItemsCount = computed(() => {
const receipt = this.#remissionStore.receipt();
return receipt?.items?.length ?? 0;
const returnData = this.#remissionStore.returnData();
return getReceiptItemQuantityFromReturn(returnData!);
});
receiptNumber = computed(() => {
const receipt = this.#remissionStore.receipt();
return receipt?.receiptNumber?.substring(6, 12);
const returnData = this.#remissionStore.returnData();
return getReceiptNumberFromReturn(returnData!);
});
constructor() {
effect(() => {
this.returnId();
this.receiptId();
this.#remissionStore.reloadReceipt();
this.#remissionStore.reloadReturn();
});
}
}

View File

@@ -1,9 +1,6 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { RemissionStore } from '@isa/remission/data-access';
import { RemissionStartDialogComponent } from '@isa/remission/shared/remission-start-dialog';
import { RemissionStartService } from '@isa/remission/shared/remission-start-dialog';
import { ButtonComponent } from '@isa/ui/buttons';
import { injectDialog } from '@isa/ui/dialog';
import { firstValueFrom } from 'rxjs';
@Component({
selector: 'remi-feature-remission-start-card',
@@ -13,24 +10,9 @@ import { firstValueFrom } from 'rxjs';
imports: [ButtonComponent],
})
export class RemissionStartCardComponent {
#remissionStartDialog = injectDialog(RemissionStartDialogComponent);
#remissionStore = inject(RemissionStore);
#remissionStartService = inject(RemissionStartService);
async startRemission() {
const remissionStartDialogRef = this.#remissionStartDialog({
data: { returnGroup: undefined },
classList: ['gap-0'],
width: '30rem',
});
const result = await firstValueFrom(remissionStartDialogRef.closed);
if (result) {
const { returnId, receiptId } = result;
this.#remissionStore.startRemission({
returnId,
receiptId,
});
}
await this.#remissionStartService.startRemission(undefined);
}
}

View File

@@ -1,16 +0,0 @@
<ui-stateful-button
[(state)]="state"
defaultContent="Remittieren"
successContent="Hinzugefügt"
errorContent="Konnte nicht hinzugefügt werden."
errorAction="Noch mal versuchen"
defaultWidth="10rem"
successWidth="20.375rem"
errorWidth="32rem"
[pending]="isLoading()"
color="brand"
size="large"
class="remit-button"
(clicked)="clickHandler()"
(action)="retryHandler()"
/>

View File

@@ -1 +0,0 @@
// Component now uses ui-stateful-button which handles all styling

View File

@@ -1,58 +0,0 @@
import {
ChangeDetectionStrategy,
Component,
signal,
OnDestroy,
} from '@angular/core';
import { StatefulButtonComponent, StatefulButtonState } from '@isa/ui/buttons';
@Component({
selector: 'remi-remit-button',
templateUrl: './remit-button.component.html',
styleUrls: ['./remit-button.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [StatefulButtonComponent],
})
export class RemitButtonComponent implements OnDestroy {
state = signal<StatefulButtonState>('default');
isLoading = signal<boolean>(false);
private timer: ReturnType<typeof setTimeout> | null = null;
ngOnDestroy(): void {
this.clearTimer();
}
clickHandler() {
// Clear any existing timer to prevent multiple clicks from stacking
this.clearTimer();
this.isLoading.set(true);
this.timer = setTimeout(() => {
this.isLoading.set(false);
// Simulate an async operation, e.g., API call
const success = Math.random() > 0.5; // Randomly succeed or fail
if (success) {
this.state.set('success');
} else {
this.state.set('error');
}
}, 100); // Simulate async operation
}
retryHandler() {
this.isLoading.set(true);
this.timer = setTimeout(() => {
this.isLoading.set(false);
this.state.set('success');
}, 100);
}
private clearTimer(): void {
if (this.timer) {
clearTimeout(this.timer);
this.timer = null;
}
}
}

View File

@@ -2,15 +2,15 @@ import { inject, resource } from '@angular/core';
import { ListResponseArgs, ResponseArgsError } from '@isa/common/data-access';
import {
QueryTokenInput,
RemissionItem,
RemissionListType,
RemissionSearchService,
RemissionStockService,
RemissionSupplierService,
ReturnItem,
ReturnSuggestion,
} from '@isa/remission/data-access';
import { SearchTrigger } from '@isa/shared/filter';
import { parseISO, compareDesc } from 'date-fns';
import { isEan } from '@isa/utils/ean-validation';
/**
* Creates an Angular resource for fetching remission lists.
@@ -36,7 +36,7 @@ import { parseISO, compareDesc } from 'date-fns';
* },
* searchTrigger: 'input'
* }));
*
*
* @remarks
* The searchTrigger parameter influences query behavior:
* - 'scan': Clears existing filters to show scan-specific results
@@ -70,19 +70,27 @@ export const createRemissionListResource = (
throw new Error('No Supplier available');
}
let res:
| ListResponseArgs<ReturnItem>
| ListResponseArgs<ReturnSuggestion>
| undefined;
let res: ListResponseArgs<RemissionItem> | undefined = undefined;
const queryToken = { ...params.queryToken };
const exactSearch = isExactSearch(queryToken, params.searchTrigger);
const isReload = params.searchTrigger === 'reload';
if (params.searchTrigger === 'scan') {
queryToken.filter = {};
// #5273
if (isReload) {
queryToken.input = {};
}
if (params.remissionListType === RemissionListType.Pflicht) {
res = await remissionSearchService.fetchList(
if (exactSearch) {
queryToken.filter = {};
queryToken.orderBy = [];
}
if (
exactSearch ||
params.remissionListType === RemissionListType.Pflicht
) {
const fetchListResponse = await remissionSearchService.fetchList(
{
assignedStockId: assignedStock.id,
supplierId: firstSupplier.id,
@@ -90,53 +98,137 @@ export const createRemissionListResource = (
},
abortSignal,
);
res = fetchListResponse;
}
if (params.remissionListType === RemissionListType.Abteilung) {
res = await remissionSearchService.fetchDepartmentList(
{
assignedStockId: assignedStock.id,
supplierId: firstSupplier.id,
...params.queryToken,
},
abortSignal,
);
if (
exactSearch ||
canFetchDepartmentList(queryToken, params.remissionListType)
) {
const fetchDepartmentListResponse =
await remissionSearchService.fetchDepartmentList(
{
assignedStockId: assignedStock.id,
supplierId: firstSupplier.id,
...queryToken,
},
abortSignal,
);
if (res) {
// Merge results if both lists are fetched
res.result = [
...(res.result || []),
...(fetchDepartmentListResponse.result || []),
];
res.hits += fetchDepartmentListResponse.hits;
res.skip += fetchDepartmentListResponse.skip;
res.take += fetchDepartmentListResponse.take;
} else {
res = fetchDepartmentListResponse;
}
}
if (res?.error) {
throw new ResponseArgsError(res);
}
// Sort items: manually-added items first, then by created date (latest first)
if (res && res.result && Array.isArray(res.result)) {
res.result.sort((a, b) => {
const aIsManuallyAdded = a.source === 'manually-added';
const bIsManuallyAdded = b.source === 'manually-added';
// #5276 Fix - Replace defaultSort Mechanism with orderBy from QueryToken if available
const hasOrderBy = !!queryToken?.orderBy && queryToken.orderBy.length > 0;
// First priority: manually-added items come first
if (aIsManuallyAdded && !bIsManuallyAdded) {
return -1;
}
if (!aIsManuallyAdded && bIsManuallyAdded) {
return 1;
}
// Second priority: sort by created date (latest first)
if (a.created && b.created) {
const dateA = parseISO(a.created);
const dateB = parseISO(b.created);
return compareDesc(dateA, dateB); // Descending order (latest first)
}
// Handle cases where created date might be missing
if (a.created && !b.created) return -1;
if (!a.created && b.created) return 1;
return 0;
});
if (!hasOrderBy && res && res.result && Array.isArray(res.result)) {
sortResponseResult(res);
}
return res;
},
});
};
/**
* Sorts the remission items in the response based on specific criteria:
* - Items with impediments are moved to the end of the list.
* - Manually added items are prioritized to appear first.
* - (Commented out) Items can be sorted by creation date in descending order.
* @param {ListResponseArgs<RemissionItem>} resopnse - The response object containing remission items to be sorted
* @returns {void} The function modifies the response object in place
*/
const sortResponseResult = (
resopnse: ListResponseArgs<RemissionItem>,
): void => {
resopnse.result.sort((a, b) => {
const aHasImpediment = !!a.impediment;
const bHasImpediment = !!b.impediment;
const aIsManuallyAdded = a.source === 'manually-added';
const bIsManuallyAdded = b.source === 'manually-added';
// First priority: move all items with impediment to the end of the list
if (!aHasImpediment && bHasImpediment) {
return -1;
}
if (aHasImpediment && !bHasImpediment) {
return 1;
}
// Second priority: manually-added items come first
if (aIsManuallyAdded && !bIsManuallyAdded) {
return -1;
}
if (!aIsManuallyAdded && bIsManuallyAdded) {
return 1;
}
// #5295 Fix - Sortierung über Created (Pflichtremission) wird wie auch die Sortierung über die SORT Nummer (Abteilungsremission) bereits über das Backend erledigt
// Third priority: sort by created date (latest first)
// if (a.created && b.created) {
// const dateA = parseISO(a.created);
// const dateB = parseISO(b.created);
// return compareDesc(dateA, dateB); // Descending order (latest first)
// }
// // Handle cases where created date might be missing
// if (a.created && !b.created) return -1;
// if (!a.created && b.created) return 1;
return 0;
});
};
// #5128 #5234 Bei Exact Search soll er über Alle Listen nur mit dem Input ohne aktive Filter / orderBy suchen
/**
* Checks if the query token is an exact search based on the search trigger.
* An exact search is defined as:
* - Triggered by 'scan'
* - Or if the query token input contains a valid EAN (barcode) in 'qs'
* @param {QueryTokenInput} queryToken - The query token containing input parameters
* @param {SearchTrigger} searchTrigger - The trigger that initiated the search
* @returns {boolean} True if the search is exact, false otherwise
*/
const isExactSearch = (
queryToken: QueryTokenInput,
searchTrigger: SearchTrigger | 'reload' | 'initial',
): boolean => {
return searchTrigger === 'scan' || isEan(queryToken?.input?.['qs']);
};
// #5255 Performance optimization for initial department list fetch
/**
* Checks if the query token allows fetching the department list.
* This is true if the remission list type is 'Abteilung' and either:
* - There is a search input (queryToken.input['qs'])
* - There is an active filter for 'abteilungen'
*
* @param {QueryTokenInput} queryToken - The query token containing input and filter
* @param {RemissionListType} remissionListType - The type of remission list being queried
* @returns {boolean} True if the department list can be fetched, false otherwise
*/
const canFetchDepartmentList = (
queryToken: QueryTokenInput,
remissionListType: RemissionListType,
): boolean => {
const hasInput = queryToken?.input?.['qs'];
const hasAbteilungFilter = queryToken?.filter?.['abteilungen'];
return (
remissionListType === RemissionListType.Abteilung &&
(hasInput || hasAbteilungFilter)
);
};

View File

@@ -13,7 +13,7 @@
class="isa-text-body-1-bold"
*uiSkeletonLoader="loading(); height: '1.5rem'"
>
{{ positionCount() }}
{{ itemQuantity() }}
</div>
</div>
<div>

View File

@@ -1,304 +0,0 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { signal } from '@angular/core';
import { DatePipe } from '@angular/common';
import { RemissionReturnReceiptDetailsCardComponent } from './remission-return-receipt-details-card.component';
import { Receipt, Supplier } from '@isa/remission/data-access';
// Mock the supplier resource
vi.mock('./resources', () => ({
createSupplierResource: vi.fn(() => ({
value: signal([]),
isLoading: signal(false),
error: signal(null),
})),
}));
describe('RemissionReturnReceiptDetailsCardComponent', () => {
let component: RemissionReturnReceiptDetailsCardComponent;
let fixture: ComponentFixture<RemissionReturnReceiptDetailsCardComponent>;
const mockSuppliers: Supplier[] = [
{
id: 123,
name: 'Test Supplier GmbH',
address: 'Test Street 1',
} as Supplier,
{
id: 456,
name: 'Another Supplier Ltd',
address: 'Another Street 2',
} as Supplier,
];
const mockReceipt: Receipt = {
id: 789,
receiptNumber: 'RR-2024-001234-ABC',
completed: true,
created: new Date('2024-01-15T10:30:00Z'),
supplier: {
id: 123,
name: 'Test Supplier GmbH',
},
items: [
{
id: 1,
data: {
id: 1,
quantity: 5,
product: { id: 1, name: 'Product 1' },
},
},
{
id: 2,
data: {
id: 2,
quantity: 3,
product: { id: 2, name: 'Product 2' },
},
},
],
packages: [
{
id: 1,
data: {
id: 1,
packageNumber: 'PKG-001',
},
},
{
id: 2,
data: {
id: 2,
packageNumber: 'PKG-002',
},
},
],
} as Receipt;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [RemissionReturnReceiptDetailsCardComponent, DatePipe],
}).compileComponents();
fixture = TestBed.createComponent(RemissionReturnReceiptDetailsCardComponent);
component = fixture.componentInstance;
});
describe('Component Setup', () => {
it('should create', () => {
expect(component).toBeTruthy();
});
it('should have default loading state', () => {
expect(component.loading()).toBe(true);
});
it('should accept receipt input', () => {
fixture.componentRef.setInput('receipt', mockReceipt);
expect(component.receipt()).toEqual(mockReceipt);
});
it('should accept loading input', () => {
fixture.componentRef.setInput('loading', false);
expect(component.loading()).toBe(false);
});
});
describe('status computed signal', () => {
it('should return "Abgeschlossen" when receipt is completed', () => {
const completedReceipt = { ...mockReceipt, completed: true };
fixture.componentRef.setInput('receipt', completedReceipt);
expect(component.status()).toBe('Abgeschlossen');
});
it('should return "Offen" when receipt is not completed', () => {
const openReceipt = { ...mockReceipt, completed: false };
fixture.componentRef.setInput('receipt', openReceipt);
expect(component.status()).toBe('Offen');
});
it('should return "Offen" when no receipt provided', () => {
fixture.componentRef.setInput('receipt', undefined);
expect(component.status()).toBe('Offen');
});
});
describe('positionCount computed signal', () => {
it('should return the number of items', () => {
fixture.componentRef.setInput('receipt', mockReceipt);
// mockReceipt has 2 items
expect(component.positionCount()).toBe(2);
});
it('should return 0 when no items', () => {
const receiptWithoutItems = { ...mockReceipt, items: [] };
fixture.componentRef.setInput('receipt', receiptWithoutItems);
expect(component.positionCount()).toBe(0);
});
it('should return undefined when no receipt provided', () => {
fixture.componentRef.setInput('receipt', undefined);
expect(component.positionCount()).toBeUndefined();
});
it('should count all items regardless of data', () => {
const receiptWithUndefinedItems = {
...mockReceipt,
items: [
{ id: 1, data: undefined },
{ id: 2, data: { id: 2, quantity: 5 } },
],
};
fixture.componentRef.setInput('receipt', receiptWithUndefinedItems);
expect(component.positionCount()).toBe(2);
});
});
describe('supplier computed signal', () => {
it('should return supplier name when found', () => {
fixture.componentRef.setInput('receipt', mockReceipt);
(component.supplierResource as any).value = signal(mockSuppliers);
expect(component.supplier()).toBe('Test Supplier GmbH');
});
it('should return "Unbekannt" when supplier not found', () => {
const receiptWithUnknownSupplier = {
...mockReceipt,
supplier: { id: 999, name: 'Unknown' },
};
fixture.componentRef.setInput('receipt', receiptWithUnknownSupplier);
(component.supplierResource as any).value = signal(mockSuppliers);
expect(component.supplier()).toBe('Unbekannt');
});
it('should return "Unbekannt" when no suppliers loaded', () => {
fixture.componentRef.setInput('receipt', mockReceipt);
(component.supplierResource as any).value = signal([]);
expect(component.supplier()).toBe('Unbekannt');
});
it('should return "Unbekannt" when no receipt provided', () => {
fixture.componentRef.setInput('receipt', undefined);
(component.supplierResource as any).value = signal(mockSuppliers);
expect(component.supplier()).toBe('Unbekannt');
});
});
describe('completedAt computed signal', () => {
it('should return created date', () => {
fixture.componentRef.setInput('receipt', mockReceipt);
expect(component.completedAt()).toEqual(mockReceipt.created);
});
it('should return undefined when no receipt', () => {
fixture.componentRef.setInput('receipt', undefined);
expect(component.completedAt()).toBeUndefined();
});
});
describe('remiDate computed signal', () => {
it('should return completed date when available', () => {
const completedDate = new Date('2024-01-20T15:45:00Z');
const receiptWithCompleted = {
...mockReceipt,
completed: completedDate,
created: new Date('2024-01-15T10:30:00Z'),
};
fixture.componentRef.setInput('receipt', receiptWithCompleted);
expect(component.remiDate()).toEqual(completedDate);
});
it('should return created date when completed date not available', () => {
const receiptWithoutCompleted = {
...mockReceipt,
completed: false,
};
fixture.componentRef.setInput('receipt', receiptWithoutCompleted);
expect(component.remiDate()).toEqual(mockReceipt.created);
});
it('should return undefined when no receipt', () => {
fixture.componentRef.setInput('receipt', undefined);
expect(component.remiDate()).toBeUndefined();
});
});
describe('packageNumber computed signal', () => {
it('should return comma-separated package numbers', () => {
fixture.componentRef.setInput('receipt', mockReceipt);
expect(component.packageNumber()).toBe('PKG-001, PKG-002');
});
it('should return empty string when no packages', () => {
const receiptWithoutPackages = { ...mockReceipt, packages: [] };
fixture.componentRef.setInput('receipt', receiptWithoutPackages);
expect(component.packageNumber()).toBe('');
});
it('should return empty string when no receipt', () => {
fixture.componentRef.setInput('receipt', undefined);
expect(component.packageNumber()).toBe('');
});
it('should handle packages with undefined data', () => {
const receiptWithUndefinedPackages = {
...mockReceipt,
packages: [
{ id: 1, data: undefined },
{ id: 2, data: { id: 2, packageNumber: 'PKG-002' } },
],
};
fixture.componentRef.setInput('receipt', receiptWithUndefinedPackages);
// packageNumber maps undefined values, which join as ', PKG-002'
expect(component.packageNumber()).toBe(', PKG-002');
});
});
describe('Component reactivity', () => {
it('should update computed signals when receipt changes', () => {
// Initial receipt
fixture.componentRef.setInput('receipt', mockReceipt);
(component.supplierResource as any).value = signal(mockSuppliers);
expect(component.status()).toBe('Abgeschlossen');
expect(component.positionCount()).toBe(2);
// Change receipt
const newReceipt = {
...mockReceipt,
completed: false,
items: [{ id: 1, data: { id: 1, quantity: 10 } }],
};
fixture.componentRef.setInput('receipt', newReceipt);
expect(component.status()).toBe('Offen');
expect(component.positionCount()).toBe(1);
});
it('should create supplier resource on initialization', () => {
expect(component.supplierResource).toBeDefined();
expect(component.supplierResource.value).toBeDefined();
});
});
});

View File

@@ -4,25 +4,19 @@ import {
Component,
computed,
input,
linkedSignal,
} from '@angular/core';
import { Receipt } from '@isa/remission/data-access';
import {
getPackageNumbersFromReturn,
getReceiptItemQuantityFromReturn,
getReceiptNumberFromReturn,
getReceiptStatusFromReturn,
ReceiptCompleteStatusValue,
Return,
} from '@isa/remission/data-access';
import { SkeletonLoaderDirective } from '@isa/ui/skeleton-loader';
import { createSupplierResource } from './resources';
/**
* Component that displays detailed information about a remission return receipt in a card format.
* Shows supplier information, status, dates, item counts, and package numbers.
*
* @component
* @selector remi-remission-return-receipt-details-card
* @standalone
*
* @example
* <remi-remission-return-receipt-details-card
* [receipt]="receiptData"
* [loading]="isLoading">
* </remi-remission-return-receipt-details-card>
*/
@Component({
selector: 'remi-remission-return-receipt-details-card',
templateUrl: './remission-return-receipt-details-card.component.html',
@@ -33,10 +27,10 @@ import { createSupplierResource } from './resources';
})
export class RemissionReturnReceiptDetailsCardComponent {
/**
* Input for the receipt data to display.
* Input for the return data to be displayed in the card.
* @input
*/
receipt = input<Receipt>();
return = input.required<Return>();
/**
* Input to control the loading state of the card.
@@ -50,63 +44,57 @@ export class RemissionReturnReceiptDetailsCardComponent {
*/
supplierResource = createSupplierResource();
/**
* Computed signal that determines the receipt status text.
* @returns {'Abgeschlossen' | 'Offen'} Status text based on completion state
*/
status = computed(() => {
return this.receipt()?.completed ? 'Abgeschlossen' : 'Offen';
firstReceipt = computed(() => {
const returnData = this.return();
return returnData?.receipts?.[0]?.data;
});
/**
* Computed signal that calculates the items in the receipt.
* @returns {number} Count of items in the receipt or 0 if not available
* Computed signal that retrieves the receipt number from the return data.
* Uses the helper function to get the receipt number.
* @returns {string} The receipt number from the return
*/
positionCount = computed(() => {
const receipt = this.receipt();
return receipt?.items?.length;
receiptNumber = computed(() => {
const returnData = this.return();
return getReceiptNumberFromReturn(returnData);
});
/**
* Computed signal that finds and returns the supplier name.
* @returns {string} Supplier name or 'Unbekannt' if not found
* Computed signal that calculates the total item quantity from all receipts in the return.
* Uses the helper function to get the quantity.
* @returns {number} The total item quantity from all receipts
*/
itemQuantity = computed(() => {
const returnData = this.return();
return getReceiptItemQuantityFromReturn(returnData);
});
/**
* Linked signal that determines the completion status of the return.
* Uses the helper function to get the status based on the return data.
* @returns {ReceiptCompleteStatusValue} The completion status of the return
*/
status = linkedSignal<ReceiptCompleteStatusValue>(() => {
const returnData = this.return();
return getReceiptStatusFromReturn(returnData);
});
remiDate = computed(() => {
const returnData = this.return();
return returnData?.completed || returnData?.created;
});
packageNumber = computed(() => {
const returnData = this.return();
return getPackageNumbersFromReturn(returnData);
});
supplier = computed(() => {
const receipt = this.receipt();
const receipt = this.firstReceipt();
const supplier = this.supplierResource.value();
return (
supplier?.find((s) => s.id === receipt?.supplier?.id)?.name || 'Unbekannt'
);
});
/**
* Computed signal for the receipt completion date.
* @returns {Date | undefined} The creation date of the receipt
*/
completedAt = computed(() => {
const receipt = this.receipt();
return receipt?.created;
});
/**
* Computed signal for the remission date.
* Prioritizes completed date over created date.
* @returns {Date | undefined} The remission date
*/
remiDate = computed(() => {
const receipt = this.receipt();
return receipt?.completed || receipt?.created;
});
/**
* Computed signal that concatenates all package numbers.
* @returns {string} Comma-separated list of package numbers
*/
packageNumber = computed(() => {
const receipt = this.receipt();
return (
receipt?.packages?.map((p) => p.data?.packageNumber).join(', ') || ''
);
});
}

View File

@@ -1,492 +0,0 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { MockComponent, MockDirective } from 'ng-mocks';
import { RemissionReturnReceiptDetailsItemComponent } from './remission-return-receipt-details-item.component';
import { ProductFormatComponent } from '@isa/shared/product-foramt';
import { ProductImageDirective } from '@isa/shared/product-image';
import { ProductRouterLinkDirective } from '@isa/shared/product-router-link';
import {
ReceiptItem,
RemissionProductGroupService,
RemissionReturnReceiptService,
} from '@isa/remission/data-access';
import { IconButtonComponent } from '@isa/ui/buttons';
describe('RemissionReturnReceiptDetailsItemComponent', () => {
let component: RemissionReturnReceiptDetailsItemComponent;
let fixture: ComponentFixture<RemissionReturnReceiptDetailsItemComponent>;
const mockReceiptItem: ReceiptItem = {
id: 1,
quantity: 5,
product: {
id: 123,
name: 'Test Product',
contributors: 'Test Author',
ean: '1234567890123',
format: 'Hardcover',
formatDetail: '200 pages',
productGroup: 'BOOK',
},
} as ReceiptItem;
const mockProductGroups = [
{ key: 'BOOK', value: 'Books' },
{ key: 'MAGAZINE', value: 'Magazines' },
{ key: 'DVD', value: 'DVDs' },
];
const mockRemissionProductGroupService = {
fetchProductGroups: vi.fn().mockResolvedValue(mockProductGroups),
};
const mockRemissionReturnReceiptService = {
removeReturnItemFromReturnReceipt: vi.fn(),
};
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [RemissionReturnReceiptDetailsItemComponent],
providers: [
{
provide: RemissionProductGroupService,
useValue: mockRemissionProductGroupService,
},
{
provide: RemissionReturnReceiptService,
useValue: mockRemissionReturnReceiptService,
},
],
})
.overrideComponent(RemissionReturnReceiptDetailsItemComponent, {
remove: {
imports: [
ProductImageDirective,
ProductRouterLinkDirective,
ProductFormatComponent,
IconButtonComponent,
],
},
add: {
imports: [
MockDirective(ProductImageDirective),
MockDirective(ProductRouterLinkDirective),
MockComponent(ProductFormatComponent),
MockComponent(IconButtonComponent),
],
},
})
.compileComponents();
fixture = TestBed.createComponent(
RemissionReturnReceiptDetailsItemComponent,
);
component = fixture.componentInstance;
});
afterEach(() => {
mockRemissionReturnReceiptService.removeReturnItemFromReturnReceipt.mockClear();
});
describe('Component Setup', () => {
it('should create', () => {
fixture.componentRef.setInput('item', mockReceiptItem);
expect(component).toBeTruthy();
});
it('should have required item input', () => {
fixture.componentRef.setInput('item', mockReceiptItem);
expect(component.item()).toEqual(mockReceiptItem);
});
});
describe('Component with valid receipt item', () => {
beforeEach(() => {
fixture.componentRef.setInput('item', mockReceiptItem);
});
it('should display receipt item data', () => {
expect(component.item()).toEqual(mockReceiptItem);
expect(component.item().id).toBe(1);
expect(component.item().quantity).toBe(5);
expect(component.item().product.name).toBe('Test Product');
});
it('should handle product information correctly', () => {
const item = component.item();
expect(item.product.name).toBe('Test Product');
expect(item.product.contributors).toBe('Test Author');
expect(item.product.ean).toBe('1234567890123');
expect(item.product.format).toBe('Hardcover');
expect(item.product.formatDetail).toBe('200 pages');
expect(item.product.productGroup).toBe('BOOK');
});
it('should handle quantity correctly', () => {
expect(component.item().quantity).toBe(5);
});
it('should have default removeable value', () => {
expect(component.removeable()).toBe(false);
});
});
describe('Component with different receipt item data', () => {
it('should handle different quantity values', () => {
const differentItem = {
...mockReceiptItem,
quantity: 10,
};
fixture.componentRef.setInput('item', differentItem);
expect(component.item().quantity).toBe(10);
});
it('should handle different product information', () => {
const differentItem: ReceiptItem = {
...mockReceiptItem,
product: {
...mockReceiptItem.product,
name: 'Different Product',
contributors: 'Different Author',
productGroup: 'MAGAZINE',
},
};
fixture.componentRef.setInput('item', differentItem);
expect(component.item().product.name).toBe('Different Product');
expect(component.item().product.contributors).toBe('Different Author');
expect(component.item().product.productGroup).toBe('MAGAZINE');
});
it('should handle item with different ID', () => {
const differentItem = {
...mockReceiptItem,
id: 999,
};
fixture.componentRef.setInput('item', differentItem);
expect(component.item().id).toBe(999);
});
});
describe('Component reactivity', () => {
it('should update when item input changes', () => {
fixture.componentRef.setInput('item', mockReceiptItem);
expect(component.item().quantity).toBe(5);
expect(component.item().product.name).toBe('Test Product');
// Change the item
const newItem = {
...mockReceiptItem,
id: 2,
quantity: 3,
product: {
...mockReceiptItem.product,
name: 'Updated Product',
},
};
fixture.componentRef.setInput('item', newItem);
expect(component.item().id).toBe(2);
expect(component.item().quantity).toBe(3);
expect(component.item().product.name).toBe('Updated Product');
});
});
describe('Removeable input', () => {
it('should default to false when not provided', () => {
fixture.componentRef.setInput('item', mockReceiptItem);
expect(component.removeable()).toBe(false);
});
it('should accept true value', () => {
fixture.componentRef.setInput('item', mockReceiptItem);
fixture.componentRef.setInput('removeable', true);
expect(component.removeable()).toBe(true);
});
it('should accept false value', () => {
fixture.componentRef.setInput('item', mockReceiptItem);
fixture.componentRef.setInput('removeable', false);
expect(component.removeable()).toBe(false);
});
});
describe('Product Group functionality', () => {
beforeEach(() => {
fixture.componentRef.setInput('item', mockReceiptItem);
});
it('should initialize productGroupResource', () => {
expect(component.productGroupResource).toBeDefined();
});
it('should compute productGroupDetail correctly when resource has data', () => {
// Mock the resource value directly
vi.spyOn(component.productGroupResource, 'value').mockReturnValue(
mockProductGroups,
);
// The productGroupDetail should find the matching product group
expect(component.productGroupDetail()).toBe('Books');
});
it('should return empty string when resource value is undefined', () => {
// Mock the resource to return undefined
vi.spyOn(component.productGroupResource, 'value').mockReturnValue(
undefined,
);
expect(component.productGroupDetail()).toBe('');
});
it('should return empty string when product group not found', () => {
const differentItem: ReceiptItem = {
...mockReceiptItem,
product: {
...mockReceiptItem.product,
productGroup: 'UNKNOWN',
},
};
fixture.componentRef.setInput('item', differentItem);
// Mock the resource value
vi.spyOn(component.productGroupResource, 'value').mockReturnValue(
mockProductGroups,
);
expect(component.productGroupDetail()).toBe('');
});
});
describe('Icon button rendering', () => {
it('should render icon button when removeable is true', () => {
fixture.componentRef.setInput('item', mockReceiptItem);
fixture.componentRef.setInput('removeable', true);
fixture.detectChanges();
const iconButton = fixture.nativeElement.querySelector('ui-icon-button');
expect(iconButton).toBeTruthy();
});
it('should not render icon button when removeable is false', () => {
fixture.componentRef.setInput('item', mockReceiptItem);
fixture.componentRef.setInput('removeable', false);
fixture.detectChanges();
const iconButton = fixture.nativeElement.querySelector('ui-icon-button');
expect(iconButton).toBeFalsy();
});
it('should render icon button with correct properties', () => {
fixture.componentRef.setInput('item', mockReceiptItem);
fixture.componentRef.setInput('removeable', true);
fixture.detectChanges();
const iconButton = fixture.debugElement.query(
By.css('ui-icon-button'),
)?.componentInstance;
expect(iconButton).toBeTruthy();
expect(iconButton.name).toBe('isaActionClose');
expect(iconButton.size).toBe('large');
expect(iconButton.color).toBe('secondary');
});
});
describe('Template rendering', () => {
beforeEach(() => {
fixture.componentRef.setInput('item', mockReceiptItem);
fixture.detectChanges();
});
it('should render product image with correct attributes', () => {
const img = fixture.nativeElement.querySelector('img');
expect(img).toBeTruthy();
expect(img.getAttribute('alt')).toBe('Test Product');
expect(img.classList.contains('w-full')).toBe(true);
expect(img.classList.contains('max-h-[5.125rem]')).toBe(true);
expect(img.classList.contains('object-contain')).toBe(true);
});
it('should render product contributors', () => {
const contributorsElement = fixture.nativeElement.querySelector(
'.isa-text-body-2-bold',
);
expect(contributorsElement).toBeTruthy();
expect(contributorsElement.textContent).toBe('Test Author');
});
it('should render product name', () => {
const nameElement = fixture.nativeElement.querySelector(
'.isa-text-body-2-regular',
);
expect(nameElement).toBeTruthy();
expect(nameElement.textContent).toBe('Test Product');
});
it('should render bullet list items', () => {
const bulletListItems = fixture.nativeElement.querySelectorAll(
'ui-bullet-list-item',
);
expect(bulletListItems.length).toBe(2);
});
});
describe('Component imports', () => {
it('should have ProductImageDirective import', () => {
fixture.componentRef.setInput('item', mockReceiptItem);
// Component should be created successfully with mocked imports
expect(component).toBeTruthy();
});
it('should have ProductRouterLinkDirective import', () => {
fixture.componentRef.setInput('item', mockReceiptItem);
// Component should be created successfully with mocked imports
expect(component).toBeTruthy();
});
it('should have ProductFormatComponent import', () => {
fixture.componentRef.setInput('item', mockReceiptItem);
// Component should be created successfully with mocked imports
expect(component).toBeTruthy();
});
});
describe('E2E Testing Attributes', () => {
it('should consider adding data-what and data-which attributes for E2E testing', () => {
// This test serves as a reminder that E2E testing attributes
// should be added to the template for better testability.
// Currently the template does not have these attributes.
fixture.componentRef.setInput('item', mockReceiptItem);
fixture.detectChanges();
const hostElement = fixture.nativeElement;
// Verify the component renders (basic check)
expect(hostElement).toBeTruthy();
// Note: In a future update, the template should include:
// - data-what="receipt-item" on the host or main container
// - data-which="receipt-item-details"
// - [attr.data-item-id]="item().id" for dynamic identification
// This would improve E2E test reliability and maintainability
});
});
describe('New inputs - receiptId and returnId', () => {
it('should accept receiptId input', () => {
fixture.componentRef.setInput('item', mockReceiptItem);
fixture.componentRef.setInput('receiptId', 123);
expect(component.receiptId()).toBe(123);
});
it('should accept returnId input', () => {
fixture.componentRef.setInput('item', mockReceiptItem);
fixture.componentRef.setInput('returnId', 456);
expect(component.returnId()).toBe(456);
});
it('should handle both receiptId and returnId inputs together', () => {
fixture.componentRef.setInput('item', mockReceiptItem);
fixture.componentRef.setInput('receiptId', 123);
fixture.componentRef.setInput('returnId', 456);
expect(component.receiptId()).toBe(123);
expect(component.returnId()).toBe(456);
});
});
describe('Remove functionality', () => {
beforeEach(() => {
fixture.componentRef.setInput('item', mockReceiptItem);
fixture.componentRef.setInput('receiptId', 123);
fixture.componentRef.setInput('returnId', 456);
});
it('should initialize removing signal as false', () => {
expect(component.removing()).toBe(false);
});
it('should call service and emit removed event on successful remove', async () => {
mockRemissionReturnReceiptService.removeReturnItemFromReturnReceipt.mockResolvedValue(
undefined,
);
let emittedItem: ReceiptItem | undefined;
component.removed.subscribe((item) => {
emittedItem = item;
});
await component.remove();
expect(
mockRemissionReturnReceiptService.removeReturnItemFromReturnReceipt,
).toHaveBeenCalledWith({
receiptId: 123,
returnId: 456,
receiptItemId: 1,
});
expect(emittedItem).toEqual(mockReceiptItem);
expect(component.removing()).toBe(false);
});
it('should handle remove error gracefully', async () => {
const mockError = new Error('Remove failed');
mockRemissionReturnReceiptService.removeReturnItemFromReturnReceipt.mockRejectedValue(
mockError,
);
let emittedItem: ReceiptItem | undefined;
component.removed.subscribe((item) => {
emittedItem = item;
});
await component.remove();
expect(
mockRemissionReturnReceiptService.removeReturnItemFromReturnReceipt,
).toHaveBeenCalledWith({
receiptId: 123,
returnId: 456,
receiptItemId: 1,
});
expect(emittedItem).toBeUndefined();
expect(component.removing()).toBe(false);
});
it('should not call service if already removing', async () => {
component.removing.set(true);
await component.remove();
expect(
mockRemissionReturnReceiptService.removeReturnItemFromReturnReceipt,
).not.toHaveBeenCalled();
});
});
});

View File

@@ -10,7 +10,6 @@ import {
import {
ReceiptItem,
RemissionReturnReceiptService,
ReturnItem,
} from '@isa/remission/data-access';
import { ProductFormatComponent } from '@isa/shared/product-foramt';
import { ProductImageDirective } from '@isa/shared/product-image';

View File

@@ -18,13 +18,13 @@
</div>
<div></div>
<remi-remission-return-receipt-details-card
[receipt]="returnResource.value()"
[loading]="returnResource.isLoading()"
[return]="returnData()"
[loading]="returnLoading()"
></remi-remission-return-receipt-details-card>
@let items = returnResource.value()?.items;
@let items = receiptItems();
@if (returnResource.isLoading()) {
@if (returnLoading()) {
<div class="text-center">
<ui-icon-button
class="animate-spin"
@@ -33,31 +33,26 @@
color="neutral"
></ui-icon-button>
</div>
} @else if (items.length === 0) {
} @else if (items?.length === 0 && !returnData()?.completed) {
<div class="flex items-center justify-center">
<ui-empty-state
[title]="emptyWbsTitle"
[description]="emptyWbsDescription"
appearance="noArticles"
>
<button
<lib-remission-return-receipt-actions
class="mt-[1.5rem]"
uiButton
type="button"
appearance="secondary"
size="large"
[disabled]="store.remissionStarted()"
(click)="continueRemission()"
>
Jetzt befüllen
</button>
[remissionReturn]="returnData()"
[displayDeleteAction]="false"
(reloadData)="returnResource.reload()"
></lib-remission-return-receipt-actions>
</ui-empty-state>
</div>
} @else {
<div class="bg-isa-white rounded-2xl p-6 grid grid-flow-row gap-6">
@for (item of items; track item.id; let last = $last) {
<remi-remission-return-receipt-details-item
[item]="item.data"
[item]="item"
[removeable]="canRemoveItems()"
[receiptId]="receiptId()"
[returnId]="returnId()"
@@ -69,22 +64,11 @@
}
</div>
}
@if (!returnResource.isLoading() && !returnResource.value()?.completed) {
<ui-stateful-button
class="fixed right-6 bottom-6"
(clicked)="completeReturn()"
[(state)]="completeReturnState"
defaultContent="Wanne abschließen"
defaultWidth="13rem"
[errorContent]="completeReturnError()"
errorWidth="32rem"
errorAction="Erneut versuchen"
(action)="completeReturn()"
successContent="Wanne abgeschlossen"
successWidth="20rem"
[pending]="completingReturn()"
size="large"
color="brand"
>
</ui-stateful-button>
@if (!returnLoading() && !returnData()?.completed) {
<lib-remission-return-receipt-complete
[returnId]="returnId()"
[receiptId]="receiptId()"
[itemsLength]="items?.length"
(reloadData)="returnResource.reload()"
></lib-remission-return-receipt-complete>
}

View File

@@ -1,316 +0,0 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { MockComponent, MockProvider } from 'ng-mocks';
import { signal } from '@angular/core';
import { Location } from '@angular/common';
import { RemissionReturnReceiptDetailsComponent } from './remission-return-receipt-details.component';
import { RemissionReturnReceiptDetailsCardComponent } from './remission-return-receipt-details-card.component';
import { RemissionReturnReceiptDetailsItemComponent } from './remission-return-receipt-details-item.component';
import {
Receipt,
RemissionReturnReceiptService,
RemissionStore,
} from '@isa/remission/data-access';
// Mock the resource function
vi.mock('./resources/remission-return-receipt.resource', () => ({
createRemissionReturnReceiptResource: vi.fn(() => ({
value: signal(null),
isLoading: signal(false),
error: signal(null),
})),
}));
describe('RemissionReturnReceiptDetailsComponent', () => {
let component: RemissionReturnReceiptDetailsComponent;
let fixture: ComponentFixture<RemissionReturnReceiptDetailsComponent>;
const mockReceipt: Receipt = {
id: 123,
receiptNumber: 'RR-2024-001234-ABC',
items: [],
completed: true,
created: new Date('2024-01-15T10:30:00Z'),
} as Receipt;
const mockRemissionReturnReceiptService = {
completeReturnReceiptAndReturn: vi.fn(),
};
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [RemissionReturnReceiptDetailsComponent],
providers: [
MockProvider(Location, { back: vi.fn() }),
{
provide: RemissionReturnReceiptService,
useValue: mockRemissionReturnReceiptService,
},
MockProvider(RemissionStore, {
returnId: signal(123),
receiptId: signal(456),
finishRemission: vi.fn(),
}),
],
})
.overrideComponent(RemissionReturnReceiptDetailsComponent, {
remove: {
imports: [
RemissionReturnReceiptDetailsCardComponent,
RemissionReturnReceiptDetailsItemComponent,
],
},
add: {
imports: [
MockComponent(RemissionReturnReceiptDetailsCardComponent),
MockComponent(RemissionReturnReceiptDetailsItemComponent),
],
},
})
.compileComponents();
fixture = TestBed.createComponent(RemissionReturnReceiptDetailsComponent);
component = fixture.componentInstance;
});
describe('Component Setup', () => {
it('should create', () => {
fixture.componentRef.setInput('returnId', 123);
fixture.componentRef.setInput('receiptId', 456);
expect(component).toBeTruthy();
});
it('should have required inputs', () => {
fixture.componentRef.setInput('returnId', 123);
fixture.componentRef.setInput('receiptId', 456);
expect(component.returnId()).toBe(123);
expect(component.receiptId()).toBe(456);
});
it('should coerce string inputs to numbers', () => {
fixture.componentRef.setInput('returnId', '123');
fixture.componentRef.setInput('receiptId', '456');
expect(component.returnId()).toBe(123);
expect(component.receiptId()).toBe(456);
});
});
describe('Dependencies', () => {
it('should inject Location service', () => {
fixture.componentRef.setInput('returnId', 123);
fixture.componentRef.setInput('receiptId', 456);
expect(component.location).toBeDefined();
});
it('should create return resource', () => {
fixture.componentRef.setInput('returnId', 123);
fixture.componentRef.setInput('receiptId', 456);
expect(component.returnResource).toBeDefined();
});
});
describe('receiptNumber computed signal', () => {
it('should return empty string when no receipt data', () => {
fixture.componentRef.setInput('returnId', 123);
fixture.componentRef.setInput('receiptId', 456);
// Mock empty resource
(component.returnResource as any).value = signal(null);
expect(component.receiptNumber()).toBe('');
});
it('should extract receipt number substring correctly', () => {
fixture.componentRef.setInput('returnId', 123);
fixture.componentRef.setInput('receiptId', 456);
// Mock resource with receipt data
(component.returnResource as any).value = signal(mockReceipt);
// substring(6, 12) on 'RR-2024-001234-ABC' should return '4-0012'
expect(component.receiptNumber()).toBe('4-0012');
});
it('should handle undefined receipt number', () => {
const receiptWithoutNumber = {
...mockReceipt,
receiptNumber: undefined,
};
fixture.componentRef.setInput('returnId', 123);
fixture.componentRef.setInput('receiptId', 456);
(component.returnResource as any).value = signal(receiptWithoutNumber);
expect(component.receiptNumber()).toBe('');
});
});
describe('Resource reactivity', () => {
it('should handle resource loading state', () => {
fixture.componentRef.setInput('returnId', 123);
fixture.componentRef.setInput('receiptId', 456);
// Mock loading resource
(component.returnResource as any).isLoading = signal(true);
expect(component.returnResource.isLoading()).toBe(true);
});
it('should handle resource with data', () => {
fixture.componentRef.setInput('returnId', 123);
fixture.componentRef.setInput('receiptId', 456);
// Mock resource with data
(component.returnResource as any).value = signal(mockReceipt);
(component.returnResource as any).isLoading = signal(false);
expect(component.returnResource.value()).toEqual(mockReceipt);
expect(component.returnResource.isLoading()).toBe(false);
});
});
describe('canRemoveItems computed signal', () => {
it('should return true when receipt is not completed', () => {
const incompleteReceipt = {
...mockReceipt,
completed: false,
};
fixture.componentRef.setInput('returnId', 123);
fixture.componentRef.setInput('receiptId', 456);
(component.returnResource as any).value = signal(incompleteReceipt);
expect(component.canRemoveItems()).toBe(true);
});
it('should return false when receipt is completed', () => {
fixture.componentRef.setInput('returnId', 123);
fixture.componentRef.setInput('receiptId', 456);
(component.returnResource as any).value = signal(mockReceipt);
expect(component.canRemoveItems()).toBe(false);
});
it('should return false when no receipt data', () => {
fixture.componentRef.setInput('returnId', 123);
fixture.componentRef.setInput('receiptId', 456);
(component.returnResource as any).value = signal(null);
// Fix: canRemoveItems() should be false when no data
expect(component.canRemoveItems()).toBe(false);
});
});
describe('completeReturn functionality', () => {
beforeEach(() => {
fixture.componentRef.setInput('returnId', 123);
fixture.componentRef.setInput('receiptId', 456);
(component.returnResource as any).reload = vi.fn();
// Reset mocks before each test to avoid call count bleed
mockRemissionReturnReceiptService.completeReturnReceiptAndReturn.mockClear();
if (
component.store.finishRemission &&
'mockClear' in component.store.finishRemission
) {
(component.store.finishRemission as any).mockClear();
}
});
it('should initialize completion state signals', () => {
expect(component.completeReturnState()).toBe('default');
expect(component.completingReturn()).toBe(false);
expect(component.completeReturnError()).toBe(null);
});
it('should complete return successfully', async () => {
const mockCompletedReturn = { ...mockReceipt, completed: true };
mockRemissionReturnReceiptService.completeReturnReceiptAndReturn.mockResolvedValue(
mockCompletedReturn,
);
await component.completeReturn();
expect(
mockRemissionReturnReceiptService.completeReturnReceiptAndReturn,
).toHaveBeenCalledWith({
returnId: 123,
receiptId: 456,
});
expect(component.completeReturnState()).toBe('success');
expect(component.completingReturn()).toBe(false);
expect(component.completeReturnError()).toBe(null);
expect(component.returnResource.reload).toHaveBeenCalled();
});
it('should handle completion error', async () => {
const mockError = new Error('Completion failed');
mockRemissionReturnReceiptService.completeReturnReceiptAndReturn.mockRejectedValue(
mockError,
);
await component.completeReturn();
expect(
mockRemissionReturnReceiptService.completeReturnReceiptAndReturn,
).toHaveBeenCalledWith({
returnId: 123,
receiptId: 456,
});
expect(component.completeReturnState()).toBe('error');
expect(component.completingReturn()).toBe(false);
expect(component.completeReturnError()).toBe('Completion failed');
expect(component.returnResource.reload).not.toHaveBeenCalled();
});
it('should handle non-Error objects', async () => {
mockRemissionReturnReceiptService.completeReturnReceiptAndReturn.mockRejectedValue(
'String error',
);
await component.completeReturn();
expect(component.completeReturnState()).toBe('error');
expect(component.completeReturnError()).toBe(
'Wanne konnte nicht abgeschlossen werden',
);
});
it('should call finishRemission on store', async () => {
// Fix: ensure the mock is reset and tracked
if (
component.store.finishRemission &&
'mockClear' in component.store.finishRemission
) {
(component.store.finishRemission as any).mockClear();
}
mockRemissionReturnReceiptService.completeReturnReceiptAndReturn.mockResolvedValue(
{},
);
await component.completeReturn();
expect(component.store.finishRemission).toHaveBeenCalled();
});
it('should not process if already completing', async () => {
// Fix: ensure no calls are made if already completing
component.completingReturn.set(true);
// Clear any previous calls
mockRemissionReturnReceiptService.completeReturnReceiptAndReturn.mockClear();
await component.completeReturn();
expect(
mockRemissionReturnReceiptService.completeReturnReceiptAndReturn,
).not.toHaveBeenCalled();
});
});
});

View File

@@ -4,43 +4,26 @@ import {
computed,
inject,
input,
signal,
} from '@angular/core';
import { coerceNumberProperty, NumberInput } from '@angular/cdk/coercion';
import {
ButtonComponent,
IconButtonComponent,
StatefulButtonComponent,
StatefulButtonState,
} from '@isa/ui/buttons';
import { ButtonComponent, IconButtonComponent } from '@isa/ui/buttons';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { isaActionChevronLeft, isaLoading } from '@isa/icons';
import { RemissionReturnReceiptDetailsCardComponent } from './remission-return-receipt-details-card.component';
import { RemissionReturnReceiptDetailsItemComponent } from './remission-return-receipt-details-item.component';
import { Location } from '@angular/common';
import { createRemissionReturnReceiptResource } from './resources/remission-return-receipt.resource';
import { createReturnResource } from './resources/return.resource';
import {
RemissionReturnReceiptService,
RemissionStore,
getReceiptItemsFromReturn,
getReceiptNumberFromReturn,
} from '@isa/remission/data-access';
import { logger } from '@isa/core/logging';
import { EmptyStateComponent } from '@isa/ui/empty-state';
import { EMPTY_WBS_DESCRIPTION, EMPTY_WBS_TITLE } from './constants';
import {
RemissionReturnReceiptActionsComponent,
RemissionReturnReceiptCompleteComponent,
} from '@isa/remission/shared/return-receipt-actions';
/**
* Component for displaying detailed information about a remission return receipt.
* Shows receipt header information and individual receipt items.
*
* @component
* @selector remi-remission-return-receipt-details
* @standalone
*
* @example
* <remi-remission-return-receipt-details
* [returnId]="123"
* [receiptId]="456">
* </remi-remission-return-receipt-details>
*/
@Component({
selector: 'remi-remission-return-receipt-details',
templateUrl: './remission-return-receipt-details.component.html',
@@ -53,23 +36,18 @@ import { EMPTY_WBS_DESCRIPTION, EMPTY_WBS_TITLE } from './constants';
NgIcon,
RemissionReturnReceiptDetailsCardComponent,
RemissionReturnReceiptDetailsItemComponent,
StatefulButtonComponent,
EmptyStateComponent,
RemissionReturnReceiptActionsComponent,
RemissionReturnReceiptCompleteComponent,
],
providers: [provideIcons({ isaActionChevronLeft, isaLoading })],
})
export class RemissionReturnReceiptDetailsComponent {
#logger = logger(() => ({
component: 'RemissionReturnReceiptDetailsComponent',
}));
#remissionReturnReceiptService = inject(RemissionReturnReceiptService);
/** Title for the empty state when no remission return receipt is available */
emptyWbsTitle = EMPTY_WBS_TITLE;
emptyWbsDescription = EMPTY_WBS_DESCRIPTION;
/** Instance of the RemissionStore for managing remission state */
store = inject(RemissionStore);
/** Description for the empty state when no remission return receipt is available */
emptyWbsDescription = EMPTY_WBS_DESCRIPTION;
/** Angular Location service for navigation */
location = inject(Location);
@@ -95,66 +73,36 @@ export class RemissionReturnReceiptDetailsComponent {
});
/**
* Resource that fetches the return receipt data based on the provided IDs.
* Automatically updates when input IDs change.
* Computed signal that retrieves the current remission return receipt.
* This is used to display detailed information about the return receipt.
* @returns {Return} The remission return receipt data
*/
returnResource = createRemissionReturnReceiptResource(() => ({
returnResource = createReturnResource(() => ({
returnId: this.returnId(),
receiptId: this.receiptId(),
eagerLoading: 3,
}));
returnLoading = computed(() => this.returnResource.isLoading());
returnData = computed(() => this.returnResource.value());
/**
* Computed signal that extracts the receipt number from the resource.
* Returns a substring of the receipt number (characters 6-12) for display.
* @returns {string} The formatted receipt number or empty string if not available
* Computed signal that retrieves the receipt number from the return data.
* Uses the helper function to get the receipt number.
* @returns {string} The receipt number from the return
*/
receiptNumber = computed(() => {
const ret = this.returnResource.value();
if (!ret) {
return '';
}
const returnData = this.returnData();
return getReceiptNumberFromReturn(returnData!);
});
return ret.receiptNumber?.substring(6, 12) || '';
receiptItems = computed(() => {
const returnData = this.returnData();
return getReceiptItemsFromReturn(returnData!);
});
canRemoveItems = computed(() => {
const ret = this.returnResource.value();
return !!ret && !ret.completed;
const returnData = this.returnData();
return !!returnData && !returnData.completed;
});
completeReturnState = signal<StatefulButtonState>('default');
completingReturn = signal(false);
completeReturnError = signal<string | null>(null);
async continueRemission() {
this.store.startRemission({
returnId: this.returnId(),
receiptId: this.receiptId(),
});
}
async completeReturn() {
if (this.completingReturn()) {
return;
}
this.completingReturn.set(true);
try {
await this.#remissionReturnReceiptService.completeReturnReceiptAndReturn({
returnId: this.returnId(),
receiptId: this.receiptId(),
});
this.store.finishRemission();
this.completeReturnState.set('success');
this.returnResource.reload();
} catch (error) {
this.#logger.error('Failed to complete return', error);
this.completeReturnError.set(
error instanceof Error
? error.message
: 'Wanne konnte nicht abgeschlossen werden',
);
this.completeReturnState.set('error');
}
this.completingReturn.set(false);
}
}

View File

@@ -1,3 +1,3 @@
export * from './product-group.resource';
export * from './remission-return-receipt.resource';
export * from './return.resource';
export * from './supplier.resource';

View File

@@ -1,184 +0,0 @@
import { TestBed } from '@angular/core/testing';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { runInInjectionContext, Injector } from '@angular/core';
import { MockProvider } from 'ng-mocks';
import { createRemissionReturnReceiptResource } from './remission-return-receipt.resource';
import { RemissionReturnReceiptService } from '@isa/remission/data-access';
import { Receipt } from '@isa/remission/data-access';
describe('createRemissionReturnReceiptResource', () => {
let mockService: any;
let mockReceipt: Receipt;
beforeEach(() => {
mockReceipt = {
id: 123,
receiptNumber: 'RR-2024-001234-ABC',
completed: true,
created: new Date('2024-01-15T10:30:00Z'),
supplier: {
id: 456,
name: 'Test Supplier',
},
items: [
{
id: 1,
data: {
id: 1,
quantity: 5,
product: { id: 1, name: 'Product 1' },
},
},
],
packages: [
{
id: 1,
data: {
id: 1,
packageNumber: 'PKG-001',
},
},
],
} as Receipt;
mockService = {
fetchRemissionReturnReceipt: vi.fn().mockResolvedValue(mockReceipt),
};
TestBed.configureTestingModule({
providers: [
MockProvider(RemissionReturnReceiptService, mockService),
],
});
});
describe('Resource Creation', () => {
it('should create resource successfully', () => {
const resource = runInInjectionContext(TestBed.inject(Injector), () =>
createRemissionReturnReceiptResource(() => ({
receiptId: 123,
returnId: 456,
}))
);
expect(resource).toBeDefined();
expect(resource.value).toBeDefined();
expect(resource.isLoading).toBeDefined();
expect(resource.error).toBeDefined();
});
it('should inject RemissionReturnReceiptService', () => {
const resource = runInInjectionContext(TestBed.inject(Injector), () =>
createRemissionReturnReceiptResource(() => ({
receiptId: 123,
returnId: 456,
}))
);
expect(resource).toBeDefined();
expect(mockService).toBeDefined();
});
});
describe('Resource Parameters', () => {
it('should handle numeric parameters', () => {
const resource = runInInjectionContext(TestBed.inject(Injector), () =>
createRemissionReturnReceiptResource(() => ({
receiptId: 123,
returnId: 456,
}))
);
expect(resource).toBeDefined();
});
it('should handle string parameters', () => {
const resource = runInInjectionContext(TestBed.inject(Injector), () =>
createRemissionReturnReceiptResource(() => ({
receiptId: '123',
returnId: '456',
}))
);
expect(resource).toBeDefined();
});
it('should handle mixed parameter types', () => {
const resource = runInInjectionContext(TestBed.inject(Injector), () =>
createRemissionReturnReceiptResource(() => ({
receiptId: 123,
returnId: '456',
}))
);
expect(resource).toBeDefined();
});
});
describe('Resource State Management', () => {
it('should provide loading state', () => {
const resource = runInInjectionContext(TestBed.inject(Injector), () =>
createRemissionReturnReceiptResource(() => ({
receiptId: 123,
returnId: 456,
}))
);
expect(resource.isLoading).toBeDefined();
expect(typeof resource.isLoading).toBe('function');
});
it('should provide error state', () => {
const resource = runInInjectionContext(TestBed.inject(Injector), () =>
createRemissionReturnReceiptResource(() => ({
receiptId: 123,
returnId: 456,
}))
);
expect(resource.error).toBeDefined();
expect(typeof resource.error).toBe('function');
});
it('should provide value state', () => {
const resource = runInInjectionContext(TestBed.inject(Injector), () =>
createRemissionReturnReceiptResource(() => ({
receiptId: 123,
returnId: 456,
}))
);
expect(resource.value).toBeDefined();
expect(typeof resource.value).toBe('function');
});
});
describe('Resource Function', () => {
it('should create resource function correctly', () => {
const createResourceFn = () => createRemissionReturnReceiptResource(() => ({
receiptId: 123,
returnId: 456,
}));
const resource = runInInjectionContext(TestBed.inject(Injector), createResourceFn);
expect(resource).toBeDefined();
expect(typeof resource.value).toBe('function');
expect(typeof resource.isLoading).toBe('function');
expect(typeof resource.error).toBe('function');
});
it('should handle resource initialization', () => {
const params = { receiptId: 123, returnId: 456 };
const resource = runInInjectionContext(TestBed.inject(Injector), () =>
createRemissionReturnReceiptResource(() => params)
);
expect(resource).toBeDefined();
expect(resource.value).toBeDefined();
expect(resource.isLoading).toBeDefined();
expect(resource.error).toBeDefined();
});
});
});

View File

@@ -1,39 +0,0 @@
import { resource, inject } from '@angular/core';
import {
RemissionReturnReceiptService,
FetchRemissionReturnParams,
} from '@isa/remission/data-access';
/**
* Creates an Angular resource for fetching a specific remission return receipt.
* The resource automatically manages loading state and caching.
*
* @function createRemissionReturnReceiptResource
* @param {Function} params - Function that returns the receipt and return IDs
* @param {string | number} params.receiptId - ID of the receipt to fetch
* @param {string | number} params.returnId - ID of the return containing the receipt
* @returns {Resource} Angular resource that manages the receipt data
*
* @example
* const receiptResource = createRemissionReturnReceiptResource(() => ({
* receiptId: '123',
* returnId: '456'
* }));
*
* // Access the resource value
* const receipt = receiptResource.value();
* const isLoading = receiptResource.isLoading();
*/
export const createRemissionReturnReceiptResource = (
params: () => FetchRemissionReturnParams,
) => {
const remissionReturnReceiptService = inject(RemissionReturnReceiptService);
return resource({
params,
loader: ({ abortSignal, params }) =>
remissionReturnReceiptService.fetchRemissionReturnReceipt(
params,
abortSignal,
),
});
};

View File

@@ -0,0 +1,20 @@
import { resource, inject } from '@angular/core';
import {
FetchReturn,
RemissionReturnReceiptService,
} from '@isa/remission/data-access';
/**
* Resource for creating a new remission return.
* It uses the RemissionReturnReceiptService to handle the creation logic.
* @param {Function} params - Function that returns parameters for creating a return
* @return {Resource} Angular resource that manages the return creation data
*/
export const createReturnResource = (params: () => FetchReturn) => {
const remissionReturnReceiptService = inject(RemissionReturnReceiptService);
return resource({
params,
loader: ({ abortSignal, params }) =>
remissionReturnReceiptService.fetchReturn(params, abortSignal),
});
};

View File

@@ -1,5 +1,7 @@
<remi-return-receipt-list-card></remi-return-receipt-list-card>
<div class="flex flex-rows justify-end">
<filter-order-by-toolbar class="w-[44.375rem]"></filter-order-by-toolbar>
<filter-controls-panel></filter-controls-panel>
</div>
<div class="grid grid-flow-rows grid-cols-1 gap-4">
@@ -7,6 +9,7 @@
<a [routerLink]="[remissionReturn[0].id, remissionReturn[1].id]">
<remi-return-receipt-list-item
[remissionReturn]="remissionReturn[0]"
(reloadList)="reloadList()"
></remi-return-receipt-list-item>
</a>
}

View File

@@ -1,3 +1,3 @@
:host {
@apply grid grid-flow-row gap-8 p-6;
@apply w-full grid grid-flow-row gap-4 mt-5 isa-desktop:mt-6 overflow-x-hidden;
}

View File

@@ -1,15 +1,9 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { Component, signal } from '@angular/core';
import { MockProvider } from 'ng-mocks';
import { RemissionReturnReceiptListComponent } from './remission-return-receipt-list.component';
import {
RemissionReturnReceiptService,
Return,
Receipt,
} from '@isa/remission/data-access';
import { Return, Receipt } from '@isa/remission/data-access';
import { FilterService } from '@isa/shared/filter';
import { of } from 'rxjs';
// Mock the filter providers
vi.mock('@isa/shared/filter', async () => {
@@ -22,6 +16,12 @@ vi.mock('@isa/shared/filter', async () => {
};
});
// Mock the resources
vi.mock('./resources', () => ({
completedRemissionReturnsResource: vi.fn(),
incompletedRemissionReturnsResource: vi.fn(),
}));
// Mock child components
@Component({
selector: 'remi-return-receipt-list-item',
@@ -31,21 +31,25 @@ vi.mock('@isa/shared/filter', async () => {
class MockReturnReceiptListItemComponent {}
@Component({
selector: 'remi-order-by-toolbar',
template: '<div>Mock Order By Toolbar</div>',
selector: 'remi-remission-return-receipt-list-card',
template: '<div>Mock Return Receipt List Card</div>',
standalone: true,
})
class MockOrderByToolbarComponent {}
class MockRemissionReturnReceiptListCardComponent {}
@Component({
selector: 'isa-filter-controls-panel',
template: '<div>Mock Filter Controls Panel</div>',
standalone: true,
})
class MockFilterControlsPanelComponent {}
describe('RemissionReturnReceiptListComponent', () => {
let component: RemissionReturnReceiptListComponent;
let fixture: ComponentFixture<RemissionReturnReceiptListComponent>;
let mockRemissionReturnReceiptService: {
fetchRemissionReturnReceipts: ReturnType<typeof vi.fn>;
};
let mockFilterService: {
orderBy: ReturnType<typeof signal>;
};
let mockFilterService: any;
let mockCompletedResource: any;
let mockIncompletedResource: any;
const mockCompletedReturn: Return = {
id: 1,
@@ -81,54 +85,58 @@ describe('RemissionReturnReceiptListComponent', () => {
],
} as Return;
const mockReturnWithoutReceiptData: Return = {
id: 3,
completed: '2024-01-17T10:30:00.000Z',
receipts: [
{
id: 103,
data: undefined,
},
],
} as Return;
const mockReturns = [mockCompletedReturn, mockIncompletedReturn];
beforeEach(async () => {
// Arrange: Setup mocks
mockRemissionReturnReceiptService = {
fetchRemissionReturnReceipts: vi.fn().mockReturnValue(of(mockReturns)),
};
// Setup mocks
mockFilterService = {
orderBy: signal([]),
inputs: signal([]),
groups: signal([]),
queryParams: signal({}),
query: signal({ filter: {}, input: {}, orderBy: [] }),
isEmpty: signal(true),
isDefaultFilter: signal(true),
selectedFilterCount: signal(0),
};
mockCompletedResource = {
value: vi.fn().mockReturnValue([mockCompletedReturn]),
reload: vi.fn(),
isLoading: vi.fn().mockReturnValue(false),
};
mockIncompletedResource = {
value: vi.fn().mockReturnValue([mockIncompletedReturn]),
reload: vi.fn(),
isLoading: vi.fn().mockReturnValue(false),
};
// Mock the resource functions
const {
completedRemissionReturnsResource,
incompletedRemissionReturnsResource,
} = await import('./resources');
vi.mocked(completedRemissionReturnsResource).mockReturnValue(
mockCompletedResource,
);
vi.mocked(incompletedRemissionReturnsResource).mockReturnValue(
mockIncompletedResource,
);
await TestBed.configureTestingModule({
imports: [
RemissionReturnReceiptListComponent,
MockReturnReceiptListItemComponent,
MockOrderByToolbarComponent,
],
providers: [
MockProvider(
RemissionReturnReceiptService,
mockRemissionReturnReceiptService
),
{
provide: FilterService,
useValue: mockFilterService,
},
],
imports: [RemissionReturnReceiptListComponent],
providers: [{ provide: FilterService, useValue: mockFilterService }],
})
.overrideComponent(RemissionReturnReceiptListComponent, {
remove: {
imports: [],
imports: [
// Remove original components
],
},
add: {
imports: [
MockReturnReceiptListItemComponent,
MockOrderByToolbarComponent,
MockRemissionReturnReceiptListCardComponent,
MockFilterControlsPanelComponent,
],
},
})
@@ -138,102 +146,40 @@ describe('RemissionReturnReceiptListComponent', () => {
component = fixture.componentInstance;
});
describe('Component Initialization', () => {
it('should create the component', () => {
// Assert
describe('Component Setup', () => {
it('should create', () => {
expect(component).toBeTruthy();
});
it('should inject dependencies correctly', () => {
// Assert - Private fields cannot be directly tested
// Instead, we verify the component initializes correctly with dependencies
expect(component).toBeDefined();
it('should initialize resources', () => {
expect(component.completedRemissionReturnsResource).toBeDefined();
expect(component.incompletedRemissionReturnsResource).toBeDefined();
expect(component.orderDateBy).toBeDefined();
});
it('should render the component', () => {
// Act
fixture.detectChanges();
// Assert
expect(fixture.nativeElement).toBeTruthy();
expect(fixture.componentInstance).toBe(component);
});
});
describe('Resource Loading', () => {
it('should initialize completed and incomplete resources', () => {
// Assert
expect(component.completedRemissionReturnsResource).toBeDefined();
expect(component.incompletedRemissionReturnsResource).toBeDefined();
describe('orderDateBy computed signal', () => {
it('should return undefined when no order is selected', () => {
mockFilterService.orderBy.set([]);
const orderBy = component.orderDateBy();
expect(orderBy).toBeUndefined();
});
it('should fetch remission return receipts on component initialization', () => {
// Arrange
mockRemissionReturnReceiptService.fetchRemissionReturnReceipts.mockClear();
it('should return selected order option', () => {
const selectedOrder = { selected: true, by: 'created', dir: 'desc' };
mockFilterService.orderBy.set([selectedOrder]);
// Act
fixture.detectChanges();
const orderBy = component.orderDateBy();
// Assert
expect(
mockRemissionReturnReceiptService.fetchRemissionReturnReceipts
).toHaveBeenCalled();
});
it('should handle loading state', () => {
// Arrange
mockRemissionReturnReceiptService.fetchRemissionReturnReceipts.mockReturnValue(
new Promise(() => undefined) // Never resolving promise to simulate loading
);
// Act
const newFixture = TestBed.createComponent(
RemissionReturnReceiptListComponent
);
const newComponent = newFixture.componentInstance;
// Assert
expect(newComponent.completedRemissionReturnsResource.isLoading()).toBeDefined();
expect(newComponent.incompletedRemissionReturnsResource.isLoading()).toBeDefined();
});
it('should handle error state when service fails', async () => {
// Arrange
const errorMessage = 'Service failed';
mockRemissionReturnReceiptService.fetchRemissionReturnReceipts.mockReturnValue(
Promise.reject(new Error(errorMessage))
);
// Act
const errorFixture = TestBed.createComponent(
RemissionReturnReceiptListComponent
);
errorFixture.detectChanges();
await errorFixture.whenStable();
// Assert
const errorComponent = errorFixture.componentInstance;
expect(errorComponent.completedRemissionReturnsResource.error).toBeDefined();
expect(orderBy).toBe(selectedOrder);
});
});
describe('returns computed signal', () => {
it('should combine returns with incompleted first', () => {
// Arrange
vi.spyOn(component.completedRemissionReturnsResource, 'value').mockReturnValue([
mockCompletedReturn
]);
vi.spyOn(component.incompletedRemissionReturnsResource, 'value').mockReturnValue([
mockIncompletedReturn
]);
// Act
it('should combine completed and incompleted returns', () => {
const returns = component.returns();
// Assert
expect(returns).toHaveLength(2);
expect(returns[0][0]).toBe(mockIncompletedReturn);
expect(returns[0][1]).toBe(mockIncompletedReturn.receipts[0].data);
@@ -241,401 +187,22 @@ describe('RemissionReturnReceiptListComponent', () => {
expect(returns[1][1]).toBe(mockCompletedReturn.receipts[0].data);
});
it('should filter out receipts without data', () => {
// Arrange
const returnsWithNullData = [mockCompletedReturn, mockReturnWithoutReceiptData];
vi.spyOn(component.completedRemissionReturnsResource, 'value').mockReturnValue(
returnsWithNullData
);
vi.spyOn(component.incompletedRemissionReturnsResource, 'value').mockReturnValue([]);
it('should handle empty returns', () => {
mockCompletedResource.value.mockReturnValue([]);
mockIncompletedResource.value.mockReturnValue([]);
// Act
const returns = component.returns();
// Assert
expect(returns).toHaveLength(1);
expect(returns[0][0]).toBe(mockCompletedReturn);
expect(returns[0][1]).toBe(mockCompletedReturn.receipts[0].data);
});
it('should handle empty returns array', () => {
// Arrange
vi.spyOn(component.completedRemissionReturnsResource, 'value').mockReturnValue([]);
vi.spyOn(component.incompletedRemissionReturnsResource, 'value').mockReturnValue([]);
// Act
const returns = component.returns();
// Assert
expect(returns).toHaveLength(0);
expect(returns).toEqual([]);
});
it('should handle null value from resource', () => {
// Arrange
vi.spyOn(component.completedRemissionReturnsResource, 'value').mockReturnValue(
undefined as Return[] | undefined
);
vi.spyOn(component.incompletedRemissionReturnsResource, 'value').mockReturnValue([]);
// Act
const returns = component.returns();
// Assert
expect(returns).toHaveLength(0);
expect(returns).toEqual([]);
});
it('should handle undefined value from resource', () => {
// Arrange
vi.spyOn(component.completedRemissionReturnsResource, 'value').mockReturnValue(
undefined as Return[] | undefined
);
vi.spyOn(component.incompletedRemissionReturnsResource, 'value').mockReturnValue([]);
// Act
const returns = component.returns();
// Assert
expect(returns).toHaveLength(0);
expect(returns).toEqual([]);
});
it('should flatten multiple receipts per return', () => {
// Arrange
const returnWithMultipleReceipts: Return = {
id: 4,
completed: '2024-01-15T10:00:00.000Z',
receipts: [
{
id: 201,
data: {
id: 201,
receiptNumber: 'REC-2024-201',
created: '2024-01-15T09:00:00.000Z',
completed: '2024-01-15T10:00:00.000Z',
items: [],
} as Receipt,
},
{
id: 202,
data: {
id: 202,
receiptNumber: 'REC-2024-202',
created: '2024-01-15T10:00:00.000Z',
completed: '2024-01-15T11:00:00.000Z',
items: [],
} as Receipt,
},
],
} as Return;
vi.spyOn(component.completedRemissionReturnsResource, 'value').mockReturnValue([
returnWithMultipleReceipts,
]);
vi.spyOn(component.incompletedRemissionReturnsResource, 'value').mockReturnValue([]);
// Act
const returns = component.returns();
// Assert
expect(returns).toHaveLength(2);
expect(returns[0][0]).toBe(returnWithMultipleReceipts);
expect(returns[0][1]).toBe(returnWithMultipleReceipts.receipts[0].data);
expect(returns[1][0]).toBe(returnWithMultipleReceipts);
expect(returns[1][1]).toBe(returnWithMultipleReceipts.receipts[1].data);
});
});
describe('orderDateBy computed signal', () => {
it('should return undefined when no order is selected', () => {
// Arrange
mockFilterService.orderBy = signal([]);
describe('reloadList method', () => {
it('should reload both resources', () => {
component.reloadList();
// Act
const orderBy = component.orderDateBy();
// Assert
expect(orderBy).toBeUndefined();
});
it('should return selected order option', () => {
// Arrange
const selectedOrder = { selected: true, by: 'created', dir: 'desc' };
const notSelectedOrder = { selected: false, by: 'completed', dir: 'asc' };
// Update the existing mockFilterService signal
mockFilterService.orderBy.set([notSelectedOrder, selectedOrder]);
const newFixture = TestBed.createComponent(
RemissionReturnReceiptListComponent
);
const newComponent = newFixture.componentInstance;
// Act
const orderBy = newComponent.orderDateBy();
// Assert
expect(orderBy).toBe(selectedOrder);
});
});
describe('Sorting functionality', () => {
it('should sort returns by created date in descending order', () => {
// Arrange
const orderOption = { selected: true, by: 'created', dir: 'desc' };
mockFilterService.orderBy.set([orderOption]);
const olderReturn: Return = {
id: 10,
completed: '2024-01-15T10:00:00.000Z',
created: '2024-01-10T09:00:00.000Z',
receipts: [
{
id: 301,
data: {
id: 301,
receiptNumber: 'REC-2024-301',
created: '2024-01-10T09:00:00.000Z',
completed: '2024-01-10T10:00:00.000Z',
items: [],
} as Receipt,
},
],
} as Return;
const newerReturn: Return = {
id: 11,
completed: '2024-01-15T10:00:00.000Z',
created: '2024-01-20T09:00:00.000Z',
receipts: [
{
id: 302,
data: {
id: 302,
receiptNumber: 'REC-2024-302',
created: '2024-01-20T09:00:00.000Z',
completed: '2024-01-20T10:00:00.000Z',
items: [],
} as Receipt,
},
],
} as Return;
vi.spyOn(component.completedRemissionReturnsResource, 'value').mockReturnValue([
olderReturn,
newerReturn,
]);
vi.spyOn(component.incompletedRemissionReturnsResource, 'value').mockReturnValue([]);
// Act
const returns = component.returns();
// Assert
expect(returns).toHaveLength(2);
expect(returns[0][0]).toBe(newerReturn); // Newer date should come first in desc order
expect(returns[1][0]).toBe(olderReturn);
});
it('should sort returns by created date in ascending order', () => {
// Arrange
const orderOption = { selected: true, by: 'created', dir: 'asc' };
mockFilterService.orderBy.set([orderOption]);
const sortedFixture = TestBed.createComponent(
RemissionReturnReceiptListComponent
);
const sortedComponent = sortedFixture.componentInstance;
const olderReturn: Return = {
id: 10,
completed: '2024-01-15T10:00:00.000Z',
created: '2024-01-10T09:00:00.000Z',
receipts: [
{
id: 301,
data: {
id: 301,
receiptNumber: 'REC-2024-301',
created: '2024-01-10T09:00:00.000Z',
completed: '2024-01-10T10:00:00.000Z',
items: [],
} as Receipt,
},
],
} as Return;
const newerReturn: Return = {
id: 11,
completed: '2024-01-15T10:00:00.000Z',
created: '2024-01-20T09:00:00.000Z',
receipts: [
{
id: 302,
data: {
id: 302,
receiptNumber: 'REC-2024-302',
created: '2024-01-20T09:00:00.000Z',
completed: '2024-01-20T10:00:00.000Z',
items: [],
} as Receipt,
},
],
} as Return;
vi.spyOn(sortedComponent.completedRemissionReturnsResource, 'value').mockReturnValue([
newerReturn,
olderReturn,
]);
vi.spyOn(sortedComponent.incompletedRemissionReturnsResource, 'value').mockReturnValue([]);
// Act
const returns = sortedComponent.returns();
// Assert
expect(returns).toHaveLength(2);
expect(returns[0][0]).toBe(olderReturn); // Older date should come first in asc order
expect(returns[1][0]).toBe(newerReturn);
});
it('should handle sorting with undefined dates', () => {
// Arrange
const orderOption = { selected: true, by: 'created', dir: 'desc' };
mockFilterService.orderBy = signal([orderOption]);
const returnWithDate: Return = {
id: 10,
completed: '2024-01-15T10:00:00.000Z',
created: '2024-01-10T09:00:00.000Z',
receipts: [
{
id: 301,
data: {
id: 301,
receiptNumber: 'REC-2024-301',
created: '2024-01-10T09:00:00.000Z',
completed: '2024-01-10T10:00:00.000Z',
items: [],
} as Receipt,
},
],
} as Return;
const returnWithoutDate: Return = {
id: 11,
completed: '2024-01-15T10:00:00.000Z',
created: undefined,
receipts: [
{
id: 302,
data: {
id: 302,
receiptNumber: 'REC-2024-302',
created: '2024-01-20T09:00:00.000Z',
completed: '2024-01-20T10:00:00.000Z',
items: [],
} as Receipt,
},
],
} as Return;
vi.spyOn(component.completedRemissionReturnsResource, 'value').mockReturnValue([
returnWithDate,
returnWithoutDate,
]);
vi.spyOn(component.incompletedRemissionReturnsResource, 'value').mockReturnValue([]);
// Act
const returns = component.returns();
// Assert
expect(returns).toHaveLength(2);
expect(returns[0][0]).toBe(returnWithDate); // Item with date should come first
expect(returns[1][0]).toBe(returnWithoutDate); // Undefined date goes to end
});
});
describe('Component Destruction', () => {
it('should handle component destruction gracefully', () => {
// Act
fixture.destroy();
// Assert
expect(component).toBeDefined();
});
});
describe('Edge Cases', () => {
it('should handle returns with empty receipts array', () => {
// Arrange
const returnWithEmptyReceipts: Return = {
id: 100,
completed: '2024-01-15T10:00:00.000Z',
receipts: [],
} as Return;
vi.spyOn(component.completedRemissionReturnsResource, 'value').mockReturnValue([
returnWithEmptyReceipts,
]);
vi.spyOn(component.incompletedRemissionReturnsResource, 'value').mockReturnValue([]);
// Act
const returns = component.returns();
// Assert
expect(returns).toHaveLength(0);
});
it('should handle mixed returns with and without receipt data', () => {
// Arrange
vi.spyOn(component.completedRemissionReturnsResource, 'value').mockReturnValue([
mockCompletedReturn,
mockReturnWithoutReceiptData
]);
vi.spyOn(component.incompletedRemissionReturnsResource, 'value').mockReturnValue([
mockIncompletedReturn
]);
// Act
const returns = component.returns();
// Assert
expect(returns).toHaveLength(2); // Only returns with receipt data
expect(returns[0][0]).toBe(mockIncompletedReturn); // Incompleted first
expect(returns[1][0]).toBe(mockCompletedReturn);
});
it('should handle very large number of receipts per return', () => {
// Arrange
const returnWithManyReceipts: Return = {
id: 200,
completed: '2024-01-15T10:00:00.000Z',
receipts: Array.from({ length: 100 }, (_, i) => ({
id: 1000 + i,
data: {
id: 1000 + i,
receiptNumber: `REC-2024-${1000 + i}`,
created: '2024-01-15T09:00:00.000Z',
completed: '2024-01-15T10:00:00.000Z',
items: [],
} as Receipt,
})),
} as Return;
vi.spyOn(component.completedRemissionReturnsResource, 'value').mockReturnValue([
returnWithManyReceipts,
]);
vi.spyOn(component.incompletedRemissionReturnsResource, 'value').mockReturnValue([]);
// Act
const returns = component.returns();
// Assert
expect(returns).toHaveLength(100);
returns.forEach(([returnData, receipt]) => {
expect(returnData).toBe(returnWithManyReceipts);
expect(receipt).toBeDefined();
});
expect(mockCompletedResource.reload).toHaveBeenCalled();
expect(mockIncompletedResource.reload).toHaveBeenCalled();
});
});
});

View File

@@ -3,29 +3,28 @@ import {
Component,
computed,
inject,
resource,
} from '@angular/core';
import { ReturnReceiptListItemComponent } from './return-receipt-list-item/return-receipt-list-item.component';
import {
Receipt,
RemissionReturnReceiptService,
Return,
} from '@isa/remission/data-access';
import { Receipt, Return } from '@isa/remission/data-access';
import {
provideFilter,
withQueryParamsSync,
withQuerySettings,
OrderByToolbarComponent,
FilterService,
FilterControlsPanelComponent,
} from '@isa/shared/filter';
import { RouterLink } from '@angular/router';
import { compareAsc, compareDesc, subDays } from 'date-fns';
import { RETURN_RECEIPT_QUERY_SETTINGS } from './remission-return-receipt-list.query-settings';
import { RemissionReturnReceiptListCardComponent } from './return-receipt-list-card/return-receipt-list-card.component';
import {
completedRemissionReturnsResource,
incompletedRemissionReturnsResource,
} from './resources';
/**
* Component that displays a list of remission return receipts.
* Fetches both completed and incomplete receipts and combines them for display.
* Supports filtering and sorting through query parameters.
* Component for displaying a list of remission return receipts.
* It shows both completed and incomplete receipts, with options to filter and sort.
*
* @component
* @selector remi-remission-return-receipt-list
@@ -41,8 +40,9 @@ import { RETURN_RECEIPT_QUERY_SETTINGS } from './remission-return-receipt-list.q
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [
RemissionReturnReceiptListCardComponent,
FilterControlsPanelComponent,
ReturnReceiptListItemComponent,
OrderByToolbarComponent,
RouterLink,
],
providers: [
@@ -53,46 +53,41 @@ import { RETURN_RECEIPT_QUERY_SETTINGS } from './remission-return-receipt-list.q
],
})
export class RemissionReturnReceiptListComponent {
/** Private instance of the remission return receipt service */
#remissionReturnReceiptService = inject(RemissionReturnReceiptService);
/** Filter service for managing filter state and operations */
#filter = inject(FilterService);
/**
* Computed signal that retrieves the currently selected order date for sorting.
* This is used to determine how the return receipts should be ordered.
* @returns {string | undefined} The selected order date
*/
orderDateBy = computed(() => this.#filter.orderBy().find((o) => o.selected));
/**
* Resource that fetches completed remission return receipts.
* Automatically loads when the component is initialized.
*/
completedRemissionReturnsResource = resource({
loader: ({ abortSignal }) =>
this.#remissionReturnReceiptService.fetchRemissionReturnReceipts(
{ returncompleted: true, start: subDays(new Date(), 7) },
abortSignal,
),
/** Resource for fetching completed remission return receipts */
completedRemissionReturnsResource = completedRemissionReturnsResource();
/** Resource for fetching incomplete remission return receipts */
incompletedRemissionReturnsResource = incompletedRemissionReturnsResource();
/** Computed signal that retrieves the value of the completed remission returns resource */
completedRemissionReturnsResourceValue = computed(() => {
return this.completedRemissionReturnsResource.value() || [];
});
/** Computed signal that retrieves the value of the incomplete remission returns resource */
incompletedRemissionReturnsResourceValue = computed(() => {
return this.incompletedRemissionReturnsResource.value() || [];
});
/**
* Resource that fetches incomplete remission return receipts.
* Automatically loads when the component is initialized.
*/
incompletedRemissionReturnsResource = resource({
loader: ({ abortSignal }) =>
this.#remissionReturnReceiptService.fetchRemissionReturnReceipts(
{ returncompleted: false },
abortSignal,
),
});
/**
* Computed signal that combines completed and incomplete returns.
* Maps each return with its receipts into tuples for display.
* When date ordering is selected, sorts by completion date with incomplete items first.
* @returns {Array<[Return, Receipt]>} Array of tuples containing return and receipt pairs
* Computed signal that combines completed and incomplete remission returns,
* filtering out any receipts that do not have associated data.
* It also orders the returns based on the selected order date.
* @returns {Array<[Return, Receipt]>} Array of tuples containing Return and Receipt objects
*/
returns = computed(() => {
let completed = this.completedRemissionReturnsResource.value() || [];
let incompleted = this.incompletedRemissionReturnsResource.value() || [];
let completed = this.completedRemissionReturnsResourceValue();
let incompleted = this.incompletedRemissionReturnsResourceValue();
const orderBy = this.orderDateBy();
if (orderBy) {
@@ -113,8 +108,27 @@ export class RemissionReturnReceiptListComponent {
.map((rec) => [ret, rec.data] as [Return, Receipt]),
);
});
/**
* Reloads the completed and incomplete remission returns resources.
* This is typically called when the user performs an action that requires
* refreshing the list of returns, such as after adding or deleting a return.
*/
reloadList() {
this.completedRemissionReturnsResource.reload();
this.incompletedRemissionReturnsResource.reload();
}
}
/**
* Helper function to order an array of objects by a specific key.
* Uses a custom comparison function to sort the items.
*
* @param items - Array of items to be sorted
* @param by - Key to sort by
* @param compareFn - Comparison function for sorting
* @returns Sorted array of items
*/
function orderByKey<T, K extends keyof T>(
items: T[],
by: K,

View File

@@ -0,0 +1,56 @@
import { inject, resource } from '@angular/core';
import {
FetchRemissionReturnReceipts,
RemissionReturnReceiptService,
} from '@isa/remission/data-access';
import { subDays } from 'date-fns';
/**
* Resource for fetching completed remission return receipts.
* It retrieves receipts that are marked as completed within a specified date range.
* @param {Function} params - Function that returns parameters for fetching receipts
* @param {Object} params.returncompleted - Boolean indicating if the return is completed
* @param {Date} params.start - Start date for filtering receipts
* @return {Resource} Angular resource that manages the completed receipts data
*/
export const completedRemissionReturnsResource = (
params: () => FetchRemissionReturnReceipts = () => ({
returncompleted: true,
start: subDays(new Date(), 7),
}),
) => {
const remissionReturnReceiptService = inject(RemissionReturnReceiptService);
return resource({
params,
loader: ({ abortSignal, params }) => {
return remissionReturnReceiptService.fetchRemissionReturnReceipts(
params,
abortSignal,
);
},
});
};
/**
* Resource for fetching incomplete remission return receipts.
* It retrieves receipts that are not marked as completed.
* @param {Function} params - Function that returns parameters for fetching receipts
* @param {Object} params.returncompleted - Boolean indicating if the return is completed
* @return {Resource} Angular resource that manages the incomplete receipts data
*/
export const incompletedRemissionReturnsResource = (
params: () => FetchRemissionReturnReceipts = () => ({
returncompleted: false,
}),
) => {
const remissionReturnReceiptService = inject(RemissionReturnReceiptService);
return resource({
params,
loader: ({ abortSignal, params }) => {
return remissionReturnReceiptService.fetchRemissionReturnReceipts(
params,
abortSignal,
);
},
});
};

View File

@@ -0,0 +1 @@
export * from './fetch-return-receipt-list.recource';

View File

@@ -0,0 +1,9 @@
<div class="remi-return-receipt-list-card__title-container">
<h2 class="isa-text-subtitle-1-regular">Warenbegleitscheine</h2>
<p class="isa-text-body-1-regular">
Offene Warenbegleitscheine können nur gelöscht werden, wenn sie keine
Artikel enthalten. Entfernen Sie diese, bevor Sie den Warenbegleitschein
löschen.
</p>
</div>

View File

@@ -0,0 +1,7 @@
:host {
@apply w-full flex flex-row gap-6 rounded-2xl bg-isa-neutral-400 p-6 justify-between;
}
.remi-return-receipt-list-card__title-container {
@apply flex flex-col gap-4 text-isa-neutral-900;
}

View File

@@ -0,0 +1,9 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
@Component({
selector: 'remi-return-receipt-list-card',
templateUrl: './return-receipt-list-card.component.html',
styleUrl: './return-receipt-list-card.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class RemissionReturnReceiptListCardComponent {}

View File

@@ -1,14 +1,30 @@
<div class="flex flex-col">
<div>Warenbegleitschein</div>
<div class="isa-text-body-1-bold">#{{ receiptNumber() }}</div>
</div>
<div
class="flex flex-col gap-6 p-6 rounded-2xl text-isa-neutral-900 isa-text-body-1-regular"
[class.bg-isa-white]="status() === ReceiptCompleteStatus.Offen"
[class.bg-isa-neutral-400]="status() === ReceiptCompleteStatus.Abgeschlossen"
>
<div class="flex flex-row justify-start gap-6">
<div class="flex flex-col">
<div>Warenbegleitschein</div>
<div class="isa-text-body-1-bold">#{{ receiptNumber() }}</div>
</div>
<div class="flex flex-col">
<div>Anzahl Positionen</div>
<div class="isa-text-body-1-bold">{{ itemQuantity() }}</div>
</div>
<div class="flex-grow"></div>
<div class="flex flex-col">
<div>Status</div>
<div class="isa-text-body-1-bold">{{ status() }}</div>
<div class="flex flex-col">
<div>Anzahl Positionen</div>
<div class="isa-text-body-1-bold">{{ itemQuantity() }}</div>
</div>
<div class="flex-grow"></div>
<div class="flex flex-col w-32">
<div>Status</div>
<div class="isa-text-body-1-bold">{{ status() }}</div>
</div>
</div>
@if (status() === ReceiptCompleteStatus.Offen) {
<lib-remission-return-receipt-actions
[remissionReturn]="remissionReturn()"
(reloadData)="reloadList.emit()"
>
</lib-remission-return-receipt-actions>
}
</div>

View File

@@ -1,11 +0,0 @@
:host {
@apply flex flex-row justify-start gap-6 p-6 rounded-2xl text-isa-neutral-900 isa-text-body-1-regular;
&.remi-return-receipt-list-item--offen {
@apply bg-isa-white;
}
&.remi-return-receipt-list-item--abgeschlossen {
@apply bg-isa-neutral-400;
}
}

View File

@@ -1,7 +1,27 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { describe, it, expect, beforeEach } from 'vitest';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { HttpClientTestingModule } from '@angular/common/http/testing';
import { ReturnReceiptListItemComponent } from './return-receipt-list-item.component';
import { Return } from '@isa/remission/data-access';
import {
Return,
ReceiptCompleteStatus,
getReceiptNumberFromReturn,
getReceiptItemQuantityFromReturn,
getReceiptStatusFromReturn,
} from '@isa/remission/data-access';
import { MockComponent } from 'ng-mocks';
import { RemissionReturnReceiptActionsComponent } from '@isa/remission/shared/return-receipt-actions';
// Mock the helper functions
vi.mock('@isa/remission/data-access', async () => {
const actual = await vi.importActual('@isa/remission/data-access');
return {
...actual,
getReceiptNumberFromReturn: vi.fn(),
getReceiptItemQuantityFromReturn: vi.fn(),
getReceiptStatusFromReturn: vi.fn(),
};
});
describe('ReturnReceiptListItemComponent', () => {
let component: ReturnReceiptListItemComponent;
@@ -10,13 +30,23 @@ describe('ReturnReceiptListItemComponent', () => {
const createMockReturn = (overrides: Partial<Return> = {}): Return =>
({
id: 1,
receipts: [],
receipts: [
{ id: 101, items: [] },
{ id: 102, items: [] },
],
...overrides,
}) as Return;
beforeEach(async () => {
// Reset all mocks before each test
vi.clearAllMocks();
await TestBed.configureTestingModule({
imports: [ReturnReceiptListItemComponent],
imports: [
HttpClientTestingModule,
ReturnReceiptListItemComponent,
MockComponent(RemissionReturnReceiptActionsComponent),
],
}).compileComponents();
fixture = TestBed.createComponent(ReturnReceiptListItemComponent);
@@ -25,595 +55,311 @@ describe('ReturnReceiptListItemComponent', () => {
describe('Component Setup', () => {
it('should create', () => {
fixture.componentRef.setInput('remissionReturn', createMockReturn());
const mockReturn = createMockReturn();
fixture.componentRef.setInput('remissionReturn', mockReturn);
expect(component).toBeTruthy();
});
it('should have remissionReturn as required input', () => {
fixture.componentRef.setInput('remissionReturn', createMockReturn());
const mockReturn = createMockReturn();
fixture.componentRef.setInput('remissionReturn', mockReturn);
fixture.detectChanges();
expect(component.remissionReturn()).toBeDefined();
expect(component.remissionReturn()).toEqual(mockReturn);
});
it('should have correct host class applied', () => {
const mockReturn = createMockReturn();
fixture.componentRef.setInput('remissionReturn', mockReturn);
fixture.detectChanges();
const hostElement = fixture.debugElement.nativeElement;
expect(
hostElement.classList.contains('remi-return-receipt-list-item'),
).toBe(true);
});
});
describe('receiptNumber computed signal', () => {
it('should return "Keine Belege vorhanden" when no receipts', () => {
const mockReturn = createMockReturn({ receipts: [] });
fixture.componentRef.setInput('remissionReturn', mockReturn);
fixture.detectChanges();
describe('Computed signals', () => {
describe('receiptNumber', () => {
it('should return receipt number from helper function', () => {
const mockReturn = createMockReturn();
const expectedReceiptNumber = '24-001';
expect(component.receiptNumber()).toBe('Keine Belege vorhanden');
vi.mocked(getReceiptNumberFromReturn).mockReturnValue(
expectedReceiptNumber,
);
fixture.componentRef.setInput('remissionReturn', mockReturn);
fixture.detectChanges();
expect(component.receiptNumber()).toBe(expectedReceiptNumber);
expect(getReceiptNumberFromReturn).toHaveBeenCalledWith(mockReturn);
});
});
it('should return single receipt number substring', () => {
const mockReturn = createMockReturn({
receipts: [
{
id: 1,
data: {
id: 1,
receiptNumber: 'REC-2024-001-ABC',
items: [],
},
},
],
});
fixture.componentRef.setInput('remissionReturn', mockReturn);
fixture.detectChanges();
describe('itemQuantity', () => {
it('should return item quantity from helper function', () => {
const mockReturn = createMockReturn();
const expectedQuantity = 5;
expect(component.receiptNumber()).toBe('24-001');
vi.mocked(getReceiptItemQuantityFromReturn).mockReturnValue(
expectedQuantity,
);
fixture.componentRef.setInput('remissionReturn', mockReturn);
fixture.detectChanges();
expect(component.itemQuantity()).toBe(expectedQuantity);
expect(getReceiptItemQuantityFromReturn).toHaveBeenCalledWith(
mockReturn,
);
});
});
it('should return multiple receipt numbers joined with comma', () => {
const mockReturn = createMockReturn({
receipts: [
{
id: 1,
data: {
id: 1,
receiptNumber: 'REC-2024-001-ABC',
items: [],
},
},
{
id: 2,
data: {
id: 2,
receiptNumber: 'REC-2024-002-DEF',
items: [],
},
},
{
id: 3,
data: {
id: 3,
receiptNumber: 'REC-2024-003-GHI',
items: [],
},
},
],
describe('status', () => {
it('should return status from helper function using linkedSignal', () => {
const mockReturn = createMockReturn();
const expectedStatus = ReceiptCompleteStatus.Abgeschlossen;
vi.mocked(getReceiptStatusFromReturn).mockReturnValue(expectedStatus);
fixture.componentRef.setInput('remissionReturn', mockReturn);
fixture.detectChanges();
expect(component.status()).toBe(expectedStatus);
expect(getReceiptStatusFromReturn).toHaveBeenCalledWith(mockReturn);
});
fixture.componentRef.setInput('remissionReturn', mockReturn);
fixture.detectChanges();
expect(component.receiptNumber()).toBe('24-001, 24-002, 24-003');
});
it('should update when input changes', () => {
const mockReturn1 = createMockReturn({ id: 1 });
const mockReturn2 = createMockReturn({ id: 2 });
it('should handle receipts with null data', () => {
const mockReturn = createMockReturn({
receipts: [
{
id: 1,
data: null as any,
},
{
id: 2,
data: {
id: 2,
receiptNumber: 'REC-2024-002-DEF',
items: [],
},
},
],
// Setup first status
vi.mocked(getReceiptStatusFromReturn).mockReturnValue(
ReceiptCompleteStatus.Offen,
);
fixture.componentRef.setInput('remissionReturn', mockReturn1);
fixture.detectChanges();
expect(component.status()).toBe(ReceiptCompleteStatus.Offen);
// Change status for second return
vi.mocked(getReceiptStatusFromReturn).mockReturnValue(
ReceiptCompleteStatus.Abgeschlossen,
);
fixture.componentRef.setInput('remissionReturn', mockReturn2);
fixture.detectChanges();
expect(component.status()).toBe(ReceiptCompleteStatus.Abgeschlossen);
});
fixture.componentRef.setInput('remissionReturn', mockReturn);
fixture.detectChanges();
expect(component.receiptNumber()).toBe('24-002');
});
it('should handle receipts with undefined receiptNumber', () => {
const mockReturn = createMockReturn({
receipts: [
{
id: 1,
data: {
id: 1,
receiptNumber: undefined as any,
items: [],
},
},
{
id: 2,
data: {
id: 2,
receiptNumber: 'REC-2024-002-DEF',
items: [],
},
},
],
});
fixture.componentRef.setInput('remissionReturn', mockReturn);
fixture.detectChanges();
expect(component.receiptNumber()).toBe('24-002');
});
it('should handle short receipt numbers', () => {
const mockReturn = createMockReturn({
receipts: [
{
id: 1,
data: {
id: 1,
receiptNumber: 'SHORT',
items: [],
},
},
],
});
fixture.componentRef.setInput('remissionReturn', mockReturn);
fixture.detectChanges();
expect(component.receiptNumber()).toBe('');
});
});
describe('itemQuantity computed signal', () => {
it('should return 0 when no receipts', () => {
const mockReturn = createMockReturn({ receipts: [] });
fixture.componentRef.setInput('remissionReturn', mockReturn);
fixture.detectChanges();
expect(component.itemQuantity()).toBe(0);
describe('Template rendering', () => {
beforeEach(() => {
// Setup default mock values for template rendering tests
vi.mocked(getReceiptNumberFromReturn).mockReturnValue('24-001');
vi.mocked(getReceiptItemQuantityFromReturn).mockReturnValue(3);
vi.mocked(getReceiptStatusFromReturn).mockReturnValue(
ReceiptCompleteStatus.Offen,
);
});
it('should return sum of all items across receipts', () => {
const mockReturn = createMockReturn({
receipts: [
{
id: 1,
data: {
id: 1,
receiptNumber: 'REC-2024-001-ABC',
items: new Array(5),
},
},
{
id: 2,
data: {
id: 2,
receiptNumber: 'REC-2024-002-DEF',
items: new Array(3),
},
},
{
id: 3,
data: {
id: 3,
receiptNumber: 'REC-2024-003-GHI',
items: new Array(7),
},
},
],
});
it('should display receipt information correctly', () => {
const mockReturn = createMockReturn();
fixture.componentRef.setInput('remissionReturn', mockReturn);
fixture.detectChanges();
expect(component.itemQuantity()).toBe(15);
const compiled = fixture.nativeElement;
// Check receipt number display
expect(compiled.textContent).toContain('#24-001');
// Check item quantity display
expect(compiled.textContent).toContain('3');
// Check status display
expect(compiled.textContent).toContain('Offen');
});
it('should handle receipts with null data', () => {
const mockReturn = createMockReturn({
receipts: [
{
id: 1,
data: null as any,
},
{
id: 2,
data: {
id: 2,
items: new Array(3),
},
},
],
});
it('should apply correct background class for "Offen" status', () => {
const mockReturn = createMockReturn();
vi.mocked(getReceiptStatusFromReturn).mockReturnValue(
ReceiptCompleteStatus.Offen,
);
fixture.componentRef.setInput('remissionReturn', mockReturn);
fixture.detectChanges();
expect(component.itemQuantity()).toBe(3);
const containerDiv = fixture.nativeElement.querySelector(
'.flex.flex-col.gap-6',
);
expect(containerDiv.classList.contains('bg-isa-white')).toBe(true);
expect(containerDiv.classList.contains('bg-isa-neutral-400')).toBe(false);
});
it('should handle receipts with undefined items', () => {
const mockReturn = createMockReturn({
receipts: [
{
id: 1,
data: {
id: 1,
receiptNumber: 'REC-2024-001-ABC',
items: undefined as any,
},
},
{
id: 2,
data: {
id: 2,
receiptNumber: 'REC-2024-002-DEF',
items: new Array(5),
},
},
],
});
it('should apply correct background class for "Abgeschlossen" status', () => {
const mockReturn = createMockReturn();
vi.mocked(getReceiptStatusFromReturn).mockReturnValue(
ReceiptCompleteStatus.Abgeschlossen,
);
fixture.componentRef.setInput('remissionReturn', mockReturn);
fixture.detectChanges();
expect(component.itemQuantity()).toBe(5);
const containerDiv = fixture.nativeElement.querySelector(
'.flex.flex-col.gap-6',
);
expect(containerDiv.classList.contains('bg-isa-neutral-400')).toBe(true);
expect(containerDiv.classList.contains('bg-isa-white')).toBe(false);
});
it('should handle receipts with empty items array', () => {
const mockReturn = createMockReturn({
receipts: [
{
id: 1,
data: {
id: 1,
receiptNumber: 'REC-2024-001-ABC',
items: [],
},
},
{
id: 2,
data: {
id: 2,
receiptNumber: 'REC-2024-002-DEF',
items: new Array(2),
},
},
],
});
it('should show action component for "Offen" status', () => {
const mockReturn = createMockReturn();
vi.mocked(getReceiptStatusFromReturn).mockReturnValue(
ReceiptCompleteStatus.Offen,
);
fixture.componentRef.setInput('remissionReturn', mockReturn);
fixture.detectChanges();
expect(component.itemQuantity()).toBe(2);
const actionsComponent = fixture.nativeElement.querySelector(
'lib-remission-return-receipt-actions',
);
expect(actionsComponent).toBeTruthy();
});
it('should hide action component for "Abgeschlossen" status', () => {
const mockReturn = createMockReturn();
vi.mocked(getReceiptStatusFromReturn).mockReturnValue(
ReceiptCompleteStatus.Abgeschlossen,
);
fixture.componentRef.setInput('remissionReturn', mockReturn);
fixture.detectChanges();
const actionsComponent = fixture.nativeElement.querySelector(
'lib-remission-return-receipt-actions',
);
expect(actionsComponent).toBeFalsy();
});
it('should pass correct inputs to action component', () => {
const mockReturn = createMockReturn();
vi.mocked(getReceiptStatusFromReturn).mockReturnValue(
ReceiptCompleteStatus.Offen,
);
fixture.componentRef.setInput('remissionReturn', mockReturn);
fixture.detectChanges();
const actionsComponent = fixture.nativeElement.querySelector(
'lib-remission-return-receipt-actions',
);
expect(actionsComponent).toBeTruthy();
// Since we're using MockComponent, we can verify the component is present
// and that it should receive the remissionReturn input based on the template
// The actual input binding testing would require a more complex setup
expect(actionsComponent.tagName.toLowerCase()).toBe(
'lib-remission-return-receipt-actions',
);
});
});
describe('completed computed signal', () => {
it('should return "Offen" when no receipts are completed', () => {
const mockReturn = createMockReturn({
receipts: [
{
id: 1,
data: {
id: 1,
completed: false,
},
},
{
id: 2,
data: {
id: 2,
completed: false,
},
},
],
});
describe('Output events', () => {
it('should emit reloadList when action component emits reloadData', () => {
const mockReturn = createMockReturn();
vi.mocked(getReceiptStatusFromReturn).mockReturnValue(
ReceiptCompleteStatus.Offen,
);
fixture.componentRef.setInput('remissionReturn', mockReturn);
fixture.detectChanges();
expect(component.completed()).toBe('Offen');
});
const reloadListSpy = vi.fn();
component.reloadList.subscribe(reloadListSpy);
it('should return "Abgeschlossen" when at least one receipt is completed', () => {
const mockReturn = createMockReturn({
receipts: [
{
id: 1,
data: {
id: 1,
completed: false,
},
},
{
id: 2,
data: {
id: 2,
completed: true,
},
},
{
id: 3,
data: {
id: 3,
completed: false,
},
},
],
});
fixture.componentRef.setInput('remissionReturn', mockReturn);
// Simulate the action component emitting reloadData
const actionsComponent = fixture.nativeElement.querySelector(
'lib-remission-return-receipt-actions',
);
// Trigger the reloadData event from the actions component
actionsComponent.dispatchEvent(new CustomEvent('reloadData'));
fixture.detectChanges();
expect(component.completed()).toBe('Abgeschlossen');
});
it('should return "Abgeschlossen" when all receipts are completed', () => {
const mockReturn = createMockReturn({
receipts: [
{
id: 1,
data: {
id: 1,
completed: true,
},
},
{
id: 2,
data: {
id: 2,
completed: true,
},
},
],
});
fixture.componentRef.setInput('remissionReturn', mockReturn);
fixture.detectChanges();
expect(component.completed()).toBe('Abgeschlossen');
});
it('should return "Offen" when no receipts exist', () => {
const mockReturn = createMockReturn({ receipts: [] });
fixture.componentRef.setInput('remissionReturn', mockReturn);
fixture.detectChanges();
expect(component.completed()).toBe('Offen');
});
it('should handle receipts with null data', () => {
const mockReturn = createMockReturn({
receipts: [
{
id: 1,
data: null as any,
},
{
id: 2,
data: {
id: 2,
completed: false,
},
},
],
});
fixture.componentRef.setInput('remissionReturn', mockReturn);
fixture.detectChanges();
expect(component.completed()).toBe('Offen');
});
it('should handle receipts with undefined completed status', () => {
const mockReturn = createMockReturn({
receipts: [
{
id: 1,
data: {
id: 1,
receiptNumber: 'REC-2024-001-ABC',
items: [],
completed: undefined as any,
},
},
{
id: 2,
data: {
id: 2,
receiptNumber: 'REC-2024-002-DEF',
items: [],
completed: true,
},
},
],
});
fixture.componentRef.setInput('remissionReturn', mockReturn);
fixture.detectChanges();
expect(component.completed()).toBe('Abgeschlossen');
expect(reloadListSpy).toHaveBeenCalled();
});
});
describe('Component reactivity', () => {
it('should update computed signals when input changes', () => {
const initialReturn = createMockReturn({
receipts: [
{
id: 1,
data: {
id: 1,
receiptNumber: 'REC-2024-001-ABC',
items: new Array(3),
completed: false,
},
},
],
});
const initialReturn = createMockReturn({ id: 1 });
const updatedReturn = createMockReturn({ id: 2 });
// Mock return values for initial state
vi.mocked(getReceiptNumberFromReturn).mockReturnValue('24-001');
vi.mocked(getReceiptItemQuantityFromReturn).mockReturnValue(3);
vi.mocked(getReceiptStatusFromReturn).mockReturnValue(
ReceiptCompleteStatus.Offen,
);
fixture.componentRef.setInput('remissionReturn', initialReturn);
fixture.detectChanges();
expect(component.receiptNumber()).toBe('24-001');
expect(component.itemQuantity()).toBe(3);
expect(component.completed()).toBe('Offen');
expect(component.status()).toBe(ReceiptCompleteStatus.Offen);
// Mock return values for updated state
vi.mocked(getReceiptNumberFromReturn).mockReturnValue('24-002, 24-003');
vi.mocked(getReceiptItemQuantityFromReturn).mockReturnValue(7);
vi.mocked(getReceiptStatusFromReturn).mockReturnValue(
ReceiptCompleteStatus.Abgeschlossen,
);
const updatedReturn = createMockReturn({
receipts: [
{
id: 1,
data: {
id: 1,
receiptNumber: 'REC-2024-002-DEF',
items: new Array(5),
completed: true,
},
},
{
id: 2,
data: {
id: 2,
receiptNumber: 'REC-2024-003-GHI',
items: new Array(2),
completed: false,
},
},
],
});
fixture.componentRef.setInput('remissionReturn', updatedReturn);
fixture.detectChanges();
expect(component.receiptNumber()).toBe('24-002, 24-003');
expect(component.itemQuantity()).toBe(7);
expect(component.completed()).toBe('Abgeschlossen');
expect(component.status()).toBe(ReceiptCompleteStatus.Abgeschlossen);
});
it('should trigger helper functions with correct parameters when input changes', () => {
const mockReturn1 = createMockReturn({ id: 1 });
const mockReturn2 = createMockReturn({ id: 2 });
// First input
fixture.componentRef.setInput('remissionReturn', mockReturn1);
fixture.detectChanges();
expect(getReceiptNumberFromReturn).toHaveBeenCalledWith(mockReturn1);
expect(getReceiptItemQuantityFromReturn).toHaveBeenCalledWith(
mockReturn1,
);
expect(getReceiptStatusFromReturn).toHaveBeenCalledWith(mockReturn1);
// Clear mock calls
vi.clearAllMocks();
// Second input
fixture.componentRef.setInput('remissionReturn', mockReturn2);
fixture.detectChanges();
expect(getReceiptNumberFromReturn).toHaveBeenCalledWith(mockReturn2);
expect(getReceiptItemQuantityFromReturn).toHaveBeenCalledWith(
mockReturn2,
);
expect(getReceiptStatusFromReturn).toHaveBeenCalledWith(mockReturn2);
});
});
describe('status alias', () => {
it('should have status as an alias for completed', () => {
const mockReturn = createMockReturn({
receipts: [
{
id: 1,
data: {
id: 1,
completed: false,
},
},
{
id: 2,
data: {
id: 2,
completed: true,
},
},
],
});
fixture.componentRef.setInput('remissionReturn', mockReturn);
fixture.detectChanges();
expect(component.status()).toBe(component.completed());
expect(component.status()).toBe('Abgeschlossen');
});
it('should update status when completed changes', () => {
const initialReturn = createMockReturn({
receipts: [
{
id: 1,
data: {
id: 1,
completed: false,
},
},
],
});
fixture.componentRef.setInput('remissionReturn', initialReturn);
fixture.detectChanges();
expect(component.status()).toBe('Offen');
const updatedReturn = createMockReturn({
receipts: [
{
id: 1,
data: {
id: 1,
completed: true,
},
},
],
});
fixture.componentRef.setInput('remissionReturn', updatedReturn);
fixture.detectChanges();
expect(component.status()).toBe('Abgeschlossen');
});
});
describe('Edge cases', () => {
it('should handle return with deeply nested null values', () => {
const mockReturn = createMockReturn({
receipts: [
{
id: 1,
data: null as any,
},
{
id: 2,
data: {
id: 2,
receiptNumber: null as any,
items: null as any,
completed: null as any,
},
},
],
});
fixture.componentRef.setInput('remissionReturn', mockReturn);
fixture.detectChanges();
expect(component.receiptNumber()).toBe('');
expect(component.itemQuantity()).toBe(0);
expect(component.completed()).toBe('Offen');
});
it('should handle very long receipt numbers', () => {
const mockReturn = createMockReturn({
receipts: [
{
id: 1,
data: {
id: 1,
receiptNumber:
'PREFIX-VERY-LONG-RECEIPT-NUMBER-THAT-EXCEEDS-NORMAL-LENGTH',
items: [],
},
},
],
});
fixture.componentRef.setInput('remissionReturn', mockReturn);
fixture.detectChanges();
expect(component.receiptNumber()).toBe('-VERY-');
});
it('should handle large number of receipts', () => {
const receipts = Array.from({ length: 100 }, (_, i) => ({
id: i + 1,
data: {
id: i + 1,
receiptNumber: `REC-2024-${String(i + 1).padStart(3, '0')}-ABC`,
items: new Array(2),
completed: i % 2 === 0 ? '2024-01-15T10:30:00.000Z' : undefined,
},
}));
const mockReturn = createMockReturn({ receipts });
fixture.componentRef.setInput('remissionReturn', mockReturn);
fixture.detectChanges();
expect(component.itemQuantity()).toBe(200);
expect(component.completed()).toBe('Abgeschlossen');
expect(component.receiptNumber()).toContain('24-001');
expect(component.receiptNumber()).toContain('24-100');
describe('Constants', () => {
it('should expose ReceiptCompleteStatus constant', () => {
expect(component.ReceiptCompleteStatus).toBe(ReceiptCompleteStatus);
});
});
});

View File

@@ -3,22 +3,29 @@ import {
Component,
computed,
input,
linkedSignal,
output,
} from '@angular/core';
import { Return } from '@isa/remission/data-access';
import {
Return,
ReceiptCompleteStatusValue,
ReceiptCompleteStatus,
getReceiptStatusFromReturn,
getReceiptItemQuantityFromReturn,
getReceiptNumberFromReturn,
} from '@isa/remission/data-access';
import { RemissionReturnReceiptActionsComponent } from '@isa/remission/shared/return-receipt-actions';
/**
* Component that displays a single return receipt item in the list view.
* Shows receipt number, item quantity, and status information.
* Component that displays a list item for a remission return receipt.
* Shows the receipt number, item quantity, and status of the return.
*
* @component
* @selector remi-return-receipt-list-item
* @standalone
*
* @example
* <remi-return-receipt-list-item
* [remissionReturn]="returnData"
* [returnReceipt]="receiptData">
* </remi-return-receipt-list-item>
* <remi-return-receipt-list-item [remissionReturn]="returnData"></remi-return-receipt-list-item>
*/
@Component({
selector: 'remi-return-receipt-list-item',
@@ -26,81 +33,54 @@ import { Return } from '@isa/remission/data-access';
styleUrls: ['./return-receipt-list-item.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [],
imports: [RemissionReturnReceiptActionsComponent],
host: {
'class': 'remi-return-receipt-list-item',
'[class.remi-return-receipt-list-item--offen]': 'completed() === "Offen"',
'[class.remi-return-receipt-list-item--abgeschlossen]':
'completed() === "Abgeschlossen"',
class: 'remi-return-receipt-list-item',
},
})
export class ReturnReceiptListItemComponent {
/** Constant for receipt completion status */
ReceiptCompleteStatus = ReceiptCompleteStatus;
/**
* Required input for the return data.
* Input for the remission return data.
* @input
* @required
*/
remissionReturn = input.required<Return>();
/**
* Computed signal that extracts and formats receipt numbers from all receipts.
* Returns "Keine Belege vorhanden" if no receipts, otherwise returns formatted receipt numbers.
* @returns {string} The formatted receipt numbers or message
* Output event that emits when the list needs to be reloaded.
* @output
*/
reloadList = output<void>();
/**
* Computed signal that retrieves the receipt number from the return data.
* Uses the helper function to get the receipt number.
* @returns {string} The receipt number from the return
*/
receiptNumber = computed(() => {
const returnData = this.remissionReturn();
if (!returnData.receipts || returnData.receipts.length === 0) {
return 'Keine Belege vorhanden';
}
const receiptNumbers = returnData.receipts
.map((receipt) => receipt.data?.receiptNumber)
.filter((receiptNumber) => receiptNumber && receiptNumber.length >= 12)
.map((receiptNumber) => receiptNumber!.substring(6, 12));
return receiptNumbers.length > 0 ? receiptNumbers.join(', ') : '';
return getReceiptNumberFromReturn(returnData);
});
/**
* Computed signal that calculates the total quantity of all items across all receipts.
* @returns {number} Total quantity of items
* Computed signal that calculates the total item quantity from all receipts in the return.
* Uses the helper function to get the quantity.
* @returns {number} The total item quantity from all receipts
*/
itemQuantity = computed(() => {
const returnData = this.remissionReturn();
if (!returnData.receipts || returnData.receipts.length === 0) {
return 0;
}
return returnData.receipts.reduce((totalItems, receipt) => {
const items = receipt.data?.items;
return totalItems + (items ? items.length : 0);
}, 0);
return getReceiptItemQuantityFromReturn(returnData);
});
/**
* Computed signal that determines the completion status.
* Returns "Abgeschlossen" if any receipt is completed, "Offen" otherwise.
* @returns {'Abgeschlossen' | 'Offen'} Status text based on completion state
* Linked signal that determines the completion status of the return.
* Uses the helper function to get the status based on the return data.
* @returns {ReceiptCompleteStatusValue} The completion status of the return
*/
completed = computed(() => {
status = linkedSignal<ReceiptCompleteStatusValue>(() => {
const returnData = this.remissionReturn();
if (!returnData.receipts || returnData.receipts.length === 0) {
return 'Offen';
}
const hasCompletedReceipt = returnData.receipts.some(
(receipt) => receipt.data?.completed,
);
return hasCompletedReceipt ? 'Abgeschlossen' : 'Offen';
return getReceiptStatusFromReturn(returnData);
});
/**
* Alias for completed for backward compatibility with tests.
* @deprecated Use completed() instead
*/
status = this.completed;
}

View File

@@ -1,7 +1,8 @@
@let product = item().product;
@let price = item().retailPrice;
@let tag = item().tag;
@let horizontal = orientation() === 'horizontal';
<div>
<div class="flex flex-col gap-2" data-which="product-image-and-remi-label">
<img
class="w-full h-auto object-contain"
sharedProductRouterLink
@@ -10,6 +11,17 @@
[alt]="product.name"
data-what="product-image"
/>
@if (tag) {
<ui-label
data-what="remission-label"
[type]="Labeltype.Tag"
[priority]="
tag === RemissionItemTags.Prio2 ? LabelPriority.Low : LabelPriority.High
"
>{{ tag }}</ui-label
>
}
</div>
<div
@@ -27,7 +39,7 @@
{{ product.name }}
</div>
<div class="isa-text-body-2-bold" data-what="product-price">
{{ price.value.value | currency: price.value.currencySymbol }}
{{ price?.value?.value | currency: price?.value?.currencySymbol }}
</div>
</div>
<div class="flex flex-col w-full gap-2 items-start justify-end">

View File

@@ -7,6 +7,7 @@ import { MockComponents, MockDirectives } from 'ng-mocks';
import { ProductFormatComponent } from '@isa/shared/product-foramt';
import { ProductImageDirective } from '@isa/shared/product-image';
import { ProductRouterLinkDirective } from '@isa/shared/product-router-link';
import { LabelComponent } from '@isa/ui/label';
import { By } from '@angular/platform-browser';
describe('ProductInfoComponent', () => {
@@ -28,6 +29,7 @@ describe('ProductInfoComponent', () => {
currencySymbol: '€',
},
},
tag: 'Prio 1',
} as ProductInfoItem;
beforeEach(async () => {
@@ -40,11 +42,12 @@ describe('ProductInfoComponent', () => {
ProductFormatComponent,
ProductImageDirective,
ProductRouterLinkDirective,
LabelComponent,
],
},
add: {
imports: [
MockComponents(ProductFormatComponent),
MockComponents(ProductFormatComponent, LabelComponent),
MockDirectives(ProductImageDirective, ProductRouterLinkDirective),
],
},
@@ -154,6 +157,29 @@ describe('ProductInfoComponent', () => {
'product-format',
);
});
it('should display remission label when tag is present', () => {
const labelElement = fixture.debugElement.query(
By.css('[data-what="remission-label"]'),
);
expect(labelElement).toBeTruthy();
expect(labelElement.nativeElement.textContent.trim()).toBe('Prio 1');
});
it('should not display remission label when tag is not present', () => {
const itemWithoutTag: ProductInfoItem = {
...mockProductItem,
tag: undefined,
};
fixture.componentRef.setInput('item', itemWithoutTag);
fixture.detectChanges();
const labelElement = fixture.debugElement.query(
By.css('[data-what="remission-label"]'),
);
expect(labelElement).toBeFalsy();
});
});
describe('Orientation Behavior', () => {
@@ -249,6 +275,56 @@ describe('ProductInfoComponent', () => {
});
});
describe('RemissionItemTags Behavior', () => {
it('should display Prio1 tag with primary appearance', () => {
const itemWithPrio1: ProductInfoItem = {
...mockProductItem,
tag: 'Prio 1',
};
fixture.componentRef.setInput('item', itemWithPrio1);
fixture.detectChanges();
const labelElement = fixture.debugElement.query(
By.css('[data-what="remission-label"]'),
);
expect(labelElement).toBeTruthy();
expect(labelElement.nativeElement.textContent.trim()).toBe('Prio 1');
});
it('should display Prio2 tag with secondary appearance', () => {
const itemWithPrio2: ProductInfoItem = {
...mockProductItem,
tag: 'Prio 2',
};
fixture.componentRef.setInput('item', itemWithPrio2);
fixture.detectChanges();
const labelElement = fixture.debugElement.query(
By.css('[data-what="remission-label"]'),
);
expect(labelElement).toBeTruthy();
expect(labelElement.nativeElement.textContent.trim()).toBe('Prio 2');
});
it('should display Pflicht tag with primary appearance', () => {
const itemWithPflicht: ProductInfoItem = {
...mockProductItem,
tag: 'Pflicht',
};
fixture.componentRef.setInput('item', itemWithPflicht);
fixture.detectChanges();
const labelElement = fixture.debugElement.query(
By.css('[data-what="remission-label"]'),
);
expect(labelElement).toBeTruthy();
expect(labelElement.nativeElement.textContent.trim()).toBe('Pflicht');
});
});
describe('Component Structure', () => {
beforeEach(() => {
fixture.componentRef.setInput('item', mockProductItem);
@@ -272,6 +348,7 @@ describe('ProductInfoComponent', () => {
const expectedDataWhatValues = [
'product-image',
'remission-label',
'product-contributors',
'product-name',
'product-price',

View File

@@ -1,14 +1,24 @@
import { CurrencyPipe } from '@angular/common';
import { Component, input } from '@angular/core';
import { ReturnItem } from '@isa/remission/data-access';
import { RemissionItem, ReturnItem } from '@isa/remission/data-access';
import { ProductImageDirective } from '@isa/shared/product-image';
import { ProductRouterLinkDirective } from '@isa/shared/product-router-link';
import { ProductFormatComponent } from '@isa/shared/product-foramt';
import { LabelComponent, LabelPriority, Labeltype } from '@isa/ui/label';
export type ProductInfoItem = Pick<ReturnItem, 'product' | 'retailPrice'>;
export type ProductInfoItem = Pick<
RemissionItem,
'product' | 'retailPrice' | 'tag'
>;
export type ProductInfoOrientation = 'horizontal' | 'vertical';
export const RemissionItemTags = {
Prio1: 'Prio 1',
Prio2: 'Prio 2',
Pflicht: 'Pflicht',
} as const;
@Component({
selector: 'remi-product-info',
templateUrl: 'product-info.component.html',
@@ -17,6 +27,7 @@ export type ProductInfoOrientation = 'horizontal' | 'vertical';
ProductRouterLinkDirective,
CurrencyPipe,
ProductFormatComponent,
LabelComponent,
],
host: {
'[class]': 'classList',
@@ -26,6 +37,9 @@ export type ProductInfoOrientation = 'horizontal' | 'vertical';
},
})
export class ProductInfoComponent {
Labeltype = Labeltype;
LabelPriority = LabelPriority;
RemissionItemTags = RemissionItemTags;
readonly classList: ReadonlyArray<string> = [
'grid',
'grid-cols-[3.5rem,1fr]',

View File

@@ -11,6 +11,7 @@
Aktueller Bestand
</div>
<div
*uiSkeletonLoader="stockFetching()"
class="isa-text-body-2-bold"
data-what="stock-value"
data-which="current-stock"
@@ -31,6 +32,7 @@
Remi Menge
</div>
<div
*uiSkeletonLoader="stockFetching()"
class="isa-text-body-2-bold"
data-what="stock-value"
data-which="remit-amount"
@@ -51,6 +53,7 @@
Übriger Bestand
</div>
<div
*uiSkeletonLoader="stockFetching()"
class="isa-text-body-2-bold"
data-what="stock-value"
data-which="remaining-stock"
@@ -63,6 +66,7 @@
ZOB
</div>
<div
*uiSkeletonLoader="stockFetching()"
class="isa-text-body-2-regular grid-flow-row"
data-what="stock-value"
data-which="zob"

View File

@@ -1,4 +1,5 @@
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
import { SkeletonLoaderDirective } from '@isa/ui/skeleton-loader';
/**
* Displays and computes stock information for a product.
@@ -17,8 +18,17 @@ import { ChangeDetectionStrategy, Component, input } from '@angular/core';
templateUrl: './product-stock-info.component.html',
styleUrls: ['./product-stock-info.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [SkeletonLoaderDirective],
})
export class ProductStockInfoComponent {
/**
* InputSignal indicating whether the stock information is currently being fetched.
* Used to show loading states in the UI.
* @default false
*
*/
stockFetching = input<boolean>(false);
/**
* Current available stock after removals.
*/

View File

@@ -1 +1 @@
export * from './lib/remission-start-dialog/remission-start-dialog.component';
export * from './lib/remission-start-dialog/remission-start.service';

View File

@@ -40,6 +40,10 @@
@if (control?.errors?.['pattern']) {
<span>Die Wannennummmer muss 14-stellig sein</span>
}
@if (control.errors?.['invalidProperties']) {
<span>{{ control.errors.invalidProperties }}</span>
}
</ui-text-field-errors>
}
</ui-text-field-container>
@@ -71,8 +75,8 @@
color="primary"
data-what="button"
data-which="save"
[disabled]="control.invalid || creatingReturnReceipt()"
[pending]="creatingReturnReceipt()"
[disabled]="control.invalid || assignPackageLoading().loading"
[pending]="assignPackageLoading().loading"
type="button"
>
Speichern

View File

@@ -1,8 +1,10 @@
import {
ChangeDetectionStrategy,
Component,
effect,
input,
output,
untracked,
} from '@angular/core';
import { FormControl, ReactiveFormsModule, Validators } from '@angular/forms';
@@ -15,8 +17,43 @@ import {
TextFieldErrorsComponent,
} from '@isa/ui/input-controls';
import { ScannerButtonComponent } from '@isa/shared/scanner';
import { boolean } from 'zod';
import { RequestStatus } from './remission-start-dialog.component';
/**
* Component for assigning a package number in the remission process.
*
* This component provides the second step of the remission workflow, allowing users to:
* - Manually input a 14-digit package number
* - Scan a package number using the integrated scanner
* - Validate package number format (14-digit requirement)
* - Handle server-side validation errors
*
* The component uses reactive forms for input validation and handles server-side
* validation errors through the RequestStatus input. It emits the package number
* when the form is submitted successfully, or undefined if validation fails.
*
* @example
* ```html
* <remi-assign-package-number
* [assignPackageLoading]="requestStatus"
* (assignPackageNumber)="onAssignPackageNumber($event)"
* />
* ```
*
* @example
* ```typescript
* // Handling the output event
* onAssignPackageNumber(packageNumber: string | undefined): void {
* if (packageNumber) {
* // Package number provided and validated
* console.log('Package number:', packageNumber);
* } else {
* // Validation failed or user cancelled
* console.log('Package assignment cancelled or invalid');
* }
* }
* ```
*/
@Component({
selector: 'remi-assign-package-number',
templateUrl: './assign-package-number.component.html',
@@ -34,18 +71,119 @@ import { boolean } from 'zod';
],
})
export class AssignPackageNumberComponent {
creatingReturnReceipt = input<boolean>(false);
/**
* Input signal containing the current request status for the assign package operation.
* Used to display loading states and handle server-side validation errors.
* When invalidProperties contains 'PackageNumber', the form control is updated with the error.
*/
assignPackageLoading = input<RequestStatus>({ loading: false });
/**
* Output signal emitted when user completes the package assignment step.
* Emits the validated package number string if successful, or undefined if validation fails
* or the user cancels the operation.
*/
assignPackageNumber = output<string | undefined>();
/**
* Form control for the package number input field.
*
* Validation rules:
* - Required: Package number must be provided
* - Pattern: Must be exactly 14 digits (/^\d{14}$/)
*
* The control also handles server-side validation errors received through
* the assignPackageLoading input signal.
*/
control = new FormControl<string | undefined>(undefined, {
validators: [Validators.required, Validators.pattern(/^\d{14}$/)],
});
onScan(value: string) {
/**
* Constructor sets up reactive error handling for server-side validation.
*
* Uses an effect to monitor the assignPackageLoading signal and automatically
* update the form control's error state when server validation fails for the
* PackageNumber field. The untracked wrapper prevents infinite loops while
* allowing the effect to respond to signal changes.
*/
constructor() {
effect(() => {
const status = this.assignPackageLoading();
untracked(() => {
if (status?.invalidProperties?.['PackageNumber']) {
this.control.setErrors({
invalidProperties: status?.invalidProperties?.['PackageNumber'],
});
} else {
this.control.setErrors(null);
}
});
});
}
/**
* Handles scanned package number input from the scanner component.
*
* When a valid value is scanned, it automatically populates the form control.
* This provides a convenient way for users to input package numbers without
* manual typing, reducing input errors and improving workflow efficiency.
*
* @param value - The scanned package number string
*
* @example
* ```html
* <isa-scanner-button (scanned)="onScan($event)" />
* ```
*/
onScan(value: string): void {
this.control.setValue(value);
}
onSave(value: string | undefined) {
/**
* Handles form submission and validation for package number assignment.
*
* This method performs comprehensive validation before emitting the result:
* 1. Checks if a request is currently in progress (prevents double submission)
* 2. Triggers form validation to ensure package number meets requirements
* 3. Validates that a value was actually provided and is not null/undefined
* 4. Emits the appropriate result based on validation outcome
*
* If validation fails or no value is provided, emits undefined to indicate
* the operation should be cancelled or retried. If validation passes,
* emits the validated package number for processing.
*
* @param value - The package number value to validate and process
*
* @example
* ```html
* <form (ngSubmit)="onSave(control.value)">
* <input [formControl]="control" />
* <button type="submit" [disabled]="assignPackageLoading().loading">
* Assign Package
* </button>
* </form>
* ```
*
* @example
* ```typescript
* // In parent component
* onAssignPackage(packageNumber: string | undefined): void {
* if (packageNumber) {
* // Process the validated package number
* this.remissionService.assignPackage(packageNumber);
* } else {
* // Handle validation failure or cancellation
* this.showError('Please provide a valid 14-digit package number');
* }
* }
* ```
*/
onSave(value: string | undefined): void {
if (this.assignPackageLoading().loading) {
return;
}
this.control.updateValueAndValidity();
if (

View File

@@ -57,6 +57,10 @@
@if (control.errors?.['pattern']) {
<span>Die Packstück ID muss 18-stellig sein</span>
}
@if (control.errors?.['invalidProperties']) {
<span>{{ control.errors.invalidProperties }}</span>
}
</ui-text-field-errors>
}
</ui-text-field-container>
@@ -87,7 +91,8 @@
color="primary"
data-what="button"
data-which="save"
[disabled]="control.invalid"
[disabled]="control.invalid || createRemissionLoading().loading"
[pending]="createRemissionLoading().loading"
(click)="
onSave({ type: ReturnReceiptResultType.Input, value: control.value })
"

View File

@@ -1,4 +1,11 @@
import { ChangeDetectionStrategy, Component, output } from '@angular/core';
import {
ChangeDetectionStrategy,
Component,
effect,
input,
output,
untracked,
} from '@angular/core';
import { FormControl, ReactiveFormsModule, Validators } from '@angular/forms';
import { ButtonComponent, TextButtonComponent } from '@isa/ui/buttons';
@@ -11,10 +18,46 @@ import {
} from '@isa/ui/input-controls';
import { ScannerButtonComponent } from '@isa/shared/scanner';
import {
RequestStatus,
ReturnReceiptResult,
ReturnReceiptResultType,
} from './remission-start-dialog.component';
/**
* Component for creating a return receipt in the remission process.
*
* This component provides the first step of the remission workflow, allowing users to:
* - Generate a receipt number automatically
* - Manually input a custom receipt number
* - Scan a receipt number using the integrated scanner
* - Validate receipt number format (18-digit requirement)
*
* The component uses reactive forms for input validation and handles server-side
* validation errors through the RequestStatus input. It emits different result types
* based on user interaction (generate, input with value, or close).
*
* @example
* ```html
* <remi-create-return-receipt
* [createRemissionLoading]="requestStatus"
* (createReturnReceipt)="onCreateReturnReceipt($event)"
* />
* ```
*
* @example
* ```typescript
* // Handling the output event
* onCreateReturnReceipt(result: ReturnReceiptResult): void {
* if (result?.type === ReturnReceiptResultType.Generate) {
* // User chose to auto-generate
* } else if (result?.type === ReturnReceiptResultType.Input) {
* // User provided custom value: result.value
* } else {
* // User closed/cancelled
* }
* }
* ```
*/
@Component({
selector: 'remi-create-return-receipt',
templateUrl: './create-return-receipt.component.html',
@@ -33,27 +76,131 @@ import {
],
})
export class CreateReturnReceiptComponent {
/**
* Expose ReturnReceiptResultType enum to template for type checking and comparisons.
*/
ReturnReceiptResultType = ReturnReceiptResultType;
/**
* Input signal containing the current request status for the create remission operation.
* Used to display loading states and handle server-side validation errors.
* When invalidProperties contains 'receiptNumber', the form control is updated with the error.
*/
createRemissionLoading = input<RequestStatus>({ loading: false });
/**
* Output signal emitted when user completes the return receipt creation step.
* Emits different result types based on user action:
* - Generate: User chose automatic generation
* - Input: User provided a custom receipt number
* - Close: User cancelled or provided invalid input
*/
createReturnReceipt = output<ReturnReceiptResult>();
/**
* Form control for the receipt number input field.
*
* Validation rules:
* - Required: Receipt number must be provided when using manual input
* - Pattern: Must be exactly 18 digits (/^\d{18}$/)
*
* The control also handles server-side validation errors received through
* the createRemissionLoading input signal.
*/
control = new FormControl<string | undefined>(undefined, {
validators: [Validators.required, Validators.pattern(/^\d{18}$/)],
});
onScan(value: string | null) {
/**
* Constructor sets up reactive error handling for server-side validation.
*
* Uses an effect to monitor the createRemissionLoading signal and automatically
* update the form control's error state when server validation fails for the
* receiptNumber field. The untracked wrapper prevents infinite loops while
* allowing the effect to respond to signal changes.
*/
constructor() {
effect(() => {
const status = this.createRemissionLoading();
untracked(() => {
if (status?.invalidProperties?.['receiptNumber']) {
this.control.setErrors({
invalidProperties: status?.invalidProperties?.['receiptNumber'],
});
} else {
this.control.setErrors(null);
}
});
});
}
/**
* Handles scanned receipt number input from the scanner component.
*
* When a valid value is scanned, it automatically populates the form control.
* This provides a convenient way for users to input receipt numbers without
* manual typing, reducing input errors.
*
* @param value - The scanned receipt number, or null if scan failed/was cancelled
*
* @example
* ```html
* <isa-scanner-button (scanned)="onScan($event)" />
* ```
*/
onScan(value: string | null): void {
if (!value) {
return;
}
this.control.setValue(value);
}
onGenerate() {
/**
* Handles the generate receipt number action.
*
* Emits a Generate result type, indicating the user wants the system
* to automatically generate a receipt number rather than providing one manually.
* This is typically the preferred option for most users as it eliminates
* input errors and ensures uniqueness.
*
* @example
* ```html
* <button (click)="onGenerate()">Generate Receipt Number</button>
* ```
*/
onGenerate(): void {
return this.createReturnReceipt.emit({
type: ReturnReceiptResultType.Generate,
});
}
onSave(value: ReturnReceiptResult) {
/**
* Handles form submission and validation for manual receipt number input.
*
* This method performs comprehensive validation before emitting the result:
* 1. Checks if a request is currently in progress (prevents double submission)
* 2. Triggers form validation to ensure receipt number meets requirements
* 3. Validates that a value was actually provided
* 4. Emits the appropriate result based on validation outcome
*
* If validation fails or no value is provided, emits a Close result.
* If validation passes, emits the provided value for processing.
*
* @param value - The return receipt result to process (typically contains user input)
*
* @example
* ```html
* <form (ngSubmit)="onSave({ type: ReturnReceiptResultType.Input, value: control.value })">
* <input [formControl]="control" />
* <button type="submit">Save Receipt Number</button>
* </form>
* ```
*/
onSave(value: ReturnReceiptResult): void {
if (this.createRemissionLoading().loading) {
return;
}
this.control.updateValueAndValidity();
if (

View File

@@ -1,10 +1,11 @@
@if (!createReturnReceipt()) {
@if (!assignPackageStepData()) {
<remi-create-return-receipt
(createReturnReceipt)="onCreateReturnReceipt($event)"
[createRemissionLoading]="createRemissionRequestStatus()"
></remi-create-return-receipt>
} @else {
<remi-assign-package-number
(assignPackageNumber)="onAssignPackageNumber($event)"
[creatingReturnReceipt]="loadRequests()"
[assignPackageLoading]="assignPackageRequestStatus()"
></remi-assign-package-number>
}

View File

@@ -1,20 +1,30 @@
import './test-mocks'; // Import mocks before anything else
import './test-mocks';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { RemissionStartDialogComponent } from './remission-start-dialog.component';
import {
RemissionStartDialogComponent,
ReturnReceiptResultType,
} from './remission-start-dialog.component';
import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting } from '@angular/common/http/testing';
import { RemissionReturnReceiptService } from '@isa/remission/data-access';
import { DialogRef, DIALOG_DATA } from '@angular/cdk/dialog';
import { DialogComponent } from '@isa/ui/dialog';
import { vi } from 'vitest';
import { describe, it, expect, beforeEach, vi } from 'vitest';
describe('RemissionStartDialogComponent', () => {
let component: RemissionStartDialogComponent;
let fixture: ComponentFixture<RemissionStartDialogComponent>;
let mockRemissionService: {
createRemission: ReturnType<typeof vi.fn>;
assignPackage: ReturnType<typeof vi.fn>;
};
let mockDialogRef: {
close: ReturnType<typeof vi.fn>;
};
beforeEach(async () => {
// Mock the dialog ref
const mockDialogRef = {
mockDialogRef = {
close: vi.fn(),
};
@@ -28,37 +38,196 @@ describe('RemissionStartDialogComponent', () => {
};
// Mock remission service
const mockRemissionService = {
createReturn: vi.fn().mockResolvedValue({ id: 1 }),
createReceipt: vi.fn().mockResolvedValue({
id: 1,
receiptNumber: '12345',
items: []
mockRemissionService = {
createRemission: vi.fn().mockResolvedValue({
returnId: 123,
receiptId: 456,
}),
assignPackage: vi.fn().mockResolvedValue({}),
};
await TestBed.configureTestingModule({
imports: [
RemissionStartDialogComponent,
],
imports: [RemissionStartDialogComponent],
providers: [
provideHttpClient(),
provideHttpClientTesting(),
{ provide: RemissionReturnReceiptService, useValue: mockRemissionService },
{
provide: RemissionReturnReceiptService,
useValue: mockRemissionService,
},
{ provide: DialogRef, useValue: mockDialogRef },
{ provide: DIALOG_DATA, useValue: mockDialogData },
{ provide: DialogComponent, useValue: mockDialogComponent },
],
})
.compileComponents();
}).compileComponents();
fixture = TestBed.createComponent(RemissionStartDialogComponent);
component = fixture.componentInstance;
// Manually set the dialog data since it's not being injected properly
(component as any).data = mockDialogData;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
describe('onCreateReturnReceipt', () => {
it('should handle generate receipt type successfully', async () => {
// Arrange
const returnReceipt = { type: ReturnReceiptResultType.Generate } as const;
// Act
await component.onCreateReturnReceipt(returnReceipt);
// Assert
expect(mockRemissionService.createRemission).toHaveBeenCalledWith({
returnGroup: 'test-group',
receiptNumber: undefined,
});
expect(component.assignPackageStepData()).toEqual({
returnId: 123,
receiptId: 456,
});
expect(component.createRemissionRequestStatus().loading).toBe(false);
});
it('should handle input receipt type with custom receipt number', async () => {
// Arrange
const returnReceipt = {
type: ReturnReceiptResultType.Input,
value: 'CUSTOM-123',
};
// Act
await component.onCreateReturnReceipt(returnReceipt);
// Assert
expect(mockRemissionService.createRemission).toHaveBeenCalledWith({
returnGroup: 'test-group',
receiptNumber: 'CUSTOM-123',
});
expect(component.assignPackageStepData()).toEqual({
returnId: 123,
receiptId: 456,
});
expect(component.createRemissionRequestStatus().loading).toBe(false);
});
it('should handle input receipt type with null value', async () => {
// Arrange
const returnReceipt = {
type: ReturnReceiptResultType.Input,
value: null,
};
// Act
await component.onCreateReturnReceipt(returnReceipt);
// Assert
expect(mockRemissionService.createRemission).toHaveBeenCalledWith({
returnGroup: 'test-group',
receiptNumber: undefined,
});
expect(component.assignPackageStepData()).toEqual({
returnId: 123,
receiptId: 456,
});
});
it('should close dialog when receipt type is close', async () => {
// Arrange
const closeSpy = vi.spyOn(component, 'onDialogClose');
const returnReceipt = { type: ReturnReceiptResultType.Close } as const;
// Act
await component.onCreateReturnReceipt(returnReceipt);
// Assert
expect(closeSpy).toHaveBeenCalledWith(undefined);
expect(mockRemissionService.createRemission).not.toHaveBeenCalled();
});
});
describe('onAssignPackageNumber', () => {
it('should assign package number successfully', async () => {
// Arrange
const packageNumber = 'PKG-789';
component.assignPackageStepData.set({
returnId: 123,
receiptId: 456,
});
const closeSpy = vi.spyOn(component, 'onDialogClose');
// Act
await component.onAssignPackageNumber(packageNumber);
// Assert
expect(mockRemissionService.assignPackage).toHaveBeenCalledWith({
packageNumber: 'PKG-789',
returnId: 123,
receiptId: 456,
});
expect(closeSpy).toHaveBeenCalledWith({
returnId: 123,
receiptId: 456,
});
});
it('should close dialog when no package number provided', async () => {
// Arrange
const closeSpy = vi.spyOn(component, 'onDialogClose');
component.assignPackageStepData.set({
returnId: 123,
receiptId: 456,
});
// Act
await component.onAssignPackageNumber(undefined);
// Assert
expect(closeSpy).toHaveBeenCalledWith(undefined);
expect(mockRemissionService.assignPackage).not.toHaveBeenCalled();
});
it('should close dialog when no step data available', async () => {
// Arrange
const closeSpy = vi.spyOn(component, 'onDialogClose');
component.assignPackageStepData.set(undefined);
// Act
await component.onAssignPackageNumber('PKG-789');
// Assert
expect(closeSpy).toHaveBeenCalledWith(undefined);
expect(mockRemissionService.assignPackage).not.toHaveBeenCalled();
});
});
describe('onDialogClose', () => {
it('should close dialog with result', () => {
// Arrange
const result = { returnId: 123, receiptId: 456 };
const closeSpy = vi.spyOn(component, 'close');
// Act
component.onDialogClose(result);
// Assert
expect(closeSpy).toHaveBeenCalledWith(result);
});
it('should close dialog with undefined result', () => {
// Arrange
const closeSpy = vi.spyOn(component, 'close');
// Act
component.onDialogClose(undefined);
// Assert
expect(closeSpy).toHaveBeenCalledWith(undefined);
});
});
});

View File

@@ -10,32 +10,93 @@ import { provideIcons } from '@ng-icons/core';
import { isaActionScanner } from '@isa/icons';
import { CreateReturnReceiptComponent } from './create-return-receipt.component';
import { AssignPackageNumberComponent } from './assign-package-number.component';
import { RemissionReturnReceiptService } from '@isa/remission/data-access';
import {
CreateRemission,
RemissionReturnReceiptService,
} from '@isa/remission/data-access';
/**
* Enumeration of possible return receipt result types.
* Used to determine the action taken by the user in the return receipt creation step.
*/
export enum ReturnReceiptResultType {
/** User closed the dialog without action */
Close = 'close',
/** User chose to generate a receipt number automatically */
Generate = 'generate',
/** User chose to input a custom receipt number */
Input = 'input',
}
/**
* Union type representing the possible results from the return receipt creation step.
* Each variant corresponds to a different user action in the dialog.
*/
export type ReturnReceiptResult =
| { type: ReturnReceiptResultType.Close }
| { type: ReturnReceiptResultType.Generate }
| {
type: ReturnReceiptResultType.Input;
/** The custom receipt number entered by the user */
value: string | undefined | null;
}
| undefined;
/**
* Request status object used to track the state of asynchronous operations.
* Contains loading state and optional validation error information.
*/
export type RequestStatus = {
/** Whether the request is currently in progress */
loading: boolean;
/** Map of property names to error messages for validation failures */
invalidProperties?: Record<string, string>;
};
/**
* Input data required to initialize the remission start dialog.
*/
export type RemissionStartDialogData = {
/** The return group identifier for the remission process */
returnGroup: string | undefined;
};
/**
* Result data returned when the remission start dialog completes successfully.
*/
export type RemissionStartDialogResult = {
/** The unique identifier of the created return */
returnId: number;
/** The unique identifier of the created receipt */
receiptId: number;
};
/**
* Dialog component for initiating the remission process.
*
* This component manages a two-step workflow:
* 1. Create a return receipt (either generate automatically or accept manual input)
* 2. Assign a package number to the created return
*
* The component extends DialogContentDirective to provide dialog functionality
* and uses Angular signals for reactive state management.
*
* @example
* ```typescript
* // Opening the dialog
* const dialogRef = this.dialog.open(RemissionStartDialogComponent, {
* data: { returnGroup: 'RG123' }
* });
*
* // Handling the result
* dialogRef.afterClosed().subscribe(result => {
* if (result) {
* console.log('Return created:', result.returnId);
* console.log('Receipt created:', result.receiptId);
* }
* });
* ```
*/
@Component({
selector: 'remi-remission-start-dialog',
templateUrl: './remission-start-dialog.component.html',
@@ -48,65 +109,169 @@ export class RemissionStartDialogComponent extends DialogContentDirective<
RemissionStartDialogData,
RemissionStartDialogResult | undefined
> {
/** Service for handling remission and return receipt operations */
#remissionReturnReceiptService = inject(RemissionReturnReceiptService);
createReturnReceipt = signal<ReturnReceiptResult>(undefined);
loadRequests = signal<boolean>(false);
onCreateReturnReceipt(returnReceipt: ReturnReceiptResult) {
if (returnReceipt && returnReceipt.type !== ReturnReceiptResultType.Close) {
this.createReturnReceipt.set(returnReceipt);
} else {
this.onDialogClose(undefined);
}
}
/**
* Signal tracking the request status for the create remission operation.
* Used to show loading states and validation errors in the first step.
*/
createRemissionRequestStatus = signal<RequestStatus>({ loading: false });
/**
* Signal tracking the request status for the assign package operation.
* Used to show loading states and validation errors in the second step.
*/
assignPackageRequestStatus = signal<RequestStatus>({ loading: false });
/**
* Signal containing the data needed for the package assignment step.
* Set after successful return receipt creation and used in the second step.
*/
assignPackageStepData = signal<
Omit<CreateRemission, 'invalidProperties'> | undefined
>(undefined);
/**
* Handles the completion of the return receipt creation step.
*
* This method processes the user's choice regarding receipt creation:
* - If the user closes or cancels, the dialog is closed with no result
* - If the user chooses to generate or input a receipt, a remission is created
* - On successful creation, the workflow advances to the package assignment step
*
* @param returnReceipt - The result from the return receipt creation step
* @returns Promise that resolves when the operation completes
*
* @example
* ```typescript
* // Called from template when user completes first step
* onCreateReturnReceipt({ type: ReturnReceiptResultType.Generate });
* onCreateReturnReceipt({
* type: ReturnReceiptResultType.Input,
* value: 'CUSTOM-123'
* });
* ```
*/
async onCreateReturnReceipt(
returnReceipt: ReturnReceiptResult,
): Promise<void> {
this.createRemissionRequestStatus.set({ loading: true });
onAssignPackageNumber(packageNumber: string | undefined) {
const returnReceipt = this.createReturnReceipt();
if (
packageNumber &&
returnReceipt &&
returnReceipt.type !== ReturnReceiptResultType.Close
!returnReceipt ||
returnReceipt.type === ReturnReceiptResultType.Close
) {
let receiptNumber: string | undefined = undefined; // undefined -> Wird generiert;
if (
returnReceipt.type === ReturnReceiptResultType.Input &&
returnReceipt.value
) {
receiptNumber = returnReceipt.value;
}
this.startRemission({ receiptNumber, packageNumber });
} else {
this.onDialogClose(undefined);
} else {
try {
let receiptNumber: string | undefined = undefined; // undefined -> Wird generiert;
if (
returnReceipt.type === ReturnReceiptResultType.Input &&
returnReceipt.value
) {
receiptNumber = returnReceipt.value;
}
const response =
await this.#remissionReturnReceiptService.createRemission({
returnGroup: this.data.returnGroup,
receiptNumber,
});
if (!response) {
return this.onDialogClose(undefined);
}
this.assignPackageStepData.set({
returnId: response.returnId,
receiptId: response.receiptId,
});
this.createRemissionRequestStatus.set({ loading: false });
} catch (error: any) {
console.error('Error creating remission:', error);
if (error?.error?.invalidProperties) {
this.createRemissionRequestStatus.set({
loading: false,
invalidProperties: error?.error?.invalidProperties,
});
}
}
}
}
async startRemission({
receiptNumber,
packageNumber,
}: {
receiptNumber: string | undefined;
packageNumber: string;
}) {
this.loadRequests.set(true);
const response = await this.#remissionReturnReceiptService.startRemission({
returnGroup: this.data.returnGroup,
receiptNumber,
packageNumber,
});
/**
* Handles the completion of the package assignment step.
*
* This method assigns a package number to the previously created return
* and completes the remission start workflow. On successful assignment,
* the dialog closes with the return and receipt IDs.
*
* @param packageNumber - The package number to assign to the return
* @returns Promise that resolves when the operation completes
*
* @example
* ```typescript
* // Called from template when user completes second step
* onAssignPackageNumber('PKG-789');
* ```
*/
async onAssignPackageNumber(
packageNumber: string | undefined,
): Promise<void> {
this.assignPackageRequestStatus.set({ loading: true });
const data = this.assignPackageStepData();
if (!response) {
if (!data || !packageNumber) {
return this.onDialogClose(undefined);
}
this.onDialogClose({
returnId: response.returnId,
receiptId: response.receiptId,
});
try {
const response = await this.#remissionReturnReceiptService.assignPackage({
packageNumber,
returnId: data.returnId,
receiptId: data.receiptId,
});
if (!response) {
return this.onDialogClose(undefined);
}
this.onDialogClose({
returnId: data.returnId,
receiptId: data.receiptId,
});
this.assignPackageRequestStatus.set({ loading: false });
} catch (error: any) {
console.error('Error assigning package:', error);
if (error?.error?.invalidProperties) {
this.assignPackageRequestStatus.set({
loading: false,
invalidProperties: error?.error?.invalidProperties,
});
}
}
}
onDialogClose(result: RemissionStartDialogResult | undefined) {
/**
* Closes the dialog with the specified result.
*
* This method wraps the inherited close method from DialogContentDirective
* to provide a consistent interface for closing the dialog from various
* points in the workflow.
*
* @param result - The result to return to the dialog opener, or undefined if cancelled
*
* @example
* ```typescript
* // Close with successful result
* this.onDialogClose({ returnId: 123, receiptId: 456 });
*
* // Close without result (cancelled)
* this.onDialogClose(undefined);
* ```
*/
onDialogClose(result: RemissionStartDialogResult | undefined): void {
this.close(result);
this.loadRequests.set(false);
}
}

View File

@@ -0,0 +1,60 @@
import './test-mocks';
import { vi } from 'vitest';
import { describe, it, expect, beforeEach } from 'vitest';
import { TestBed } from '@angular/core/testing';
import { of } from 'rxjs';
import { RemissionStartService } from './remission-start.service';
import { RemissionStore } from '@isa/remission/data-access';
import { injectDialog } from '@isa/ui/dialog';
describe('RemissionStartService', () => {
let service: RemissionStartService;
let mockRemissionStore: any;
let mockDialog: any;
let mockDialogRef: any;
beforeEach(() => {
// Create mock objects
mockRemissionStore = {
startRemission: vi.fn(),
};
mockDialogRef = {
closed: of({ returnId: 'test-return-id', receiptId: 'test-receipt-id' }),
};
mockDialog = vi.fn().mockReturnValue(mockDialogRef);
// Mock the injectDialog function to return our mock dialog
vi.mocked(injectDialog).mockReturnValue(mockDialog);
TestBed.configureTestingModule({
providers: [
RemissionStartService,
{ provide: RemissionStore, useValue: mockRemissionStore },
],
});
service = TestBed.inject(RemissionStartService);
});
it('should start remission successfully when dialog returns result', async () => {
// Arrange
const returnGroup = 'test-return-group';
// Act
await service.startRemission(returnGroup);
// Assert
expect(mockDialog).toHaveBeenCalledWith({
data: { returnGroup },
classList: ['gap-0'],
width: '30rem',
});
expect(mockRemissionStore.startRemission).toHaveBeenCalledWith({
returnId: 'test-return-id',
receiptId: 'test-receipt-id',
});
});
});

Some files were not shown because too many files have changed in this diff Show More