Compare commits

...

115 Commits

Author SHA1 Message Date
Nino Righi
4c56f394c5 Merged PR 1972: hotfix(remission-list-item, remission-list-empty-state): improve empty state...
hotfix(remission-list-item, remission-list-empty-state): improve empty state logic and cleanup selected items on destroy

Refactor empty state display conditions in remission-list-empty-state component
to correctly handle search term validation. Move hasValidSearchTerm check to
parent condition to prevent displaying empty states during active searches.

Add ngOnDestroy lifecycle hook to remission-list-item component to properly
clean up selected quantities from the store when items are removed from the list.
This prevents memory leaks and ensures the store state remains synchronized with
the displayed items.

Changes:
- Move hasValidSearchTerm check in displayEmptyState computed signal to improve
  empty state display logic
- Implement OnDestroy interface in RemissionListItemComponent
- Add removeItem call in ngOnDestroy to clean up store state
- Add corresponding unit tests for the cleanup behavior

Ref: #5387
2025-10-17 12:09:55 +00:00
Nino Righi
a086111ab5 Merged PR 1966: Adjustments for #5320, #5360, #5361
Adjustments for #5320, #5360, #5361
2025-10-06 19:02:45 +00:00
Nino Righi
15a4718e58 Merged PR 1965: feat(remission-list): improve item update handling and UI feedback
feat(remission-list): improve item update handling and UI feedback

Enhance the remission list item management by introducing a more robust
update mechanism that tracks both item removal and impediment updates.
Previously, the component only tracked deletion progress, but now it
handles both deletion and update scenarios, allowing for better state
management and user feedback.

Key changes:
- Replace simple inProgress boolean with UpdateItem interface containing
  inProgress state, itemId, and optional impediment
- Update local items signal directly when items are removed or updated,
  eliminating unnecessary API calls and improving performance
- Add visual highlight to "Remi Menge ändern" button when dialog is open
  using a border style for better accessibility
- Improve error handling by tracking specific item operations
- Ensure selected items are properly removed from store when deleted
  or updated

The new approach optimizes list reloads by only fetching data when
necessary and provides clearer visual feedback during item operations.

Unit Tests updated also

Ref: #5361
2025-10-06 08:41:47 +00:00
Nino Righi
40592b4477 Merged PR 1964: feat(shared-filter): add canApply input to filter input menu components
feat(shared-filter): add canApply input to filter input menu components

Add canApply input parameter to FilterInputMenuButtonComponent and FilterInputMenuComponent to control when filter actions can be applied. Update RemissionListDepartmentElementsComponent to use canApply flag and implement rollback functionality when filter menu is closed without applying changes.

- Add canApply input to FilterInputMenuButtonComponent with default false
- Pass canApply parameter through to FilterInputMenuComponent
- Update remission department filter to use canApply=true
- Implement rollbackFilterInput method for filter state management
- Change selectedDepartments to selectedDepartment for single selection
- Update capacity resource to work with single department selection

Ref: #5320
2025-10-06 08:41:22 +00:00
Nino Righi
d430f544f0 Merged PR 1963: feat(utils): add scroll-top button component
feat(utils): add scroll-top button component

Add a reusable ScrollTopButtonComponent that provides smooth scrolling
to the top of a page or specific element. The component automatically
shows/hides based on scroll position and respects user's reduced motion
preferences.

Key features:
- Supports both window and element-specific scrolling
- Configurable position with sensible defaults
- Accessibility compliant with proper aria-label
- Respects prefers-reduced-motion media query
- Debounced scroll event handling for performance

Integrate the component into remission list and search dialog
components to improve user navigation experience.

Ref: #5360
2025-10-06 08:41:08 +00:00
Nino Righi
62e586cfda Merged PR 1951: fix(remission-list): ensure list reload after search dialog closes
fix(remission-list): ensure list reload after search dialog closes

Move reloadListAndReturnData() call outside the conditional block
to guarantee data refresh regardless of dialog result. Previously,
the list would only reload when items were selected, causing stale
data when the dialog was cancelled or closed without selection.

Ref: #5342
2025-09-16 12:41:05 +00:00
Nino Righi
304f8a64e5 Merged PR 1949: feat(isa-app): migrate remission navigation to tab-based routing system
feat(isa-app): migrate remission navigation to tab-based routing system

Replace hardcoded /filiale/remission routes with dynamic tab-based paths
using TabService. This enables proper process isolation and multi-tab
support for remission workflows.

Changes include:
- Update notification component to use dynamic remission paths
- Migrate goods-in remission preview to tab-based navigation
- Refactor side menu to use new remission routing structure
- Remove legacy remission route from app routing module
- Add linkedSignal for reactive path generation

BREAKING CHANGE: Direct navigation to /filiale/remission is no longer supported.
Users must access remission through the new tab-based system.

Ref: #5323, #5324, #5325
2025-09-15 13:11:47 +00:00
Nino Righi
c672ae4012 Merged PR 1948: fix(remission-error): simplify error handling in remission components
fix(remission-error): simplify error handling in remission components

Refactor error handling to use consistent error message extraction pattern.
Remove dependency on ResponseArgsError type and streamline error processing
in both RemissionListComponent and RemissionReturnReceiptDetailsItemComponent.
Extract error handling logic into separate methods for better maintainability.

Ref: #5331
2025-09-12 10:15:13 +00:00
Nino Righi
fd693a4beb Merged PR 1947: #5331 Set correct Prototype
#5331 Set correct Prototype
2025-09-11 15:42:55 +00:00
Nino Righi
2c70339f23 Merged PR 1945: fix(remission-list): auto-select single search result when remission started
fix(remission-list): auto-select single search result when remission started

Enhance search result handling to automatically select items when only
one result is found during an active remission. This improves user
workflow by eliminating the extra click required to select obvious
single results.

- Add preselectRemissionItem method to handle automatic selection
- Update emptySearchResultEffect to handle single hit scenario
- Clear selected items at start of effect to prevent stale selections
- Only auto-select if item has available stock and can be remitted
- Improve effect documentation with detailed behavior explanation

Ref: #5338
2025-09-11 14:21:19 +00:00
Nino Righi
59f0cc7d43 Merged PR 1946: fix(remission-list, remission-return-receipt-details, libs-dialog): improve error handling with dedicated error dialog
fix(remission-list, remission-return-receipt-details, libs-dialog): improve error handling with dedicated error dialog

- Add RemissionResponseArgsErrorMessage constants for standardized error messages
- Create FeedbackErrorDialogComponent for consistent error display across the app
- Implement enhanced error handling in RemissionListComponent.handleRemitItemsError()
- Update RemissionReturnReceiptDetailsItemComponent to use new error dialog pattern
- Add injectFeedbackErrorDialog convenience function for easy error dialog injection
- Include comprehensive unit tests for new dialog component
- Replace generic error handling with specific ResponseArgsError handling
- Clear remission state when "AlreadyCompleted" error occurs

The new error dialog provides a standardized way to display backend error
messages to users with consistent styling and behavior. Error handling now
properly differentiates between different error types and takes appropriate
actions like clearing state for completed remissions.

Ref: #5331
2025-09-11 14:06:14 +00:00
Nino Righi
0ca58fe1bf Merged PR 1942: feat(remission-list, search-item-to-remit-dialog): simplify dialog flow by re...
feat(remission-list, search-item-to-remit-dialog): simplify dialog flow by removing conditional views

Refactor the search item to remit dialog to use a dedicated quantity and reason
dialog instead of conditional views within the main dialog. This change improves
user experience by providing clearer navigation and better separation of concerns.

Key changes:
- Remove item signal and conditional template logic from SearchItemToRemitDialogComponent
- Create new SelectRemiQuantityAndReasonDialogComponent for quantity/reason selection
- Update SearchItemToRemitComponent to open quantity dialog instead of setting item state
- Simplify dialog data interface by removing isDepartment property
- Improve stock filtering logic to show only items with available stock
- Fix import path for QuantityAndReason interface

This refactor eliminates complex state management within the dialog and provides
a more intuitive user flow with dedicated dialogs for each step.

Ref: #5326
2025-09-10 14:18:17 +00:00
Nino
8cf80a60a0 Merge branch 'develop' into release/4.1 2025-09-05 08:19:36 +02:00
Nino Righi
cffa7721bc Merged PR 1941: fix(oms-data-access): adjust tolino return eligibility logic for display damage
fix(oms-data-access): adjust tolino return eligibility logic for display damage

Update tolino return eligibility to check for display damage and refine
date range conditions. Returns are now only eligible if the receipt is
between 6-24 months old, the item was received damaged, and the display
is not damaged.

Ref: #5286
2025-09-04 15:12:44 +00:00
Nino Righi
066ab5d5be Merged PR 1940: feat(old-ui-tooltip): add pointer-events-auto to tooltip panel
feat(old-ui-tooltip): add pointer-events-auto to tooltip panel

Enable mouse interactions with tooltip content by adding pointer-events-auto
class to .ui-tooltip-panel. This allows users to interact with clickable
elements inside tooltips while maintaining proper tooltip positioning.

Ref: #5244
2025-09-04 14:11:49 +00:00
Nino Righi
3bbf79a3c3 Merged PR 1939: feat(remission-list, empty-state): add comprehensive empty state handling wit...
feat(remission-list, empty-state): add comprehensive empty state handling with new appearance types

Add dedicated empty state component for remission list with smart prioritization logic:
- Department selection required state (highest priority)
- All done state when list is processed and empty
- No search results state for filtered content

Enhance ui-empty-state component with new appearance types:
- AllDone: Trophy cup icon with animated steam effects
- SelectAction: Hand pointer with dropdown interface element
- Improved visual hierarchy and spacing for all states

Update remission list to use new empty state component with proper state detection
including search term validation, department filter checking, and reload detection.

Ref: #5317, #5290
2025-09-04 14:11:19 +00:00
Nino Righi
357485e32f Merged PR 1938: #5294 Small Adjustments
#5294 Small Adjustments
2025-09-04 14:10:55 +00:00
Nino Righi
39984342a6 Merged PR 1937: fix(ui-input-controls-dropdown): prevent multiple dropdowns from being open s...
fix(ui-input-controls-dropdown): prevent multiple dropdowns from being open simultaneously

Add DropdownService to manage global dropdown state and ensure only one
dropdown is open at any time. When a new dropdown opens, any previously
opened dropdown is automatically closed, improving user experience and
preventing UI conflicts.

Ref: #5298
2025-09-03 13:19:10 +00:00
Nino Righi
c52f18e979 Merged PR 1936: fix(remission): filter search results by stock availability and display stock...
fix(remission): filter search results by stock availability and display stock info

- Add stock resource integration to search item component
- Filter search results to only show items with available stock (> 0)
- Display current stock information in search result items
- Implement calculateAvailableStock utility for accurate stock calculation
- Add inStock input parameter to SearchItemToRemitComponent
- Create reusable instock.resource for stock data fetching

The search now only displays items that are actually available for remission,
improving user experience by preventing selection of out-of-stock items.

Ref: #5318
2025-09-03 13:18:47 +00:00
Nino Righi
e58ec93087 Merged PR 1935: fix(remission-list, remission-data-access): add impediment comment and remain...
fix(remission-list, remission-data-access): add impediment comment and remaining quantity handling for return suggestions

Add support for impedimentComment and remainingQuantity fields when adding return suggestion items. When quantity is less than available stock, automatically set impedimentComment to 'Restmenge' and calculate remainingQuantity as the difference between available stock and remitted quantity.

Changes:
- Add impedimentComment and remainingQuantity to AddReturnSuggestionItemSchema
- Update RemissionReturnReceiptService to handle new fields in addReturnSuggestionItem method
- Enhance RemissionListComponent to calculate and pass impediment data when remitting items
- Fix quantity calculation logic to properly handle partial remissions

Ref: #5322
2025-09-03 13:18:23 +00:00
Nino Righi
4e6204817d Merged PR 1934: feature(remission-list): temporarily disable remission-processed-hint component
feature(remission-list): temporarily disable remission-processed-hint component

Comment out remi-remission-processed-hint component in remission list template
and add TODO comments referencing the need to adjust code once ticket #5215
is implemented. This temporary fix prevents issues with the hint component
until the underlying changes are completed.

Ref: #5136
2025-09-03 13:16:35 +00:00
Nino Righi
c41355bcdf Merged PR 1933: fix(remission-data-access): replace hardcoded values with dynamic helper func...
fix(remission-data-access): replace hardcoded values with dynamic helper functions

Replace hardcoded assortment and retail price values in RemissionSearchService
with proper helper functions. Add getAssortmentFromItem and getRetailPriceFromItem
helpers to dynamically extract values from Item objects instead of using
static fallbacks.

Also fix potential undefined reference errors in remission list resource
by adding proper null checks for response merging operations.

Ref: #5321
2025-09-03 13:15:57 +00:00
Nino Righi
fa8e601660 Merged PR 1932: feat(remission): ensure package assignment before completing return receipts
feat(remission): ensure package assignment before completing return receipts

Add validation to check if a package is assigned to a return receipt before
allowing completion. When no package is assigned, automatically open the
package assignment dialog to let users scan/input a package number.

- Add hasAssignedPackage input to complete component and pass from parent
- Integrate RemissionStartService.assignPackage() in completion flow
- Add assignPackageOnly flag to conditionally hide step counter in dialog
- Update dialog data structure to support direct package assignment mode
- Enhance test coverage for new assignment scenarios

This ensures all completed return receipts have proper package tracking
and improves the user workflow by guiding them through required steps.

Ref: #5289
2025-09-03 13:15:32 +00:00
Nino Righi
708ec01704 Merged PR 1931: fix(remission-quantity-and-reason-item)
fix(remission-quantity-and-reason-item)
Ref: #5292
2025-09-02 15:20:44 +00:00
Nino Righi
332699ca74 Merged PR 1930: fix(remission-quantity-and-reason-item): correct quantity input binding and d...
fix(remission-quantity-and-reason-item): correct quantity input binding and default value

Fix quantity input field binding to use computed quantity signal instead of
direct quantityAndReason().quantity, ensuring proper display of undefined
values as empty field. Update initial quantity default from 1 to 0 to
prevent pre-filled values when creating new quantity/reason items.

Also improves placeholder text color contrast by changing from neutral-200
to neutral-500 for better accessibility.

Ref: #5292
2025-09-02 15:20:14 +00:00
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
2cb1f9ec99 chore(azure-pipelines): Version bump 4.1 2025-08-14 17:09:13 +02: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
d7d535c10d Merged PR 1903: fix(remission-list, product-info, search-item-to-remit): improve responsive l...
fix(remission-list, product-info, search-item-to-remit): improve responsive layout and fix orientation logic

- Fix grid layout responsiveness in remission-list-item component by updating breakpoint conditions from mobileBreakpoint to desktopBreakpoint
- Correct product-info orientation logic to properly apply horizontal/vertical layouts based on breakpoint state
- Add consistent orientation handling to search-item-to-remit component with proper breakpoint detection
- Update CSS classes to use desktop-large breakpoint for better grid column management
- Add bottom margin to remission list container to prevent overlap with fixed action button
- Enhance test coverage for new computed properties and breakpoint-dependent behavior

Ref: #5239
2025-07-31 16:44:06 +00:00
Nino Righi
ad00899b6e Merged PR 1902: feat(shared-filter-inputs-checkbox-input): add bulk toggle functionality for...
feat(shared-filter-inputs-checkbox-input): add bulk toggle functionality for checkbox options

Replace individual option iteration with new toggleAllCheckboxOptions method
in FilterService. This improves performance and provides cleaner API for
selecting/deselecting all checkbox options at once. Updates component logic
to use the new bulk operation and fixes test expectations accordingly.

Ref: #5231
2025-07-31 16:42:37 +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
Michael Auer
1e84223076 ~ azure-pipelines.yml: DockerTagSourceBranch _ ==> - 2025-07-30 17:46:32 +02:00
Nino Righi
244984b6cf Merged PR 1900: feat(remission): add getStockToRemit helper and improve stock calculation logic
feat(remission): add getStockToRemit helper and improve stock calculation logic

Add new getStockToRemit helper function that handles different remission list types
(Pflicht and Abteilung) for calculating stock to remit. Refactor existing logic
to use the centralized helper instead of duplicated calculation code.

Changes:
- Add getStockToRemit function to handle RemissionListType-specific logic
- Update calculateStockToRemit to use strict undefined check for predefinedReturnQuantity
- Refactor RemissionListItemComponent to use getStockToRemit helper
- Update RemissionListComponent to use getStockToRemit for consistent calculations
- Add comprehensive test coverage for both helper functions

This centralizes stock calculation logic and ensures consistent behavior
across all remission components.

Ref: #5252
2025-07-30 12:00:08 +00:00
Lorenz Hilpert
b39abe630d Merged PR 1899: feat(empty-state): enhance empty state component with new appearance options...
feat(empty-state): enhance empty state component with new appearance options and integration in remission details

Related work items: #5232
2025-07-30 08:54:09 +00:00
Lorenz Hilpert
239ab52890 Merged PR 1898: chore: update dependencies to latest versions
chore: update dependencies to latest versions

- Upgraded @ngrx packages from 19.2.1 to ^20.0.0
- Upgraded ngx-matomo-client from ^7.0.1 to ^8.0.0
- Upgraded jest and related packages from 30.0.4 to ^29.7.0
2025-07-30 08:52:36 +00:00
Nino
4732656a0f chore(remission, navigation): update routing and remove unused helpers 2025-07-29 12:18:07 +02:00
Nino Righi
0da9800ca0 Merged PR 1897: #5236 #4771 Abteilungsremission
- feat(remission-list): Added Tooltip and Static Toolbar
- Merge branch 'develop' into feature/5236-Remission-Abteilungsremission-Offene-Punkte
- feat(remission-list, shared-filter, ui-input-controls): enhance department filtering and UI improvements
- Merge branch 'develop' into feature/5236-Remission-Abteilungsremission-Offene-Punkte
- Merge branch 'develop' into feature/5236-Remission-Abteilungsremission-Offene-Punkte
- feat(remission-list, remission-data-access): add department capacity display functionality

#5236 #4771 Abteilungsremission
2025-07-28 19:28:14 +00:00
Lorenz Hilpert
baf4a0dfbc Merge branch 'develop' of https://dev.azure.com/hugendubel/ISA/_git/ISA-Frontend into develop 2025-07-28 19:11:36 +02:00
Lorenz Hilpert
da5a42280a feat(remission): enhance quantity input handling and error validation
Refactor quantity input to use a direct input field instead of a toggle button.
Add validation to ensure quantity does not exceed 999 and display relevant error messages.
Improve overall user experience in the remission process.

Refs: #5253
2025-07-28 19:11:08 +02:00
Lorenz Hilpert
4d29189c8d Merged PR 1896: feat(filter): add maximum selection limits for checkbox filters
feat(filter): add maximum selection limits for checkbox filters

Implement a maxOptions property to limit the number of selections in checkbox filters.
This feature includes FIFO behavior for managing selections and hides the "Select All"
control when limits are set to prevent user confusion. Update related documentation
and components to reflect these changes.

Refs: #5250
2025-07-28 16:01:34 +00:00
Lorenz Hilpert
32bd3e26d2 Merged PR 1895: feat(app): provide default currency code as EUR
feat(app): provide default currency code as EUR

Refs: #5247 #5248
2025-07-28 12:00:38 +00:00
Lorenz Hilpert
6d26f7f6c0 Merged PR 1894: feat(filter): enhance search trigger handling and event emissions
feat(filter): enhance search trigger handling and event emissions

Refactor search components to emit specific search trigger types,
improving tracking of user interactions. Update relevant components
to handle 'input', 'filter', 'scan', and 'order-by' triggers,
ensuring consistent behavior across the filter system.

Refs: #5234
2025-07-28 08:54:15 +00:00
Lorenz Hilpert
72bcacefb6 Merged PR 1893: feat(remission): add remission processed hint component and update schemas
feat(remission): add remission processed hint component and update schemas

- Introduced RemissionProcessedHintComponent to display hints based on remission processing status.
- Updated fetch-remission-return-receipts schema to include parameters for completed returns.
- Refactored remission return receipt service to handle completed and incomplete returns separately.
- Adjusted remission list component to utilize the new hint component and updated data fetching logic.

Refs: #5240 #5136
2025-07-28 08:30:04 +00:00
Lorenz Hilpert
71e9a6da0e Merged PR 1892: refactor(return-receipt-list-item): restructure styles and remove unused imports
refactor(return-receipt-list-item): restructure styles and remove unused imports

Updated the SCSS to separate background styles for different states and removed the unused Receipt import from the component.

Refs: #5225
2025-07-28 08:26:54 +00:00
Lorenz Hilpert
b339a6d79f Merged PR 1891: feat: implement multi-level checkbox filter with hierarchical selection
feat: implement multi-level checkbox filter with hierarchical selection

- Add support for hierarchical checkbox options with parent-child relationships
- Implement automatic child selection/deselection when parent is toggled
- Add checkbox-input-control component for individual option management
- Add isCheckboxSelected helper for determining selection states
- Extend FilterService with setInputCheckboxOptionSelected method
- Update checkbox schemas to support nested option structures
- Add comprehensive test coverage for new multi-level functionality

Ref: #5231
2025-07-25 13:49:44 +00:00
Lorenz Hilpert
0b4aef5f6c chore: add nx.instructions.md to .gitignore 2025-07-25 10:37:44 +02:00
Nino Righi
c5182809ac Merged PR 1890: #5230 #5233 Remi Starten Feedback
- feat(remission-data-access,remission-list,remission-return-receipt-details): improve remission list UX and persist store state
- feat(remission-list, remission-data-access): implement resource-based receipt data fetching
- Merge branch 'develop' into feature/5230-Feedback-Remi-Starten
- feat(remission-data-access, remission-list, ui-dialog, remission-start-dialog): consolidate remission workflow and enhance dialog system
- feat(remission-list-item): extract selection logic into dedicated component
Refs: #5230 #5233
2025-07-24 21:22:02 +00:00
Lorenz Hilpert
f4b541c7c0 chore: update package.json to include overrides for jest-environment-jsdom and stylus 2025-07-23 17:40:37 +02:00
Lorenz Hilpert
afe6c6abcc chore: update package.json to override stylus version to 0.64.0 and ensure jsdom is set to 26.0.0 2025-07-23 17:29:37 +02:00
Lorenz Hilpert
3f233f9580 Merge tag '4.0' into develop
Finish Release 4.0 4.0
2025-07-23 17:02:32 +02:00
Lorenz Hilpert
6f9d4d9218 Merge branch 'release/4.0' 2025-07-23 16:35:08 +02:00
Lorenz Hilpert
4111663d8c feat: add mock for ScannerButtonComponent and update feedback dialog
- Created a mock for ScannerButtonComponent in test-mocks.ts to facilitate testing.
- Updated test-setup.ts to mock browser APIs for the test environment.
- Refactored SelectRemiQuantityAndReasonComponent to simplify addToRemiList logic and update feedback dialog usage.
- Modified feedback-dialog.component.html to safely access message data.
- Cleaned up package-lock.json by removing deprecated and unnecessary dependencies.
2025-07-22 15:06:25 +02:00
Lorenz Hilpert
2beeba5c92 fix: resolve critical security vulnerability in form-data
- Updated form-data from 4.0.3 to 4.0.4
- Fixes GHSA-fjxv-7rqg-78g4: unsafe random function usage for boundary selection
- Applied npm audit fix --force due to peer dependency conflicts
2025-07-21 23:11:54 +02:00
Lorenz Hilpert
edab1322c8 chore: migrate nx to latest 2025-07-21 22:35:11 +02:00
Lorenz Hilpert
59ce736faa feat(remission-return-receipt-list): rewrite unit tests with Angular Testing Utilities
- Replace Spectator with Angular's official TestBed and ComponentFixture
- Implement isolated test approach with proper AAA pattern
- Fix TypeScript errors related to Return interface type mismatches
- Add comprehensive edge case testing and error handling
- Create proper mock components for child dependencies
- Ensure all 47 tests pass with improved maintainability
2025-07-21 20:07:02 +02:00
Nino Righi
3cd6f4bd58 Merged PR 1889: feat(remission-data-access, remission-list, remission-start-card): add remission item selection and quantity update logic
- Introduce `addReturnItem` and `addReturnSuggestionItem` methods to `RemissionReturnReceiptService` with full schema validation and error handling
- Add models and schemas for receipt-return tuples and add-return-item/suggestion operations
- Refactor `RemissionStore` (formerly `RemissionSelectionStore`) to support selection, quantity updates, and clearing of remission items; update all usages to new store name and API
- Update `RemissionListItemComponent` to support item selection via checkbox and quantity dialog, following workspace UX and state management guidelines
- Enhance `RemissionListComponent` to handle selected items, batch remission, and error/success feedback using new store and service APIs
- Fix and extend tests for new store and service logic, ensuring coverage for selection, quantity, and remission flows
- Update remission start dialog and assign package number components for improved validation and loading state handling

Ref: #5221
2025-07-21 10:28:12 +00:00
Lorenz Hilpert
594acaa5f5 feat(button): add disabled state input to stateful button component 2025-07-21 08:39:33 +02:00
Lorenz Hilpert
76ff54dd3a Merged PR 1887: feat(navigation): add collapsible submenu for remission navigation #5223
feat(navigation): add collapsible submenu for remission navigation #5223

- Convert remission navigation to expandable submenu with arrow toggle
- Add 'list' route for remission with redirect from empty path
- Separate navigation items for Remission and Warenbegleitscheine
- Refactor side-menu component to use inject() pattern
- Add remissionExpanded signal to track submenu state
2025-07-18 06:40:24 +00:00
Lorenz Hilpert
598df7d5ed Merged PR 1888: fix: improve sorting of remission return receipts
fix: improve sorting of remission return receipts

- Refactor data fetching to use a single API call for all returns
- Apply sorting separately to completed and incomplete returns
- Fix template tracking to use index instead of potentially undefined ID
- Remove redundant API calls for incomplete returns

This ensures proper sorting of remission return receipts while maintaining
the separation between completed and incomplete items in the display order.

Ref: #5224
2025-07-18 06:39:44 +00:00
Lorenz Hilpert
442670bdd0 Merged PR 1885: Remi Add Flow - ohne offener Remi
Related work items: #5135
2025-07-17 13:53:36 +00:00
Lorenz Hilpert
b015e97e1f Merged PR 1886: feat: add unit tests for remission return receipt functionality
feat: add unit tests for remission return receipt functionality

- Add tests for 4 new RemissionReturnReceiptService methods:
  - removeReturnItemFromReturnReceipt()
  - completeReturnReceipt()
  - completeReturn()
  - completeReturnReceiptAndReturn()
- Update RemissionReturnReceiptDetailsCardComponent tests for itemCount -> positionCount
- Add tests for new inputs and remove functionality in RemissionReturnReceiptDetailsItemComponent
- Add tests for canRemoveItems and completeReturn in RemissionReturnReceiptDetailsComponent
- All tests focus on happy path scenarios and isolated functionality

Refs: #5138
2025-07-17 13:46:32 +00:00
Nino Righi
65ab3bfc0a Merged PR 1884: #5213
- feat(dialog-feedback-dialog, remission-list-item): add feedback dialog and remission list item components
- feat(remission-list-item): implement remission list item component
Refs: #5213
2025-07-15 11:26:03 +00:00
Lorenz Hilpert
e674378080 Merged PR 1883: fix(return-details): update email validation and improve error handling
fix(return-details): update email validation and improve error handling

Refs: #5211
2025-07-14 14:57:41 +00:00
Lorenz Hilpert
40c9d51dfc Merged PR 1881: Stateful Remi Button
#5203

Related work items: #5203
2025-07-14 11:57:03 +00:00
Nino Righi
5f74c6ddf8 Merged PR 1878: Refs: #4769, #5196
- feat(remission-shared-produt-shelf-meta-info): Intermediate commit.
- feat(remission-shared-product-shelf-meta-info): improve template structure and data attributes
- feat(remission-list-item): add product shelf meta info and improve E2E selectors

Refs: #4769, #5196
2025-07-11 19:53:56 +00:00
Lorenz Hilpert
a36d746fb8 Merged PR 1879: Warenbegleitschein Übersicht und Details
Related work items: #5137, #5138
2025-07-10 16:00:16 +00:00
Nino
f6b2b554bb Merge branch 'release/4.0' into develop 2025-07-10 14:31:03 +02:00
Nino
465df27858 Merge branch 'release/4.0' into develop 2025-07-10 14:16:29 +02:00
Nino Righi
7c907645dc Merged PR 1880: hotfix(oms-data-access): initial implementation of OMS data access layer
hotfix(oms-data-access): initial implementation of OMS data access layer

Introduce the foundational OMS data access module, including service scaffolding and integration points for future API communication. This establishes a clear separation of concerns for order management system data retrieval and manipulation, following project architecture guidelines.

Ref: #5210
2025-07-10 11:32:42 +00:00
Nino Righi
6fee35c756 Merged PR 1872: fix(isa-app-moment-locale): correct locale initialization for date formatting
fix(isa-app-moment-locale): correct locale initialization for date formatting

Ensures proper setup of moment.js locale in the ISA app to provide accurate date and time formatting for users. Addresses issues with incorrect or inconsistent locale application.

Ref: #5188
2025-06-25 08:35:43 +00:00
Nino Righi
c15077aa86 Merged PR 1870: fix(oms-return-search): fix display and logic issues in return search results
fix(oms-return-search): fix display and logic issues in return search results

Resolve display inconsistencies and correct logic in the return search result component to improve user experience and maintain alignment with design and business requirements.

Ref: #5009
2025-06-23 21:23:27 +00:00
Nino Righi
f051a97e53 Merged PR 1871: fix(ui-dropdown): improve dropdown usability and conditional rendering
fix(ui-dropdown): improve dropdown usability and conditional rendering

Refines the logic for displaying quantity and product category dropdowns in the return details order group item controls. Ensures dropdowns are only shown when appropriate and maintains accessibility and user experience.

Ref: #5189
2025-06-23 15:32:56 +00:00
Nino Righi
1b26a44a37 Merged PR 1869: fix(oms-task-list-item): address styling and layout issues in return task lis...
fix(oms-task-list-item): address styling and layout issues in return task list item

Improves SCSS for the return task list item component to ensure consistent appearance and resolve layout inconsistencies. Enhances maintainability and visual alignment with design standards.

Ref: #5191
2025-06-23 15:25:34 +00:00
Nino Righi
80b2508708 Merged PR 1868: fix(oms-return-search): resolve issues in return search result item rendering
fix(oms-return-search): resolve issues in return search result item rendering

Corrects rendering logic and improves template structure for the return search result item component. Ensures compliance with Angular control flow best practices and enhances maintainability.

Ref: #5190
2025-06-23 15:24:26 +00:00
Nino
e9affd2359 fix(return-details): Small Layout Fix, Refs: #5171 2025-06-17 16:52:03 +02:00
Nino
8f8b9153b0 Merge branch 'develop' into release/4.0 2025-06-17 16:45:37 +02:00
Lorenz Hilpert
9a4121e2bf fix(return-details): correct storage key retrieval in ReturnDetailsStore 2025-06-16 10:53:58 +02:00
Lorenz Hilpert
50b7f21394 Merge branch 'develop' into release/4.0 2025-06-12 21:12:08 +02:00
Lorenz Hilpert
a67375557d Merge branch 'develop' into release/4.0 2025-06-02 11:41:54 +02:00
Lorenz Hilpert
6e7c56fcb9 style(errors): standardize quotation marks in error exports 2025-05-28 21:32:41 +02:00
Lorenz Hilpert
05e257b922 Merge branch 'develop' into release/4.0 2025-05-13 18:52:00 +02:00
Lorenz Hilpert
d7d61915fa Merge branch 'develop' into release/4.0 2025-05-13 18:36:09 +02:00
Nino
d0220b6246 Merge branch 'develop' into release/4.0 2025-05-09 17:26:47 +02:00
Nino
32336ba5b4 Update index file return data-access 2025-05-09 12:13:25 +02:00
Nino
1f26d5285b Merge branch 'develop' into release/4.0 2025-05-09 12:12:19 +02:00
Michael Auer
be0bff0535 Cherry Pick: PR 1824: ISA-Frontend - Expliziter Pfad für Traefik IngressRoute
(cherry picked from commit c9b2762bbc)
2025-02-28 09:36:06 +01:00
Lorenz Hilpert
cb7391e66f Update version numbers in azure-pipelines.yml to 4.0 2025-02-10 10:43:23 +01:00
467 changed files with 64899 additions and 36289 deletions

View File

@@ -4,6 +4,8 @@
You are Mentor, an AI assistant focused on ensuring code quality, strict adherence to best practices, and development efficiency. **Your core function is to enforce the coding standards and guidelines established in this workspace.** Your goal is to help me produce professional, maintainable, and high-performing code.
**Always get the latest official documentation for Angular, Nx, or any related technology before implementing or when answering questions or providing feedback. Use Context7:**
## Tone and Personality
Maintain a professional, objective, and direct tone consistently:

View File

@@ -1,40 +0,0 @@
---
applyTo: '**'
---
// This file is automatically generated by Nx Console
You are in an nx workspace using Nx 21.2.1 and npm as the package manager.
You have access to the Nx MCP server and the tools it provides. Use them. Follow these guidelines in order to best help the user:
# General Guidelines
- When answering questions, use the nx_workspace tool first to gain an understanding of the workspace architecture
- For questions around nx configuration, best practices or if you're unsure, use the nx_docs tool to get relevant, up-to-date docs!! Always use this instead of assuming things about nx configuration
- If the user needs help with an Nx configuration or project graph error, use the 'nx_workspace' tool to get any errors
- To help answer questions about the workspace structure or simply help with demonstrating how tasks depend on each other, use the 'nx_visualize_graph' tool
# Generation Guidelines
If the user wants to generate something, use the following flow:
- learn about the nx workspace and any specifics the user needs by using the 'nx_workspace' tool and the 'nx_project_details' tool if applicable
- get the available generators using the 'nx_generators' tool
- decide which generator to use. If no generators seem relevant, check the 'nx_available_plugins' tool to see if the user could install a plugin to help them
- get generator details using the 'nx_generator_schema' tool
- you may use the 'nx_docs' tool to learn more about a specific generator or technology if you're unsure
- decide which options to provide in order to best complete the user's request. Don't make any assumptions and keep the options minimalistic
- open the generator UI using the 'nx_open_generate_ui' tool
- wait for the user to finish the generator
- read the generator log file using the 'nx_read_generator_log' tool
- use the information provided in the log file to answer the user's question or continue with what they were doing
# Running Tasks Guidelines
If the user wants help with tasks or commands (which include keywords like "test", "build", "lint", or other similar actions), use the following flow:
- Use the 'nx_current_running_tasks_details' tool to get the list of tasks (this can include tasks that were completed, stopped or failed).
- If there are any tasks, ask the user if they would like help with a specific task then use the 'nx_current_running_task_output' tool to get the terminal output for that task/command
- Use the terminal output from 'nx_current_running_task_output' to see what's wrong and help the user fix their problem. Use the appropriate tools if necessary
- If the user would like to rerun the task or command, always use `nx run <taskId>` to rerun in the terminal. This will ensure that the task will run in the nx context and will be run the same way it originally executed
- If the task was marked as "continuous" do not offer to rerun the task. This task is already running and the user can see the output in the terminal. You can use 'nx_current_running_task_output' to get the output of the task to verify the output.

View File

@@ -2,8 +2,10 @@
## Framework and Tools
- Use **Jest** as the testing framework.
- For unit tests, utilize **Spectator** to simplify Angular component testing.
- **Vitest** is the recommended testing framework.
[Vitest Documentation (latest)](https://context7.com/vitest-dev/vitest/llms.txt?topic=getting+started)
- **Jest** and **Spectator** are **deprecated**.
Do not use them for new tests. Existing tests should be migrated to Vitest where possible.
## Guidelines
@@ -23,28 +25,31 @@
## Example Test Structure
```typescript
// Example using Jest and Spectator
import { createComponentFactory, Spectator } from '@ngneat/spectator';
// Example using Vitest (Jest and Spectator are deprecated)
import { describe, it, expect, beforeEach } from 'vitest';
import { render } from '@testing-library/angular';
import { MyComponent } from './my-component.component';
describe('MyComponent', () => {
let spectator: Spectator<MyComponent>;
const createComponent = createComponentFactory(MyComponent);
let component: MyComponent;
beforeEach(() => {
spectator = createComponent();
beforeEach(async () => {
const { fixture } = await render(MyComponent);
component = fixture.componentInstance;
});
it('should display the correct title', () => {
it('should display the correct title', async () => {
// Arrange
const expectedTitle = 'Hello World';
// Act
spectator.component.title = expectedTitle;
spectator.detectChanges();
component.title = expectedTitle;
// If using Angular, trigger change detection:
// fixture.detectChanges();
// Assert
expect(spectator.query('h1')).toHaveText(expectedTitle);
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('h1')?.textContent).toBe(expectedTitle);
});
it('should handle error cases gracefully', () => {
@@ -52,15 +57,17 @@ describe('MyComponent', () => {
const invalidInput = null;
// Act
spectator.component.input = invalidInput;
component.input = invalidInput;
// Assert
expect(() => spectator.component.processInput()).toThrowError('Invalid input');
expect(() => component.processInput()).toThrowError('Invalid input');
});
});
```
## Additional Resources
- [Jest Documentation](https://jestjs.io/docs/getting-started)
- [Spectator Documentation](https://ngneat.github.io/spectator/)
- [Vitest Documentation (latest)](https://context7.com/vitest-dev/vitest/llms.txt?topic=getting+started)
- [Vitest Official Guide](https://vitest.dev/guide/)
- [Testing Library for Angular](https://testing-library.com/docs/angular-testing-library/intro/)
- **Jest** and **Spectator** documentation are deprecated

149
.gitignore vendored
View File

@@ -1,72 +1,77 @@
# See http://help.github.com/ignore-files/ for more about ignoring files.
.matomo
junit.xml
# compiled output
/dist
/tmp
/out-tsc
/
# dependencies
/node_modules
# profiling files
chrome-profiler-events.json
speed-measure-plugin.json
# IDEs and editors
/.idea
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# IDE - VSCode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
.history/*
# misc
/.angular/cache
/.sass-cache
/connect.lock
/coverage
/testresults
/libpeerconnection.log
npm-debug.log
yarn-error.log
yarn.lock
testem.log
/typings
# System Files
.DS_Store
Thumbs.db
libs/swagger/src/lib/*
*storybook.log
.nx/cache
.nx/workspace-data
.angular
.claude
storybook-static
.cursor\rules\nx-rules.mdc
.github\instructions\nx.instructions.md
.cursor/rules/nx-rules.mdc
.github/instructions/nx.instructions.md
vite.config.*.timestamp*
vitest.config.*.timestamp*
# See http://help.github.com/ignore-files/ for more about ignoring files.
.matomo
junit.xml
# compiled output
/dist
/tmp
/out-tsc
/
# dependencies
/node_modules
# profiling files
chrome-profiler-events.json
speed-measure-plugin.json
# IDEs and editors
/.idea
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# IDE - VSCode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
.history/*
# misc
/.angular/cache
/.sass-cache
/connect.lock
/coverage
/testresults
/libpeerconnection.log
npm-debug.log
yarn-error.log
yarn.lock
testem.log
/typings
# System Files
.DS_Store
Thumbs.db
libs/swagger/src/lib/*
*storybook.log
.nx/cache
.nx/workspace-data
.angular
.claude
storybook-static
.cursor\rules\nx-rules.mdc
.github\instructions\nx.instructions.md
.cursor/rules/nx-rules.mdc
.github/instructions/nx.instructions.md
vite.config.*.timestamp*
vitest.config.*.timestamp*
.mcp.json
.memory.json
nx.instructions.md

View File

@@ -8,7 +8,7 @@ WORKDIR /app
COPY . .
RUN umask 0022
RUN npm version ${SEMVERSION}
RUN npm install --foreground-scripts --legacy-peer-deps
RUN npm install --foreground-scripts
RUN if [ "${IS_PRODUCTION}" = "true" ] ; then npm run-script build-prod ; else npm run-script build ; fi
# stage final

View File

@@ -1,162 +1,162 @@
{
"name": "isa-app",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"projectType": "application",
"prefix": "app",
"sourceRoot": "apps/isa-app/src",
"tags": [],
"targets": {
"build": {
"executor": "@angular-devkit/build-angular:application",
"options": {
"allowedCommonJsDependencies": [
"lodash",
"moment",
"jsrsasign",
"pdfjs-dist/build/pdf",
"pdfjs-dist/web/pdf_viewer",
"pdfjs-dist/es5/build/pdf",
"pdfjs-dist/es5/web/pdf_viewer"
],
"outputPath": "dist/isa-app",
"index": "apps/isa-app/src/index.html",
"browser": "apps/isa-app/src/main.ts",
"polyfills": ["zone.js"],
"tsConfig": "apps/isa-app/tsconfig.app.json",
"inlineStyleLanguage": "scss",
"assets": [
"apps/isa-app/src/favicon.ico",
"apps/isa-app/src/assets",
"apps/isa-app/src/config",
"apps/isa-app/src/silent-refresh.html",
"apps/isa-app/src/manifest.webmanifest",
{
"glob": "**/*",
"input": "node_modules/scandit-web-datacapture-barcode/build/engine",
"output": "scandit"
}
],
"styles": [
"@angular/cdk/overlay-prebuilt.css",
"apps/isa-app/src/tailwind.scss",
"apps/isa-app/src/styles.scss"
],
"scripts": []
},
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "2mb",
"maximumError": "5mb"
},
{
"type": "anyComponentStyle",
"maximumWarning": "25kb"
}
],
"fileReplacements": [
{
"replace": "apps/isa-app/src/environments/environment.ts",
"with": "apps/isa-app/src/environments/environment.prod.ts"
}
],
"outputHashing": "all",
"serviceWorker": "apps/isa-app/ngsw-config.json"
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
}
},
"defaultConfiguration": "production",
"outputs": ["{options.outputPath}"]
},
"serve": {
"executor": "@angular-devkit/build-angular:dev-server",
"configurations": {
"production": {
"buildTarget": "isa-app:build:production"
},
"development": {
"buildTarget": "isa-app:build:development"
}
},
"defaultConfiguration": "development",
"continuous": true
},
"extract-i18n": {
"executor": "@angular-devkit/build-angular:extract-i18n",
"options": {
"buildTarget": "isa-app:build"
}
},
"lint": {
"executor": "@nx/eslint:lint"
},
"test": {
"executor": "@nx/jest:jest",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
"options": {
"jestConfig": "apps/isa-app/jest.config.ts"
}
},
"serve-static": {
"executor": "@nx/web:file-server",
"options": {
"buildTarget": "isa-app:build",
"staticFilePath": "dist/apps/isa-app/browser",
"spa": true
}
},
"storybook": {
"executor": "@storybook/angular:start-storybook",
"options": {
"port": 4400,
"configDir": "apps/isa-app/.storybook",
"browserTarget": "isa-app:build",
"compodoc": false,
"open": false,
"assets": [
{
"glob": "**/*",
"input": "apps/isa-app/src/assets",
"output": "/assets"
}
],
"styles": [
"@angular/cdk/overlay-prebuilt.css",
"apps/isa-app/src/tailwind.scss",
"apps/isa-app/src/styles.scss"
]
},
"configurations": {
"ci": {
"quiet": true
}
}
},
"build-storybook": {
"executor": "@storybook/angular:build-storybook",
"outputs": ["{options.outputDir}"],
"options": {
"outputDir": "dist/storybook/isa-app",
"configDir": "apps/isa-app/.storybook",
"browserTarget": "isa-app:build",
"compodoc": false,
"styles": [
"@angular/cdk/overlay-prebuilt.css",
"apps/isa-app/src/tailwind.scss",
"apps/isa-app/src/styles.scss"
]
},
"configurations": {
"ci": {
"quiet": true
}
}
}
}
}
{
"name": "isa-app",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"projectType": "application",
"prefix": "app",
"sourceRoot": "apps/isa-app/src",
"tags": [],
"targets": {
"build": {
"executor": "@angular-devkit/build-angular:application",
"options": {
"allowedCommonJsDependencies": [
"lodash",
"moment",
"jsrsasign",
"pdfjs-dist/build/pdf",
"pdfjs-dist/web/pdf_viewer",
"pdfjs-dist/es5/build/pdf",
"pdfjs-dist/es5/web/pdf_viewer"
],
"outputPath": "dist/isa-app",
"index": "apps/isa-app/src/index.html",
"browser": "apps/isa-app/src/main.ts",
"polyfills": ["zone.js"],
"tsConfig": "apps/isa-app/tsconfig.app.json",
"inlineStyleLanguage": "scss",
"assets": [
"apps/isa-app/src/favicon.ico",
"apps/isa-app/src/assets",
"apps/isa-app/src/config",
"apps/isa-app/src/silent-refresh.html",
"apps/isa-app/src/manifest.webmanifest",
{
"glob": "**/*",
"input": "node_modules/scandit-web-datacapture-barcode/build/engine",
"output": "scandit"
}
],
"styles": [
"@angular/cdk/overlay-prebuilt.css",
"apps/isa-app/src/tailwind.scss",
"apps/isa-app/src/styles.scss"
],
"scripts": []
},
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "2mb",
"maximumError": "5mb"
},
{
"type": "anyComponentStyle",
"maximumWarning": "25kb"
}
],
"fileReplacements": [
{
"replace": "apps/isa-app/src/environments/environment.ts",
"with": "apps/isa-app/src/environments/environment.prod.ts"
}
],
"outputHashing": "all",
"serviceWorker": "apps/isa-app/ngsw-config.json"
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
}
},
"defaultConfiguration": "production",
"outputs": ["{options.outputPath}"]
},
"serve": {
"executor": "@angular-devkit/build-angular:dev-server",
"configurations": {
"production": {
"buildTarget": "isa-app:build:production"
},
"development": {
"buildTarget": "isa-app:build:development"
}
},
"defaultConfiguration": "development",
"continuous": true
},
"extract-i18n": {
"executor": "@angular-devkit/build-angular:extract-i18n",
"options": {
"buildTarget": "isa-app:build"
}
},
"lint": {
"executor": "@nx/eslint:lint"
},
"test": {
"executor": "@nx/jest:jest",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
"options": {
"jestConfig": "apps/isa-app/jest.config.ts"
}
},
"serve-static": {
"executor": "@nx/web:file-server",
"options": {
"buildTarget": "isa-app:build",
"staticFilePath": "dist/apps/isa-app/browser",
"spa": true
}
},
"storybook": {
"executor": "@storybook/angular:start-storybook",
"options": {
"port": 4400,
"configDir": "apps/isa-app/.storybook",
"browserTarget": "isa-app:build",
"compodoc": false,
"open": false,
"assets": [
{
"glob": "**/*",
"input": "apps/isa-app/src/assets",
"output": "/assets"
}
],
"styles": [
"@angular/cdk/overlay-prebuilt.css",
"apps/isa-app/src/tailwind.scss",
"apps/isa-app/src/styles.scss"
]
},
"configurations": {
"ci": {
"quiet": true
}
}
},
"build-storybook": {
"executor": "@storybook/angular:build-storybook",
"outputs": ["{options.outputDir}"],
"options": {
"outputDir": "dist/storybook/isa-app",
"configDir": "apps/isa-app/.storybook",
"browserTarget": "isa-app:build",
"compodoc": false,
"styles": [
"@angular/cdk/overlay-prebuilt.css",
"apps/isa-app/src/tailwind.scss",
"apps/isa-app/src/styles.scss"
]
},
"configurations": {
"ci": {
"quiet": true
}
}
}
}
}

View File

@@ -1,214 +1,231 @@
import { isDevMode, NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import {
CanActivateCartGuard,
CanActivateCartWithProcessIdGuard,
CanActivateCustomerGuard,
CanActivateCustomerOrdersGuard,
CanActivateCustomerOrdersWithProcessIdGuard,
CanActivateCustomerWithProcessIdGuard,
CanActivateGoodsInGuard,
CanActivateProductGuard,
CanActivateProductWithProcessIdGuard,
CanActivateRemissionGuard,
CanActivateTaskCalendarGuard,
IsAuthenticatedGuard,
} from './guards';
import { CanActivateAssortmentGuard } from './guards/can-activate-assortment.guard';
import { CanActivatePackageInspectionGuard } from './guards/can-activate-package-inspection.guard';
import { MainComponent } from './main.component';
import { PreviewComponent } from './preview';
import {
BranchSectionResolver,
CustomerSectionResolver,
ProcessIdResolver,
} from './resolvers';
import { TokenLoginComponent, TokenLoginModule } from './token-login';
import { ProcessIdGuard } from './guards/process-id.guard';
import {
ActivateProcessIdGuard,
ActivateProcessIdWithConfigKeyGuard,
} from './guards/activate-process-id.guard';
import { MatomoRouteData } from 'ngx-matomo-client';
import { tabResolverFn } from '@isa/core/tabs';
import { provideScrollPositionRestoration } from '@isa/utils/scroll-position';
const routes: Routes = [
{ path: '', redirectTo: 'kunde/dashboard', pathMatch: 'full' },
{
path: 'login',
children: [
{ path: ':token', component: TokenLoginComponent },
{ path: '**', redirectTo: 'kunde', pathMatch: 'full' },
],
},
{
path: '',
canActivate: [IsAuthenticatedGuard],
children: [
{
path: 'kunde',
component: MainComponent,
children: [
{
path: 'dashboard',
loadChildren: () =>
import('@page/dashboard').then((m) => m.DashboardModule),
data: {
matomo: {
title: 'Dashboard',
} as MatomoRouteData,
},
},
{
path: 'product',
loadChildren: () =>
import('@page/catalog').then((m) => m.PageCatalogModule),
canActivate: [CanActivateProductGuard],
},
{
path: ':processId/product',
loadChildren: () =>
import('@page/catalog').then((m) => m.PageCatalogModule),
canActivate: [CanActivateProductWithProcessIdGuard],
resolve: { processId: ProcessIdResolver },
},
{
path: 'order',
loadChildren: () =>
import('@page/customer-order').then((m) => m.CustomerOrderModule),
canActivate: [CanActivateCustomerOrdersGuard],
},
{
path: ':processId/order',
loadChildren: () =>
import('@page/customer-order').then((m) => m.CustomerOrderModule),
canActivate: [CanActivateCustomerOrdersWithProcessIdGuard],
resolve: { processId: ProcessIdResolver },
},
{
path: 'customer',
loadChildren: () =>
import('@page/customer').then((m) => m.CustomerModule),
canActivate: [CanActivateCustomerGuard],
},
{
path: ':processId/customer',
loadChildren: () =>
import('@page/customer').then((m) => m.CustomerModule),
canActivate: [CanActivateCustomerWithProcessIdGuard],
resolve: { processId: ProcessIdResolver },
},
{
path: 'cart',
loadChildren: () =>
import('@page/checkout').then((m) => m.PageCheckoutModule),
canActivate: [CanActivateCartGuard],
},
{
path: ':processId/cart',
loadChildren: () =>
import('@page/checkout').then((m) => m.PageCheckoutModule),
canActivate: [CanActivateCartWithProcessIdGuard],
},
{
path: 'pickup-shelf',
canActivate: [ProcessIdGuard],
// NOTE: This is a workaround for the canActivate guard not being called
loadChildren: () =>
import('@page/pickup-shelf').then((m) => m.PickupShelfOutModule),
},
{
path: ':processId/pickup-shelf',
canActivate: [ActivateProcessIdGuard],
loadChildren: () =>
import('@page/pickup-shelf').then((m) => m.PickupShelfOutModule),
},
{ path: '**', redirectTo: 'dashboard', pathMatch: 'full' },
],
resolve: { section: CustomerSectionResolver },
},
{
path: 'filiale',
component: MainComponent,
children: [
{
path: 'task-calendar',
loadChildren: () =>
import('@page/task-calendar').then(
(m) => m.PageTaskCalendarModule,
),
canActivate: [CanActivateTaskCalendarGuard],
},
{
path: 'pickup-shelf',
canActivate: [ActivateProcessIdWithConfigKeyGuard('pickupShelf')],
// NOTE: This is a workaround for the canActivate guard not being called
loadChildren: () =>
import('@page/pickup-shelf').then((m) => m.PickupShelfInModule),
},
{
path: 'goods/in',
loadChildren: () =>
import('@page/goods-in').then((m) => m.GoodsInModule),
canActivate: [CanActivateGoodsInGuard],
},
{
path: 'remission',
loadChildren: () =>
import('@page/remission').then((m) => m.PageRemissionModule),
canActivate: [CanActivateRemissionGuard],
},
{
path: 'package-inspection',
loadChildren: () =>
import('@page/package-inspection').then(
(m) => m.PackageInspectionModule,
),
canActivate: [CanActivatePackageInspectionGuard],
},
{
path: 'assortment',
loadChildren: () =>
import('@page/assortment').then((m) => m.AssortmentModule),
canActivate: [CanActivateAssortmentGuard],
},
{ path: '**', redirectTo: 'task-calendar', pathMatch: 'full' },
],
resolve: { section: BranchSectionResolver },
},
],
},
{
path: ':tabId',
component: MainComponent,
resolve: { process: tabResolverFn, tab: tabResolverFn },
canActivate: [IsAuthenticatedGuard],
children: [
{
path: 'return',
loadChildren: () =>
import('@isa/oms/feature/return-search').then((m) => m.routes),
},
{
path: 'remission',
loadChildren: () =>
import('@isa/remission/feature/remission-list').then((m) => m.routes),
},
],
},
];
if (isDevMode()) {
routes.unshift({
path: 'preview',
component: PreviewComponent,
});
}
@NgModule({
imports: [RouterModule.forRoot(routes), TokenLoginModule],
exports: [RouterModule],
providers: [provideScrollPositionRestoration()],
})
export class AppRoutingModule {}
import { isDevMode, NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import {
CanActivateCartGuard,
CanActivateCartWithProcessIdGuard,
CanActivateCustomerGuard,
CanActivateCustomerOrdersGuard,
CanActivateCustomerOrdersWithProcessIdGuard,
CanActivateCustomerWithProcessIdGuard,
CanActivateGoodsInGuard,
CanActivateProductGuard,
CanActivateProductWithProcessIdGuard,
CanActivateRemissionGuard,
CanActivateTaskCalendarGuard,
IsAuthenticatedGuard,
} from './guards';
import { CanActivateAssortmentGuard } from './guards/can-activate-assortment.guard';
import { CanActivatePackageInspectionGuard } from './guards/can-activate-package-inspection.guard';
import { MainComponent } from './main.component';
import { PreviewComponent } from './preview';
import {
BranchSectionResolver,
CustomerSectionResolver,
ProcessIdResolver,
} from './resolvers';
import { TokenLoginComponent, TokenLoginModule } from './token-login';
import { ProcessIdGuard } from './guards/process-id.guard';
import {
ActivateProcessIdGuard,
ActivateProcessIdWithConfigKeyGuard,
} from './guards/activate-process-id.guard';
import { MatomoRouteData } from 'ngx-matomo-client';
import { tabResolverFn } from '@isa/core/tabs';
import { provideScrollPositionRestoration } from '@isa/utils/scroll-position';
const routes: Routes = [
{ path: '', redirectTo: 'kunde/dashboard', pathMatch: 'full' },
{
path: 'login',
children: [
{ path: ':token', component: TokenLoginComponent },
{ path: '**', redirectTo: 'kunde', pathMatch: 'full' },
],
},
{
path: '',
canActivate: [IsAuthenticatedGuard],
children: [
{
path: 'kunde',
component: MainComponent,
children: [
{
path: 'dashboard',
loadChildren: () =>
import('@page/dashboard').then((m) => m.DashboardModule),
data: {
matomo: {
title: 'Dashboard',
} as MatomoRouteData,
},
},
{
path: 'product',
loadChildren: () =>
import('@page/catalog').then((m) => m.PageCatalogModule),
canActivate: [CanActivateProductGuard],
},
{
path: ':processId/product',
loadChildren: () =>
import('@page/catalog').then((m) => m.PageCatalogModule),
canActivate: [CanActivateProductWithProcessIdGuard],
resolve: { processId: ProcessIdResolver },
},
{
path: 'order',
loadChildren: () =>
import('@page/customer-order').then((m) => m.CustomerOrderModule),
canActivate: [CanActivateCustomerOrdersGuard],
},
{
path: ':processId/order',
loadChildren: () =>
import('@page/customer-order').then((m) => m.CustomerOrderModule),
canActivate: [CanActivateCustomerOrdersWithProcessIdGuard],
resolve: { processId: ProcessIdResolver },
},
{
path: 'customer',
loadChildren: () =>
import('@page/customer').then((m) => m.CustomerModule),
canActivate: [CanActivateCustomerGuard],
},
{
path: ':processId/customer',
loadChildren: () =>
import('@page/customer').then((m) => m.CustomerModule),
canActivate: [CanActivateCustomerWithProcessIdGuard],
resolve: { processId: ProcessIdResolver },
},
{
path: 'cart',
loadChildren: () =>
import('@page/checkout').then((m) => m.PageCheckoutModule),
canActivate: [CanActivateCartGuard],
},
{
path: ':processId/cart',
loadChildren: () =>
import('@page/checkout').then((m) => m.PageCheckoutModule),
canActivate: [CanActivateCartWithProcessIdGuard],
},
{
path: 'pickup-shelf',
canActivate: [ProcessIdGuard],
// NOTE: This is a workaround for the canActivate guard not being called
loadChildren: () =>
import('@page/pickup-shelf').then((m) => m.PickupShelfOutModule),
},
{
path: ':processId/pickup-shelf',
canActivate: [ActivateProcessIdGuard],
loadChildren: () =>
import('@page/pickup-shelf').then((m) => m.PickupShelfOutModule),
},
{ path: '**', redirectTo: 'dashboard', pathMatch: 'full' },
],
resolve: { section: CustomerSectionResolver },
},
{
path: 'filiale',
component: MainComponent,
children: [
{
path: 'task-calendar',
loadChildren: () =>
import('@page/task-calendar').then(
(m) => m.PageTaskCalendarModule,
),
canActivate: [CanActivateTaskCalendarGuard],
},
{
path: 'pickup-shelf',
canActivate: [ActivateProcessIdWithConfigKeyGuard('pickupShelf')],
// NOTE: This is a workaround for the canActivate guard not being called
loadChildren: () =>
import('@page/pickup-shelf').then((m) => m.PickupShelfInModule),
},
{
path: 'goods/in',
loadChildren: () =>
import('@page/goods-in').then((m) => m.GoodsInModule),
canActivate: [CanActivateGoodsInGuard],
},
// {
// path: 'remission',
// loadChildren: () =>
// import('@page/remission').then((m) => m.PageRemissionModule),
// canActivate: [CanActivateRemissionGuard],
// },
{
path: 'package-inspection',
loadChildren: () =>
import('@page/package-inspection').then(
(m) => m.PackageInspectionModule,
),
canActivate: [CanActivatePackageInspectionGuard],
},
{
path: 'assortment',
loadChildren: () =>
import('@page/assortment').then((m) => m.AssortmentModule),
canActivate: [CanActivateAssortmentGuard],
},
{ path: '**', redirectTo: 'task-calendar', pathMatch: 'full' },
],
resolve: { section: BranchSectionResolver },
},
],
},
{
path: ':tabId',
component: MainComponent,
resolve: { process: tabResolverFn, tab: tabResolverFn },
canActivate: [IsAuthenticatedGuard],
children: [
{
path: 'return',
loadChildren: () =>
import('@isa/oms/feature/return-search').then((m) => m.routes),
},
{
path: 'remission',
children: [
{
path: 'return-receipt',
loadChildren: () =>
import(
'@isa/remission/feature/remission-return-receipt-list'
).then((m) => m.routes),
},
{
path: '',
loadChildren: () =>
import('@isa/remission/feature/remission-list').then(
(m) => m.routes,
),
},
],
},
],
},
];
if (isDevMode()) {
routes.unshift({
path: 'preview',
component: PreviewComponent,
});
}
@NgModule({
imports: [
RouterModule.forRoot(routes, { bindToComponentInputs: true }),
TokenLoginModule,
],
exports: [RouterModule],
providers: [provideScrollPositionRestoration()],
})
export class AppRoutingModule {}

View File

@@ -4,6 +4,7 @@ import {
withInterceptorsFromDi,
} from '@angular/common/http';
import {
DEFAULT_CURRENCY_CODE,
ErrorHandler,
Injector,
LOCALE_ID,
@@ -228,6 +229,10 @@ export function _notificationsHubOptionsFactory(
withRouteData(),
),
provideLogging(withLogLevel(LogLevel.Debug), withSink(ConsoleLogSink)),
{
provide: DEFAULT_CURRENCY_CODE,
useValue: 'EUR',
},
],
})
export class AppModule {}

View File

@@ -1,11 +1,16 @@
import { HttpErrorResponse } from '@angular/common/http';
import { ErrorHandler, Injectable } from '@angular/core';
import { AuthService } from '@core/auth';
import { DialogModel, UiDialogModalComponent, UiErrorModalComponent, UiModalService } from '@ui/modal';
import { IsaLogProvider } from './isa.log-provider';
import { LogLevel } from '@core/logger';
import { HttpErrorResponse } from "@angular/common/http";
import { ErrorHandler, Injectable } from "@angular/core";
import { AuthService } from "@core/auth";
import {
DialogModel,
UiDialogModalComponent,
UiErrorModalComponent,
UiModalService,
} from "@ui/modal";
import { IsaLogProvider } from "./isa.log-provider";
import { LogLevel } from "@core/logger";
@Injectable({ providedIn: 'root' })
@Injectable({ providedIn: "root" })
export class IsaErrorHandler implements ErrorHandler {
constructor(
private _modal: UiModalService,
@@ -17,7 +22,7 @@ export class IsaErrorHandler implements ErrorHandler {
console.error(error);
// Bei Klick auf Abbrechen auf der Login Seite erneut zur Login Seite weiterleiten
if (error?.type === 'token_error') {
if (error?.type === "token_error") {
this._authService.login();
return;
}
@@ -26,11 +31,14 @@ export class IsaErrorHandler implements ErrorHandler {
await this._modal
.open({
content: UiDialogModalComponent,
title: 'Sitzung abgelaufen',
title: "Sitzung abgelaufen",
data: {
handleCommand: false,
content: 'Sie waren zu lange nicht in der ISA aktiv. Bitte melden Sie sich erneut an',
actions: [{ command: 'CLOSE', selected: true, label: 'Erneut anmelden' }],
content:
"Sie waren zu lange nicht in der ISA aktiv. Bitte melden Sie sich erneut an",
actions: [
{ command: "CLOSE", selected: true, label: "Erneut anmelden" },
],
} as DialogModel,
})
.afterClosed$.toPromise();
@@ -39,7 +47,11 @@ export class IsaErrorHandler implements ErrorHandler {
return;
}
this._isaLogProvider.log(LogLevel.ERROR, 'Client Error', error);
try {
this._isaLogProvider.log(LogLevel.ERROR, "Client Error", error);
} catch (logError) {
console.error("Error logging to IsaLogProvider:", logError);
}
// this._modal.open({
// content: UiErrorModalComponent,

View File

@@ -1,28 +1,36 @@
import { Injectable, Injector } from '@angular/core';
import { LogLevel, LogProvider } from '@core/logger';
import { UserStateService } from '@generated/swagger/isa-api';
import { environment } from '../../environments/environment';
import { Injectable } from "@angular/core";
import { LogLevel, LogProvider } from "@core/logger";
import { UserStateService } from "@generated/swagger/isa-api";
import { environment } from "../../environments/environment";
@Injectable({ providedIn: 'root' })
@Injectable({ providedIn: "root" })
export class IsaLogProvider implements LogProvider {
static InfoService: UserStateService | undefined;
constructor() {}
log(logLevel: LogLevel, message: string, error: Error, ...optionalParams: any[]): void {
if (!environment.production && (logLevel === LogLevel.WARN || logLevel === LogLevel.ERROR)) {
IsaLogProvider.InfoService?.UserStateSaveLog({
logType: logLevel,
message: message,
content: JSON.stringify({
error: error?.name,
message: error?.message,
stack: error?.stack,
data: optionalParams,
}),
})
.toPromise()
.catch(() => {});
log(
logLevel: LogLevel,
message: string,
error: Error,
...optionalParams: any[]
): void {
try {
if (
!environment.production &&
(logLevel === LogLevel.WARN || logLevel === LogLevel.ERROR)
) {
IsaLogProvider.InfoService?.UserStateSaveLog({
logType: logLevel,
message: message,
content: JSON.stringify({
error: error?.name,
message: error?.message,
stack: error?.stack,
data: optionalParams,
}),
}).toPromise();
}
} catch (error) {
console.error("Error logging to InfoService:", error);
}
}
}

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

@@ -1,5 +1,4 @@
import { Injectable } from '@angular/core';
import { LogLevel } from './log-level';
import { LogLevel } from "./log-level";
export interface LogProvider {
log(logLevel: LogLevel, message: string, ...optionalParams: any[]): void;

View File

@@ -1,21 +1,22 @@
import { enableProdMode, isDevMode } from '@angular/core';
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
import { CONFIG_DATA } from '@isa/core/config';
import { setDefaultOptions } from 'date-fns';
import { de } from 'date-fns/locale';
import * as moment from 'moment';
import { enableProdMode, isDevMode } from "@angular/core";
import { platformBrowserDynamic } from "@angular/platform-browser-dynamic";
import { CONFIG_DATA } from "@isa/core/config";
import { setDefaultOptions } from "date-fns";
import { de } from "date-fns/locale";
import * as moment from "moment";
import "moment/locale/de";
setDefaultOptions({ locale: de });
moment.locale('de');
moment.locale("de");
import { AppModule } from './app/app.module';
import { AppModule } from "./app/app.module";
if (!isDevMode()) {
enableProdMode();
}
async function bootstrap() {
const configRes = await fetch('/config/config.json');
const configRes = await fetch("/config/config.json");
const config = await configRes.json();

View File

@@ -1,10 +1,18 @@
<div class="notification-list scroll-bar">
@for (notification of notifications; track notification) {
<modal-notifications-list-item [item]="notification" (itemSelected)="itemSelected($event)"></modal-notifications-list-item>
<modal-notifications-list-item
[item]="notification"
(itemSelected)="itemSelected($event)"
></modal-notifications-list-item>
<hr />
}
</div>
<div class="actions">
<a class="cta-primary" [routerLink]="['/filiale/remission/create']" (click)="navigated.emit()">Zur Remission</a>
<a
class="cta-primary"
[routerLink]="remissionPath()"
(click)="navigated.emit()"
>Zur Remission</a
>
</div>

View File

@@ -1,8 +1,17 @@
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output, inject } from '@angular/core';
import {
ChangeDetectionStrategy,
Component,
EventEmitter,
Input,
Output,
inject,
linkedSignal,
} from '@angular/core';
import { Router } from '@angular/router';
import { PickupShelfInNavigationService } from '@shared/services/navigation';
import { UiFilter } from '@ui/filter';
import { MessageBoardItemDTO } from '@hub/notifications';
import { TabService } from '@isa/core/tabs';
@Component({
selector: 'modal-notifications-remission-group',
@@ -11,7 +20,10 @@ import { MessageBoardItemDTO } from '@hub/notifications';
standalone: false,
})
export class ModalNotificationsRemissionGroupComponent {
private _pickupShelfInNavigationService = inject(PickupShelfInNavigationService);
tabService = inject(TabService);
private _pickupShelfInNavigationService = inject(
PickupShelfInNavigationService,
);
@Input()
notifications: MessageBoardItemDTO[];
@@ -19,11 +31,19 @@ export class ModalNotificationsRemissionGroupComponent {
@Output()
navigated = new EventEmitter<void>();
remissionPath = linkedSignal(() => [
'/',
this.tabService.activatedTab()?.id || this.tabService.nextId(),
'remission',
]);
constructor(private _router: Router) {}
itemSelected(item: MessageBoardItemDTO) {
const defaultNav = this._pickupShelfInNavigationService.listRoute();
const queryParams = UiFilter.getQueryParamsFromQueryTokenDTO(item.queryToken);
const queryParams = UiFilter.getQueryParamsFromQueryTokenDTO(
item.queryToken,
);
this._router.navigate(defaultNav.path, {
queryParams: {
...defaultNav.queryParams,

View File

@@ -16,13 +16,34 @@
[deltaEnd]="150"
[itemLength]="itemLength$ | async"
[containerHeight]="24.5"
>
@for (bueryNumberGroup of items$ | async | groupBy: byBuyerNumberFn; track bueryNumberGroup) {
>
@for (
bueryNumberGroup of items$ | async | groupBy: byBuyerNumberFn;
track bueryNumberGroup
) {
<shared-goods-in-out-order-group>
@for (orderNumberGroup of bueryNumberGroup.items | groupBy: byOrderNumberFn; track orderNumberGroup; let lastOrderNumber = $last) {
@for (processingStatusGroup of orderNumberGroup.items | groupBy: byProcessingStatusFn; track processingStatusGroup; let lastProcessingStatus = $last) {
@for (compartmentCodeGroup of processingStatusGroup.items | groupBy: byCompartmentCodeFn; track compartmentCodeGroup; let lastCompartmentCode = $last) {
@for (item of compartmentCodeGroup.items; track item; let firstItem = $first) {
@for (
orderNumberGroup of bueryNumberGroup.items | groupBy: byOrderNumberFn;
track orderNumberGroup;
let lastOrderNumber = $last
) {
@for (
processingStatusGroup of orderNumberGroup.items
| groupBy: byProcessingStatusFn;
track processingStatusGroup;
let lastProcessingStatus = $last
) {
@for (
compartmentCodeGroup of processingStatusGroup.items
| groupBy: byCompartmentCodeFn;
track compartmentCodeGroup;
let lastCompartmentCode = $last
) {
@for (
item of compartmentCodeGroup.items;
track item;
let firstItem = $first
) {
<shared-goods-in-out-order-group-item
[item]="item"
[showCompartmentCode]="firstItem"
@@ -49,7 +70,6 @@
<div class="empty-message">Es sind im Moment keine Artikel vorhanden</div>
}
<div class="actions">
@if (actions$ | async; as actions) {
@for (action of actions; track action) {
@@ -57,19 +77,27 @@
[disabled]="(changeActionLoader$ | async) || (loading$ | async)"
class="cta-action cta-action-primary"
(click)="handleAction(action)"
>
<ui-spinner
[show]="(changeActionLoader$ | async) || (loading$ | async)"
>{{ action.label }}</ui-spinner
>
<ui-spinner [show]="(changeActionLoader$ | async) || (loading$ | async)">{{ action.label }}</ui-spinner>
</button>
}
}
@if (listEmpty$ | async) {
<a class="cta-action cta-action-secondary" [routerLink]="['/filiale', 'goods', 'in']">
<a
class="cta-action cta-action-secondary"
[routerLink]="['/filiale', 'goods', 'in']"
>
Zur Bestellpostensuche
</a>
}
@if (listEmpty$ | async) {
<a class="cta-action cta-action-primary" [routerLink]="['/filiale', 'remission']">Zur Remission</a>
<a class="cta-action cta-action-primary" [routerLink]="remissionPath()"
>Zur Remission</a
>
}
</div>

View File

@@ -1,7 +1,18 @@
import { ChangeDetectionStrategy, Component, OnDestroy, OnInit, ViewChild, inject } from '@angular/core';
import {
ChangeDetectionStrategy,
Component,
OnDestroy,
OnInit,
ViewChild,
inject,
linkedSignal,
} from '@angular/core';
import { Router } from '@angular/router';
import { BreadcrumbService } from '@core/breadcrumb';
import { KeyValueDTOOfStringAndString, OrderItemListItemDTO } from '@generated/swagger/oms-api';
import {
KeyValueDTOOfStringAndString,
OrderItemListItemDTO,
} from '@generated/swagger/oms-api';
import { UiErrorModalComponent, UiModalService } from '@ui/modal';
import { UiScrollContainerComponent } from '@ui/scroll-container';
import { BehaviorSubject, combineLatest, Subject } from 'rxjs';
@@ -11,6 +22,7 @@ import { Config } from '@core/config';
import { ToasterService } from '@shared/shell';
import { PickupShelfInNavigationService } from '@shared/services/navigation';
import { CacheService } from '@core/cache';
import { TabService } from '@isa/core/tabs';
@Component({
selector: 'page-goods-in-remission-preview',
@@ -21,8 +33,12 @@ import { CacheService } from '@core/cache';
standalone: false,
})
export class GoodsInRemissionPreviewComponent implements OnInit, OnDestroy {
private _pickupShelfInNavigationService = inject(PickupShelfInNavigationService);
@ViewChild(UiScrollContainerComponent) scrollContainer: UiScrollContainerComponent;
tabService = inject(TabService);
private _pickupShelfInNavigationService = inject(
PickupShelfInNavigationService,
);
@ViewChild(UiScrollContainerComponent)
scrollContainer: UiScrollContainerComponent;
items$ = this._store.results$;
@@ -50,10 +66,18 @@ export class GoodsInRemissionPreviewComponent implements OnInit, OnDestroy {
byProcessingStatusFn = (item: OrderItemListItemDTO) => item.processingStatus;
byCompartmentCodeFn = (item: OrderItemListItemDTO) =>
item.compartmentInfo ? `${item.compartmentCode}_${item.compartmentInfo}` : item.compartmentCode;
item.compartmentInfo
? `${item.compartmentCode}_${item.compartmentInfo}`
: item.compartmentCode;
private readonly SCROLL_POSITION_TOKEN = 'REMISSION_PREVIEW_SCROLL_POSITION';
remissionPath = linkedSignal(() => [
'/',
this.tabService.activatedTab()?.id || this.tabService.nextId(),
'remission',
]);
constructor(
private _breadcrumb: BreadcrumbService,
private _store: GoodsInRemissionPreviewStore,
@@ -78,12 +102,18 @@ export class GoodsInRemissionPreviewComponent implements OnInit, OnDestroy {
}
private _removeScrollPositionFromCache(): void {
this._cache.delete({ processId: this._config.get('process.ids.goodsIn'), token: this.SCROLL_POSITION_TOKEN });
this._cache.delete({
processId: this._config.get('process.ids.goodsIn'),
token: this.SCROLL_POSITION_TOKEN,
});
}
private _addScrollPositionToCache(): void {
this._cache.set<number>(
{ processId: this._config.get('process.ids.goodsIn'), token: this.SCROLL_POSITION_TOKEN },
{
processId: this._config.get('process.ids.goodsIn'),
token: this.SCROLL_POSITION_TOKEN,
},
this.scrollContainer?.scrollPos,
);
}
@@ -108,7 +138,10 @@ export class GoodsInRemissionPreviewComponent implements OnInit, OnDestroy {
async updateBreadcrumb() {
const crumbs = await this._breadcrumb
.getBreadcrumbsByKeyAndTags$(this._config.get('process.ids.goodsIn'), ['goods-in', 'preview'])
.getBreadcrumbsByKeyAndTags$(this._config.get('process.ids.goodsIn'), [
'goods-in',
'preview',
])
.pipe(first())
.toPromise();
for (const crumb of crumbs) {
@@ -120,12 +153,15 @@ export class GoodsInRemissionPreviewComponent implements OnInit, OnDestroy {
async removeBreadcrumbs() {
let breadcrumbsToDelete = await this._breadcrumb
.getBreadcrumbsByKeyAndTags$(this._config.get('process.ids.goodsIn'), ['goods-in'])
.getBreadcrumbsByKeyAndTags$(this._config.get('process.ids.goodsIn'), [
'goods-in',
])
.pipe(first())
.toPromise();
breadcrumbsToDelete = breadcrumbsToDelete.filter(
(crumb) => !crumb.tags.includes('preview') && !crumb.tags.includes('main'),
(crumb) =>
!crumb.tags.includes('preview') && !crumb.tags.includes('main'),
);
breadcrumbsToDelete.forEach((crumb) => {
@@ -133,11 +169,17 @@ export class GoodsInRemissionPreviewComponent implements OnInit, OnDestroy {
});
const detailsCrumbs = await this._breadcrumb
.getBreadcrumbsByKeyAndTags$(this._config.get('process.ids.goodsIn'), ['goods-in', 'details'])
.getBreadcrumbsByKeyAndTags$(this._config.get('process.ids.goodsIn'), [
'goods-in',
'details',
])
.pipe(first())
.toPromise();
const editCrumbs = await this._breadcrumb
.getBreadcrumbsByKeyAndTags$(this._config.get('process.ids.goodsIn'), ['goods-in', 'edit'])
.getBreadcrumbsByKeyAndTags$(this._config.get('process.ids.goodsIn'), [
'goods-in',
'edit',
])
.pipe(first())
.toPromise();
@@ -152,32 +194,44 @@ export class GoodsInRemissionPreviewComponent implements OnInit, OnDestroy {
initInitialSearch() {
if (this._store.hits === 0) {
this._store.searchResult$.pipe(takeUntil(this._onDestroy$)).subscribe(async (result) => {
await this.createBreadcrumb();
this._store.searchResult$
.pipe(takeUntil(this._onDestroy$))
.subscribe(async (result) => {
await this.createBreadcrumb();
this.scrollContainer?.scrollTo((await this._getScrollPositionFromCache()) ?? 0);
this._removeScrollPositionFromCache();
});
this.scrollContainer?.scrollTo(
(await this._getScrollPositionFromCache()) ?? 0,
);
this._removeScrollPositionFromCache();
});
}
this._store.search();
}
async navigateToRemission() {
await this._router.navigate(['/filiale/remission']);
await this._router.navigate(this.remissionPath());
}
navigateToDetails(orderItem: OrderItemListItemDTO) {
const nav = this._pickupShelfInNavigationService.detailRoute({ item: orderItem, side: false });
const nav = this._pickupShelfInNavigationService.detailRoute({
item: orderItem,
side: false,
});
this._router.navigate(nav.path, { queryParams: { ...nav.queryParams, view: 'remission' } });
this._router.navigate(nav.path, {
queryParams: { ...nav.queryParams, view: 'remission' },
});
}
async handleAction(action: KeyValueDTOOfStringAndString) {
this.changeActionLoader$.next(true);
try {
const response = await this._store.createRemissionFromPreview().pipe(first()).toPromise();
const response = await this._store
.createRemissionFromPreview()
.pipe(first())
.toPromise();
if (!response?.dialog) {
this._toast.open({

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

@@ -1,303 +1,338 @@
<div class="side-menu-group">
<span class="side-menu-group-label">Kunden</span>
<nav class="side-menu-group-nav">
<a
class="side-menu-group-item"
(click)="closeSideMenu(); resetBranch(); focusSearchBox()"
[routerLink]="productRoutePath$ | async"
sharedRegexRouterLinkActive="active"
sharedRegexRouterLinkActiveTest="^\/kunde\/\d*\/product"
(isActiveChange)="focusSearchBox()"
>
<div class="side-menu-group-item-icon">
<shared-icon icon="import-contacts"></shared-icon>
</div>
<span class="side-menu-group-item-label">Artikelsuche</span>
</a>
<div class="side-menu-group-sub-item-wrapper">
@if (customerSearchRoute$ | async; as customerSearchRoute) {
<a
class="side-menu-group-item"
(click)="closeSideMenu(); focusSearchBox()"
[routerLink]="customerSearchRoute.path"
[queryParams]="customerSearchRoute.queryParams"
sharedRegexRouterLinkActive="active"
sharedRegexRouterLinkActiveTest="^\/kunde\/\d*\/customer"
(isActiveChange)="customerActive($event); focusSearchBox()"
>
<span class="side-menu-group-item-icon">
<shared-icon icon="person"></shared-icon>
</span>
<span class="side-menu-group-item-label">Kunden</span>
<button
class="side-menu-group-arrow"
[class.side-menu-item-rotate]="customerExpanded"
(click)="
$event.stopPropagation();
$event.preventDefault();
customerExpanded = !customerExpanded
"
>
<shared-icon icon="keyboard-arrow-down"></shared-icon>
</button>
</a>
}
<div class="side-menu-group-sub-items" [class.hidden]="!customerExpanded">
@if (customerSearchRoute$ | async; as customerSearchRoute) {
<a
class="side-menu-group-item"
(click)="closeSideMenu(); focusSearchBox()"
[routerLink]="customerSearchRoute.path"
[queryParams]="customerSearchRoute.queryParams"
sharedRegexRouterLinkActive="active"
sharedRegexRouterLinkActiveTest="^\/kunde\/\d*\/customer\/(\(search|search)"
(isActiveChange)="focusSearchBox()"
>
<span class="side-menu-group-item-icon"></span>
<span class="side-menu-group-item-label">Suchen</span>
</a>
}
@if (customerCreateRoute$ | async; as customerCreateRoute) {
<a
class="side-menu-group-item"
(click)="closeSideMenu()"
[routerLink]="customerCreateRoute.path"
[queryParams]="customerCreateRoute.queryParams"
sharedRegexRouterLinkActive="active"
sharedRegexRouterLinkActiveTest="^\/kunde\/\d*\/customer\/(\(create|create)"
>
<span class="side-menu-group-item-icon"></span>
<span class="side-menu-group-item-label">Erfassen</span>
</a>
}
</div>
</div>
<a
*ifRole="'Store'"
class="side-menu-group-item"
(click)="closeSideMenu(); focusSearchBox()"
[routerLink]="pickUpShelfOutRoutePath$ | async"
sharedRegexRouterLinkActive="active"
sharedRegexRouterLinkActiveTest="^\/kunde\/\d*\/pickup-shelf"
(isActiveChange)="focusSearchBox()"
>
<span class="side-menu-group-item-icon">
<shared-icon icon="unarchive"></shared-icon>
</span>
<span class="side-menu-group-item-label">Warenausgabe</span>
</a>
<a
*ifRole="'Store'"
class="side-menu-group-item"
(click)="closeSideMenu(); focusSearchBox()"
[routerLink]="[
'/',
processService.activatedTab()?.id || processService.nextId(),
'return',
]"
(isActiveChange)="focusSearchBox()"
>
<span class="side-menu-group-item-icon w-[2.375rem] h-12">
<ng-icon name="isaNavigationReturn"></ng-icon>
</span>
<span class="side-menu-group-item-label">Retoure</span>
</a>
<a
*ifRole="'CallCenter'"
class="side-menu-group-item"
(click)="closeSideMenu(); resetBranch(); focusSearchBox()"
[routerLink]="customerOrdersRoutePath$ | async"
sharedRegexRouterLinkActive="active"
sharedRegexRouterLinkActiveTest="^\/kunde\/\d*\/order"
(isActiveChange)="focusSearchBox()"
>
<span class="side-menu-group-item-icon">
<shared-icon icon="deployed-code"></shared-icon>
</span>
<span class="side-menu-group-item-label">Bestellungen</span>
</a>
</nav>
</div>
<div class="side-menu-group" *ifRole="'Store'">
<span class="side-menu-group-label">Filiale</span>
<nav class="side-menu-group-nav">
@if (taskCalenderNavigation$ | async; as taskCalenderNavigation) {
<a
class="side-menu-group-item"
(click)="closeSideMenu(); focusSearchBox()"
[routerLink]="taskCalenderNavigation.path"
[queryParams]="taskCalenderNavigation.queryParams"
routerLinkActive="active"
(isActiveChange)="focusSearchBox()"
>
<span class="side-menu-group-item-icon">
<shared-icon icon="event-available"></shared-icon>
</span>
<span class="side-menu-group-item-label">Kalender</span>
</a>
}
@if (assortmentNavigation$ | async; as assortmentNavigation) {
<a
class="side-menu-group-item"
(click)="closeSideMenu()"
[routerLink]="assortmentNavigation.path"
[queryParams]="assortmentNavigation.queryParams"
routerLinkActive="active"
>
<span class="side-menu-group-item-icon">
<shared-icon icon="shape-outline"></shared-icon>
</span>
<span class="side-menu-group-item-label">Sortiment</span>
</a>
}
<div class="side-menu-group-sub-item-wrapper">
@if (pickUpShelfInRoutePath$ | async; as pickUpShelfInNavigation) {
<a
class="side-menu-group-item"
(click)="closeSideMenu(); focusSearchBox()"
[routerLink]="pickUpShelfInNavigation.path"
[queryParams]="pickUpShelfInNavigation.queryParams"
sharedRegexRouterLinkActive="active"
sharedRegexRouterLinkActiveTest="^\/filiale\/(pickup-shelf|goods\/in)"
(isActiveChange)="shelfActive($event); focusSearchBox()"
>
<span class="side-menu-group-item-icon">
<shared-icon icon="isa-abholfach"></shared-icon>
</span>
<span class="side-menu-group-item-label">Abholfach</span>
<button
class="side-menu-group-arrow"
[class.side-menu-item-rotate]="shelfExpanded"
(click)="
$event.stopPropagation();
$event.preventDefault();
shelfExpanded = !shelfExpanded
"
>
<shared-icon icon="keyboard-arrow-down"></shared-icon>
</button>
</a>
}
<div class="side-menu-group-sub-items" [class.hidden]="!shelfExpanded">
@if (pickUpShelfInRoutePath$ | async; as pickUpShelfInListNavigation) {
<a
class="side-menu-group-item"
(click)="closeSideMenu(); focusSearchBox()"
[routerLink]="pickUpShelfInListNavigation.path"
[queryParams]="pickUpShelfInListNavigation.queryParams"
[class.has-child-view]="currentShelfView$ | async"
sharedRegexRouterLinkActive="active"
[sharedRegexRouterLinkActiveTest]="'^\/filiale\/pickup-shelf'"
(isActiveChange)="shelfActive($event); focusSearchBox()"
>
<span class="side-menu-group-item-icon"></span>
<span class="side-menu-group-item-label">Einbuchen</span>
</a>
}
<a
class="side-menu-group-item"
(click)="closeSideMenu()"
[routerLink]="['/filiale', 'goods', 'in', 'reservation']"
[queryParams]="{ view: 'reservation' }"
[class.active-child]="(currentShelfView$ | async) === 'reservation'"
routerLinkActive="active"
(isActiveChange)="shelfActive($event)"
>
<span class="side-menu-group-item-icon"></span>
<span class="side-menu-group-item-label">Reservierung</span>
</a>
<a
class="side-menu-group-item"
(click)="closeSideMenu()"
[routerLink]="['/filiale', 'goods', 'in', 'cleanup']"
[queryParams]="{ view: 'cleanup' }"
[class.active-child]="(currentShelfView$ | async) === 'cleanup'"
routerLinkActive="active"
(isActiveChange)="shelfActive($event)"
>
<span class="side-menu-group-item-icon"></span>
<span class="side-menu-group-item-label">Ausräumen</span>
</a>
<a
class="side-menu-group-item"
(click)="closeSideMenu()"
[routerLink]="['/filiale', 'goods', 'in', 'preview']"
[queryParams]="{ view: 'remission' }"
[class.active-child]="(currentShelfView$ | async) === 'remission'"
routerLinkActive="active"
(isActiveChange)="shelfActive($event)"
>
<span class="side-menu-group-item-icon"></span>
<span class="side-menu-group-item-label">Remi-Vorschau</span>
</a>
<a
class="side-menu-group-item"
(click)="closeSideMenu()"
[routerLink]="['/filiale', 'goods', 'in', 'list']"
[queryParams]="{ view: 'wareneingangsliste' }"
[class.active-child]="
(currentShelfView$ | async) === 'wareneingangsliste'
"
routerLinkActive="active"
(isActiveChange)="shelfActive($event)"
>
<span class="side-menu-group-item-icon"></span>
<span class="side-menu-group-item-label">Fehlende</span>
</a>
</div>
</div>
@if (remissionNavigation$ | async; as remissionNavigation) {
<a
class="side-menu-group-item"
(click)="closeSideMenu()"
[routerLink]="remissionNavigation.path"
[queryParams]="remissionNavigation.queryParams"
routerLinkActive="active"
>
<span class="side-menu-group-item-icon">
<shared-icon icon="assignment-return"></shared-icon>
</span>
<span class="side-menu-group-item-label">Remission</span>
</a>
}
@if (packageInspectionNavigation$ | async; as packageInspectionNavigation) {
<a
class="side-menu-group-item"
(click)="closeSideMenu(); fetchAndOpenPackages()"
[routerLink]="packageInspectionNavigation.path"
[queryParams]="packageInspectionNavigation.queryParams"
routerLinkActive="active"
>
<span class="side-menu-group-item-icon">
<shared-icon icon="clipboard-check-outline"></shared-icon>
</span>
<span class="side-menu-group-item-label">Wareneingang</span>
</a>
}
<a
class="side-menu-group-item"
(click)="closeSideMenu(); focusSearchBox()"
[routerLink]="[
'/',
processService.activatedTab()?.id || processService.nextId(),
'remission',
]"
(isActiveChange)="focusSearchBox()"
>
<span class="side-menu-group-item-icon w-[2.375rem] h-12">
<ng-icon name="isaNavigationRemission2"></ng-icon>
</span>
<span class="side-menu-group-item-label">Remission</span>
</a>
</nav>
</div>
<div class="side-menu-group">
<span class="side-menu-group-label">Kunden</span>
<nav class="side-menu-group-nav">
<a
class="side-menu-group-item"
(click)="closeSideMenu(); resetBranch(); focusSearchBox()"
[routerLink]="productRoutePath$ | async"
sharedRegexRouterLinkActive="active"
sharedRegexRouterLinkActiveTest="^\/kunde\/\d*\/product"
(isActiveChange)="focusSearchBox()"
>
<div class="side-menu-group-item-icon">
<shared-icon icon="import-contacts"></shared-icon>
</div>
<span class="side-menu-group-item-label">Artikelsuche</span>
</a>
<div class="side-menu-group-sub-item-wrapper">
@if (customerSearchRoute$ | async; as customerSearchRoute) {
<a
class="side-menu-group-item"
(click)="closeSideMenu(); focusSearchBox()"
[routerLink]="customerSearchRoute.path"
[queryParams]="customerSearchRoute.queryParams"
sharedRegexRouterLinkActive="active"
sharedRegexRouterLinkActiveTest="^\/kunde\/\d*\/customer"
(isActiveChange)="customerActive($event); focusSearchBox()"
>
<span class="side-menu-group-item-icon">
<shared-icon icon="person"></shared-icon>
</span>
<span class="side-menu-group-item-label">Kunden</span>
<button
class="side-menu-group-arrow"
[class.side-menu-item-rotate]="customerExpanded"
(click)="
$event.stopPropagation();
$event.preventDefault();
customerExpanded = !customerExpanded
"
>
<shared-icon icon="keyboard-arrow-down"></shared-icon>
</button>
</a>
}
<div class="side-menu-group-sub-items" [class.hidden]="!customerExpanded">
@if (customerSearchRoute$ | async; as customerSearchRoute) {
<a
class="side-menu-group-item"
(click)="closeSideMenu(); focusSearchBox()"
[routerLink]="customerSearchRoute.path"
[queryParams]="customerSearchRoute.queryParams"
sharedRegexRouterLinkActive="active"
sharedRegexRouterLinkActiveTest="^\/kunde\/\d*\/customer\/(\(search|search)"
(isActiveChange)="focusSearchBox()"
>
<span class="side-menu-group-item-icon"></span>
<span class="side-menu-group-item-label">Suchen</span>
</a>
}
@if (customerCreateRoute$ | async; as customerCreateRoute) {
<a
class="side-menu-group-item"
(click)="closeSideMenu()"
[routerLink]="customerCreateRoute.path"
[queryParams]="customerCreateRoute.queryParams"
sharedRegexRouterLinkActive="active"
sharedRegexRouterLinkActiveTest="^\/kunde\/\d*\/customer\/(\(create|create)"
>
<span class="side-menu-group-item-icon"></span>
<span class="side-menu-group-item-label">Erfassen</span>
</a>
}
</div>
</div>
<a
*ifRole="'Store'"
class="side-menu-group-item"
(click)="closeSideMenu(); focusSearchBox()"
[routerLink]="pickUpShelfOutRoutePath$ | async"
sharedRegexRouterLinkActive="active"
sharedRegexRouterLinkActiveTest="^\/kunde\/\d*\/pickup-shelf"
(isActiveChange)="focusSearchBox()"
>
<span class="side-menu-group-item-icon">
<shared-icon icon="unarchive"></shared-icon>
</span>
<span class="side-menu-group-item-label">Warenausgabe</span>
</a>
<a
*ifRole="'Store'"
class="side-menu-group-item"
(click)="closeSideMenu(); focusSearchBox()"
[routerLink]="[
'/',
processService.activatedTab()?.id || processService.nextId(),
'return',
]"
(isActiveChange)="focusSearchBox()"
>
<span class="side-menu-group-item-icon w-[2.375rem] h-12">
<ng-icon name="isaNavigationReturn"></ng-icon>
</span>
<span class="side-menu-group-item-label">Retoure</span>
</a>
<a
*ifRole="'CallCenter'"
class="side-menu-group-item"
(click)="closeSideMenu(); resetBranch(); focusSearchBox()"
[routerLink]="customerOrdersRoutePath$ | async"
sharedRegexRouterLinkActive="active"
sharedRegexRouterLinkActiveTest="^\/kunde\/\d*\/order"
(isActiveChange)="focusSearchBox()"
>
<span class="side-menu-group-item-icon">
<shared-icon icon="deployed-code"></shared-icon>
</span>
<span class="side-menu-group-item-label">Bestellungen</span>
</a>
</nav>
</div>
<div class="side-menu-group" *ifRole="'Store'">
<span class="side-menu-group-label">Filiale</span>
<nav class="side-menu-group-nav">
@if (taskCalenderNavigation$ | async; as taskCalenderNavigation) {
<a
class="side-menu-group-item"
(click)="closeSideMenu(); focusSearchBox()"
[routerLink]="taskCalenderNavigation.path"
[queryParams]="taskCalenderNavigation.queryParams"
routerLinkActive="active"
(isActiveChange)="focusSearchBox()"
>
<span class="side-menu-group-item-icon">
<shared-icon icon="event-available"></shared-icon>
</span>
<span class="side-menu-group-item-label">Kalender</span>
</a>
}
@if (assortmentNavigation$ | async; as assortmentNavigation) {
<a
class="side-menu-group-item"
(click)="closeSideMenu()"
[routerLink]="assortmentNavigation.path"
[queryParams]="assortmentNavigation.queryParams"
routerLinkActive="active"
>
<span class="side-menu-group-item-icon">
<shared-icon icon="shape-outline"></shared-icon>
</span>
<span class="side-menu-group-item-label">Sortiment</span>
</a>
}
<div class="side-menu-group-sub-item-wrapper">
@if (pickUpShelfInRoutePath$ | async; as pickUpShelfInNavigation) {
<a
class="side-menu-group-item"
(click)="closeSideMenu(); focusSearchBox()"
[routerLink]="pickUpShelfInNavigation.path"
[queryParams]="pickUpShelfInNavigation.queryParams"
sharedRegexRouterLinkActive="active"
sharedRegexRouterLinkActiveTest="^\/filiale\/(pickup-shelf|goods\/in)"
(isActiveChange)="shelfActive($event); focusSearchBox()"
>
<span class="side-menu-group-item-icon">
<shared-icon icon="isa-abholfach"></shared-icon>
</span>
<span class="side-menu-group-item-label">Abholfach</span>
<button
class="side-menu-group-arrow"
[class.side-menu-item-rotate]="shelfExpanded"
(click)="
$event.stopPropagation();
$event.preventDefault();
shelfExpanded = !shelfExpanded
"
>
<shared-icon icon="keyboard-arrow-down"></shared-icon>
</button>
</a>
}
<div class="side-menu-group-sub-items" [class.hidden]="!shelfExpanded">
@if (pickUpShelfInRoutePath$ | async; as pickUpShelfInListNavigation) {
<a
class="side-menu-group-item"
(click)="closeSideMenu(); focusSearchBox()"
[routerLink]="pickUpShelfInListNavigation.path"
[queryParams]="pickUpShelfInListNavigation.queryParams"
[class.has-child-view]="currentShelfView$ | async"
sharedRegexRouterLinkActive="active"
[sharedRegexRouterLinkActiveTest]="'^\/filiale\/pickup-shelf'"
(isActiveChange)="shelfActive($event); focusSearchBox()"
>
<span class="side-menu-group-item-icon"></span>
<span class="side-menu-group-item-label">Einbuchen</span>
</a>
}
<a
class="side-menu-group-item"
(click)="closeSideMenu()"
[routerLink]="['/filiale', 'goods', 'in', 'reservation']"
[queryParams]="{ view: 'reservation' }"
[class.active-child]="(currentShelfView$ | async) === 'reservation'"
routerLinkActive="active"
(isActiveChange)="shelfActive($event)"
>
<span class="side-menu-group-item-icon"></span>
<span class="side-menu-group-item-label">Reservierung</span>
</a>
<a
class="side-menu-group-item"
(click)="closeSideMenu()"
[routerLink]="['/filiale', 'goods', 'in', 'cleanup']"
[queryParams]="{ view: 'cleanup' }"
[class.active-child]="(currentShelfView$ | async) === 'cleanup'"
routerLinkActive="active"
(isActiveChange)="shelfActive($event)"
>
<span class="side-menu-group-item-icon"></span>
<span class="side-menu-group-item-label">Ausräumen</span>
</a>
<a
class="side-menu-group-item"
(click)="closeSideMenu()"
[routerLink]="['/filiale', 'goods', 'in', 'preview']"
[queryParams]="{ view: 'remission' }"
[class.active-child]="(currentShelfView$ | async) === 'remission'"
routerLinkActive="active"
(isActiveChange)="shelfActive($event)"
>
<span class="side-menu-group-item-icon"></span>
<span class="side-menu-group-item-label">Remi-Vorschau</span>
</a>
<a
class="side-menu-group-item"
(click)="closeSideMenu()"
[routerLink]="['/filiale', 'goods', 'in', 'list']"
[queryParams]="{ view: 'wareneingangsliste' }"
[class.active-child]="
(currentShelfView$ | async) === 'wareneingangsliste'
"
routerLinkActive="active"
(isActiveChange)="shelfActive($event)"
>
<span class="side-menu-group-item-icon"></span>
<span class="side-menu-group-item-label">Fehlende</span>
</a>
</div>
</div>
<div class="side-menu-group-sub-item-wrapper">
<a
class="side-menu-group-item"
(click)="closeSideMenu(); focusSearchBox()"
[routerLink]="[
'/',
processService.activatedTab()?.id || processService.nextId(),
'remission',
]"
(isActiveChange)="focusSearchBox(); remissionExpanded.set($event)"
routerLinkActive="active"
#rlActive="routerLinkActive"
>
<span class="side-menu-group-item-icon w-[2.375rem] h-12">
<ng-icon name="isaNavigationRemission2" size="1.5rem"></ng-icon>
</span>
<span class="side-menu-group-item-label">Remission</span>
<button
class="side-menu-group-arrow"
[class.side-menu-item-rotate]="remissionExpanded()"
(click)="
$event.stopPropagation();
$event.preventDefault();
remissionExpanded.set(!remissionExpanded())
"
>
<shared-icon icon="keyboard-arrow-down"></shared-icon>
</button>
</a>
@if (remissionExpanded()) {
<div class="side-menu-group-sub-items">
<a
class="side-menu-group-item"
(click)="closeSideMenu(); focusSearchBox()"
[routerLink]="[
'/',
processService.activatedTab()?.id || processService.nextId(),
'remission',
]"
(isActiveChange)="focusSearchBox()"
sharedRegexRouterLinkActive="active"
sharedRegexRouterLinkActiveTest="^\/\d*\/remission\/(mandatory|department)"
>
<span class="side-menu-group-item-icon"> </span>
<span class="side-menu-group-item-label">Remission</span>
</a>
<a
class="side-menu-group-item"
(click)="closeSideMenu(); focusSearchBox()"
[routerLink]="[
'/',
processService.activatedTab()?.id || processService.nextId(),
'remission',
'return-receipt',
]"
(isActiveChange)="focusSearchBox()"
sharedRegexRouterLinkActive="active"
sharedRegexRouterLinkActiveTest="^\/\d*\/remission\/return-receipt"
>
<span class="side-menu-group-item-icon"> </span>
<span class="side-menu-group-item-label">Warenbegleitscheine</span>
</a>
</div>
}
</div>
@if (packageInspectionNavigation$ | async; as packageInspectionNavigation) {
<a
class="side-menu-group-item"
(click)="closeSideMenu(); fetchAndOpenPackages()"
[routerLink]="packageInspectionNavigation.path"
[queryParams]="packageInspectionNavigation.queryParams"
routerLinkActive="active"
>
<span class="side-menu-group-item-icon">
<shared-icon icon="clipboard-check-outline"></shared-icon>
</span>
<span class="side-menu-group-item-label">Wareneingang</span>
</a>
}
</nav>
</div>

View File

@@ -1,435 +1,426 @@
import {
Component,
ChangeDetectionStrategy,
Inject,
ChangeDetectorRef,
inject,
DOCUMENT,
} from '@angular/core';
import { AuthModule, AuthService } from '@core/auth';
import { StockService } from '@generated/swagger/wws-api';
import { first, map, retry, switchMap, take } from 'rxjs/operators';
import { ShellService } from '../shell.service';
import { ApplicationProcess, ApplicationService } from '@core/application';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { EnvironmentService } from '@core/environment';
import { CommonModule } from '@angular/common';
import { Config } from '@core/config';
import { BreadcrumbService } from '@core/breadcrumb';
import { IconComponent } from '@shared/components/icon';
import { RegexRouterLinkActiveDirective } from '@shared/directives/router-link-active';
import { WrongDestinationModalService } from '@modal/wrong-destination';
import {
CustomerCreateNavigation,
CustomerOrdersNavigationService,
CustomerSearchNavigation,
PickupShelfInNavigationService,
PickUpShelfOutNavigationService,
ProductCatalogNavigationService,
} from '@shared/services/navigation';
import { TabService } from '@isa/core/tabs';
import { NgIconComponent, provideIcons } from '@ng-icons/core';
import { isaNavigationRemission2, isaNavigationReturn } from '@isa/icons';
@Component({
selector: 'shell-side-menu',
templateUrl: 'side-menu.component.html',
styleUrls: ['side-menu.component.css'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
CommonModule,
NgIconComponent,
IconComponent,
RouterModule,
AuthModule,
RegexRouterLinkActiveDirective,
],
providers: [provideIcons({ isaNavigationReturn, isaNavigationRemission2 })],
})
export class ShellSideMenuComponent {
processService = inject(TabService);
branchKey$ = this._stockService.StockCurrentBranch().pipe(
retry(3),
map((x) => x.result.key),
);
section$ = this._app.getSection$();
processes$ = this.section$.pipe(
switchMap((section) => this._app.getProcesses$(section)),
);
processesCount$ = this.processes$.pipe(
map((processes) => processes?.length ?? 0),
);
activeProcess$ = this._app.activatedProcessId$.pipe(
switchMap((processId) => this._app.getProcessById$(processId)),
);
get isTablet() {
return this._environment.matchTablet();
}
customerBasePath$ = this.activeProcess$.pipe(
map((process) => {
if (
!!process &&
process.section === 'customer' &&
process.type !== 'cart-checkout'
) {
// Übernehme aktiven Prozess
return `/kunde/${process.id}`;
} else {
// Über Guards wird ein neuer Prozess erstellt
return '/kunde';
}
}),
);
customerSearchRoute$ = this.getLastActivatedCustomerProcessId$().pipe(
map((processId) => {
return this._customerSearchNavigation.defaultRoute({ processId });
}),
);
customerCreateRoute$ = this.getLastActivatedCustomerProcessId$().pipe(
map((processId) => {
return this._customerCreateNavigation.defaultRoute({ processId });
}),
);
pickUpShelfOutRoutePath$ = this.getLastActivatedCustomerProcessId$().pipe(
map((processId) => {
if (processId) {
// Übernehme aktiven Prozess
return this._pickUpShelfOutNavigation.defaultRoute({ processId }).path;
} else {
// Über Guards wird ein neuer Prozess erstellt
return this._pickUpShelfOutNavigation.defaultRoute({}).path;
}
}),
);
productRoutePath$ = this.getLastActivatedCustomerProcessId$().pipe(
map((processId) => {
if (processId) {
// Übernehme aktiven Prozess
return this._catalogNavigationService.getArticleSearchBasePath(
processId,
).path;
} else {
// Über Guards wird ein neuer Prozess erstellt
return this._catalogNavigationService.getArticleSearchBasePath().path;
}
}),
);
customerOrdersRoutePath$ = this.getLastActivatedCustomerProcessId$().pipe(
map((processId) => {
if (processId) {
// Übernehme aktiven Prozess
return this._customerOrdersNavigationService.getCustomerOrdersBasePath(
processId,
).path;
} else {
// Über Guards wird ein neuer Prozess erstellt
return this._customerOrdersNavigationService.getCustomerOrdersBasePath()
.path;
}
}),
);
taskCalenderNavigation$ = this.getLastNavigationByProcessId(
this._config.get('process.ids.taskCalendar'),
{
path: ['/filiale', 'task-calendar'],
queryParams: {},
},
'/filiale/task-calendar',
);
assortmentNavigation$ = this.getLastNavigationByProcessId(
this._config.get('process.ids.assortment'),
{
path: ['/filiale', 'assortment'],
queryParams: {},
},
);
pickUpShelfInRoutePath$ = this.getLastNavigationByProcessId(
this._config.get('process.ids.pickupShelf'),
this._pickUpShelfInNavigation.defaultRoute(),
'/filiale/pickup-shelf',
);
// #4478 - RD // Abholfach - Routing löst Suche aus
// pickUpShelfInListRoutePath$ = this.getLastNavigationByProcessId(
// this._config.get('process.ids.pickupShelf'),
// this._pickUpShelfInNavigation.listRoute()
// );
remissionNavigation$ = this.getLastNavigationByProcessId(
this._config.get('process.ids.remission'),
{
path: ['/filiale', 'remission'],
queryParams: {},
},
);
packageInspectionNavigation$ = this.getLastNavigationByProcessId(
this._config.get('process.ids.packageInspection'),
{
path: ['/filiale', 'package-inspection'],
queryParams: {},
},
);
get currentShelfView$() {
return this._route.queryParams.pipe(map((params) => params.view));
}
shelfExpanded = false;
customerExpanded = false;
constructor(
private _shellService: ShellService,
private _authService: AuthService,
private _stockService: StockService,
private _app: ApplicationService,
private _router: Router,
private _route: ActivatedRoute,
private readonly _wrongDestinationModalService: WrongDestinationModalService,
private _environment: EnvironmentService,
private _catalogNavigationService: ProductCatalogNavigationService,
private _customerOrdersNavigationService: CustomerOrdersNavigationService,
private _config: Config,
private _breadcrumbService: BreadcrumbService,
private _customerSearchNavigation: CustomerSearchNavigation,
private _customerCreateNavigation: CustomerCreateNavigation,
private _pickUpShelfOutNavigation: PickUpShelfOutNavigationService,
private _pickUpShelfInNavigation: PickupShelfInNavigationService,
private _cdr: ChangeDetectorRef,
@Inject(DOCUMENT) private readonly _document: Document,
) {}
customerActive(isActive: boolean) {
if (isActive) {
this.expandCustomer();
}
}
shelfActive(isActive: boolean) {
if (isActive) {
this.expandShelf();
}
}
expandCustomer() {
this.customerExpanded = true;
this._cdr.markForCheck();
}
expandShelf() {
this.shelfExpanded = true;
this._cdr.markForCheck();
}
getLastNavigationByProcessId(
id: number,
fallback?: { path: string[]; queryParams: unknown },
pathContainsString?: string,
) {
return this._breadcrumbService.getBreadcrumbByKey$(id)?.pipe(
map((breadcrumbs) => {
const lastCrumb = breadcrumbs
.filter((breadcrumb) => {
/**
* #4532 - Der optionale Filter wurde hinzugefügt Breadcrumbs mit fehlerhaften Pfad auszuschließen.
* Dieser Filter kann entfernt werden, sobald die Breadcrumbs korrekt gesetzt werden. Jedoch konnte man bisher nicht feststellen,
* woher die fehlerhaften Breadcrumbs kommen.
*/
if (!pathContainsString) {
// Wenn kein Filter gesetzt ist, dann wird der letzte Breadcrumb zurückgegeben
return true;
}
const pathStr = Array.isArray(breadcrumb.path)
? breadcrumb.path.join('/')
: breadcrumb.path;
return pathStr.includes(pathContainsString);
})
// eslint-disable-next-line no-prototype-builtins
.filter((breadcrumb) => !breadcrumb?.params?.hasOwnProperty('view'))
.filter((breadcrumb) => !breadcrumb?.tags?.includes('reservation'))
.filter((breadcrumb) => !breadcrumb?.tags?.includes('cleanup'))
.filter(
(breadcrumb) => !breadcrumb?.tags?.includes('wareneingangsliste'),
)
.filter((breadcrumb) => !breadcrumb?.tags?.includes('preview'))
.reduce((last, current) => {
if (!last) return current;
if (last.changed > current.changed) {
return last;
} else {
return current;
}
}, undefined);
if (!lastCrumb) {
return fallback;
}
// #4692 Return Fallback if Values contain undefined or null values, regardless if path is from type string or array
if (typeof lastCrumb?.path === 'string') {
if (
lastCrumb?.path?.includes('undefined') ||
lastCrumb?.path?.includes('null')
) {
return fallback;
}
} else {
const valuesToCheck = [];
// eslint-disable-next-line no-unsafe-optional-chaining
for (const value of lastCrumb?.path) {
if (
value?.outlets &&
value?.outlets?.primary &&
value?.outlets?.side
) {
valuesToCheck.push(
...Object.values(value?.outlets?.primary),
...Object.values(value?.outlets?.side),
);
} else {
valuesToCheck.push(value);
}
}
if (this.checkIfArrayContainsUndefinedOrNull(valuesToCheck)) {
return fallback;
}
}
return { path: lastCrumb.path, queryParams: lastCrumb.params };
}),
);
}
checkIfArrayContainsUndefinedOrNull(array: unknown[]) {
return (
array?.includes(undefined) ||
array?.includes('undefined') ||
array?.includes(null) ||
array?.includes('null')
);
}
getLastActivatedCustomerProcessId$() {
return this._app.getProcesses$('customer').pipe(
map((processes) => {
const lastCustomerProcess = processes
.filter((process) => process.type === 'cart')
.reduce((last, current) => {
if (!last) return current;
if (last.activated > current.activated) {
return last;
} else {
return current;
}
}, undefined);
return lastCustomerProcess?.id ?? Date.now();
}),
);
}
closeSideMenu() {
this._shellService.closeSideMenu();
}
logout() {
this._authService.logout();
}
async resetBranch() {
const process = await this.activeProcess$.pipe(first()).toPromise();
if (process?.id) {
this._app.patchProcessData(process.id, { selectedBranch: undefined });
}
}
focusSearchBox() {
setTimeout(() => this._document.getElementById('searchbox')?.focus(), 0);
}
async createProcess() {
const process = await this.createCartProcess();
this.navigateToCatalog(process);
}
async createCartProcess() {
const nextProcessName = await this.getNextProcessName();
const process: ApplicationProcess = {
id: this.getNextProcessId(),
type: 'cart',
name: nextProcessName,
section: 'customer',
closeable: true,
};
this._app.createProcess(process);
return process;
}
async getNextProcessName() {
let processes = await this._app
.getProcesses$('customer')
.pipe(first())
.toPromise();
processes = processes.filter(
(x) => x.type === 'cart' && x.name.startsWith('Vorgang '),
);
const maxProcessNumber = processes.reduce((max, process) => {
const number = parseInt(process.name.replace('Vorgang ', ''), 10);
return number > max ? number : max;
}, 0);
return `Vorgang ${maxProcessNumber + 1}`;
}
getNextProcessId() {
return Date.now();
}
async navigateToCatalog(process: ApplicationProcess) {
await this._catalogNavigationService
.getArticleSearchBasePath(process.id)
.navigate();
}
navigateToDashboard() {
this._router.navigate(['/kunde', 'dashboard']);
}
async closeAllProcesses() {
const processes = await this.processes$.pipe(take(1)).toPromise();
processes.forEach((process) => this._app.removeProcess(process.id));
this.navigateToDashboard();
}
fetchAndOpenPackages = () =>
this._wrongDestinationModalService.fetchAndOpen();
}
import {
Component,
ChangeDetectionStrategy,
Inject,
ChangeDetectorRef,
inject,
DOCUMENT,
signal,
} from '@angular/core';
import { AuthModule, AuthService } from '@core/auth';
import { StockService } from '@generated/swagger/wws-api';
import { first, map, retry, switchMap, take } from 'rxjs/operators';
import { ShellService } from '../shell.service';
import { ApplicationProcess, ApplicationService } from '@core/application';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { EnvironmentService } from '@core/environment';
import { CommonModule } from '@angular/common';
import { Config } from '@core/config';
import { BreadcrumbService } from '@core/breadcrumb';
import { IconComponent } from '@shared/components/icon';
import { RegexRouterLinkActiveDirective } from '@shared/directives/router-link-active';
import { WrongDestinationModalService } from '@modal/wrong-destination';
import {
CustomerCreateNavigation,
CustomerOrdersNavigationService,
CustomerSearchNavigation,
PickupShelfInNavigationService,
PickUpShelfOutNavigationService,
ProductCatalogNavigationService,
} from '@shared/services/navigation';
import { TabService } from '@isa/core/tabs';
import { NgIconComponent, provideIcons } from '@ng-icons/core';
import { isaNavigationRemission2, isaNavigationReturn } from '@isa/icons';
@Component({
selector: 'shell-side-menu',
templateUrl: 'side-menu.component.html',
styleUrls: ['side-menu.component.css'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
CommonModule,
NgIconComponent,
IconComponent,
RouterModule,
AuthModule,
RegexRouterLinkActiveDirective,
],
providers: [provideIcons({ isaNavigationReturn, isaNavigationRemission2 })],
})
export class ShellSideMenuComponent {
#shellService = inject(ShellService);
#authService = inject(AuthService);
#stockService = inject(StockService);
#app = inject(ApplicationService);
#router = inject(Router);
#route = inject(ActivatedRoute);
#wrongDestinationModalService = inject(WrongDestinationModalService);
#environment = inject(EnvironmentService);
#catalogNavigationService = inject(ProductCatalogNavigationService);
#customerOrdersNavigationService = inject(CustomerOrdersNavigationService);
#config = inject(Config);
#breadcrumbService = inject(BreadcrumbService);
#customerSearchNavigation = inject(CustomerSearchNavigation);
#customerCreateNavigation = inject(CustomerCreateNavigation);
#pickUpShelfOutNavigation = inject(PickUpShelfOutNavigationService);
#pickUpShelfInNavigation = inject(PickupShelfInNavigationService);
#cdr = inject(ChangeDetectorRef);
#document = inject(DOCUMENT);
processService = inject(TabService);
branchKey$ = this.#stockService.StockCurrentBranch().pipe(
retry(3),
map((x) => x.result.key),
);
section$ = this.#app.getSection$();
processes$ = this.section$.pipe(
switchMap((section) => this.#app.getProcesses$(section)),
);
processesCount$ = this.processes$.pipe(
map((processes) => processes?.length ?? 0),
);
activeProcess$ = this.#app.activatedProcessId$.pipe(
switchMap((processId) => this.#app.getProcessById$(processId)),
);
get isTablet() {
return this.#environment.matchTablet();
}
customerBasePath$ = this.activeProcess$.pipe(
map((process) => {
if (
!!process &&
process.section === 'customer' &&
process.type !== 'cart-checkout'
) {
// Übernehme aktiven Prozess
return `/kunde/${process.id}`;
} else {
// Über Guards wird ein neuer Prozess erstellt
return '/kunde';
}
}),
);
customerSearchRoute$ = this.getLastActivatedCustomerProcessId$().pipe(
map((processId) => {
return this.#customerSearchNavigation.defaultRoute({ processId });
}),
);
customerCreateRoute$ = this.getLastActivatedCustomerProcessId$().pipe(
map((processId) => {
return this.#customerCreateNavigation.defaultRoute({ processId });
}),
);
pickUpShelfOutRoutePath$ = this.getLastActivatedCustomerProcessId$().pipe(
map((processId) => {
if (processId) {
// Übernehme aktiven Prozess
return this.#pickUpShelfOutNavigation.defaultRoute({ processId }).path;
} else {
// Über Guards wird ein neuer Prozess erstellt
return this.#pickUpShelfOutNavigation.defaultRoute({}).path;
}
}),
);
productRoutePath$ = this.getLastActivatedCustomerProcessId$().pipe(
map((processId) => {
if (processId) {
// Übernehme aktiven Prozess
return this.#catalogNavigationService.getArticleSearchBasePath(
processId,
).path;
} else {
// Über Guards wird ein neuer Prozess erstellt
return this.#catalogNavigationService.getArticleSearchBasePath().path;
}
}),
);
customerOrdersRoutePath$ = this.getLastActivatedCustomerProcessId$().pipe(
map((processId) => {
if (processId) {
// Übernehme aktiven Prozess
return this.#customerOrdersNavigationService.getCustomerOrdersBasePath(
processId,
).path;
} else {
// Über Guards wird ein neuer Prozess erstellt
return this.#customerOrdersNavigationService.getCustomerOrdersBasePath()
.path;
}
}),
);
taskCalenderNavigation$ = this.getLastNavigationByProcessId(
this.#config.get('process.ids.taskCalendar'),
{
path: ['/filiale', 'task-calendar'],
queryParams: {},
},
'/filiale/task-calendar',
);
assortmentNavigation$ = this.getLastNavigationByProcessId(
this.#config.get('process.ids.assortment'),
{
path: ['/filiale', 'assortment'],
queryParams: {},
},
);
pickUpShelfInRoutePath$ = this.getLastNavigationByProcessId(
this.#config.get('process.ids.pickupShelf'),
this.#pickUpShelfInNavigation.defaultRoute(),
'/filiale/pickup-shelf',
);
// #4478 - RD // Abholfach - Routing löst Suche aus
// pickUpShelfInListRoutePath$ = this.getLastNavigationByProcessId(
// this._config.get('process.ids.pickupShelf'),
// this._pickUpShelfInNavigation.listRoute()
// );
packageInspectionNavigation$ = this.getLastNavigationByProcessId(
this.#config.get('process.ids.packageInspection'),
{
path: ['/filiale', 'package-inspection'],
queryParams: {},
},
);
get currentShelfView$() {
return this.#route.queryParams.pipe(map((params) => params.view));
}
shelfExpanded = false;
customerExpanded = false;
remissionExpanded = signal(false);
customerActive(isActive: boolean) {
if (isActive) {
this.expandCustomer();
}
}
shelfActive(isActive: boolean) {
if (isActive) {
this.expandShelf();
}
}
expandCustomer() {
this.customerExpanded = true;
this.#cdr.markForCheck();
}
expandShelf() {
this.shelfExpanded = true;
this.#cdr.markForCheck();
}
getLastNavigationByProcessId(
id: number,
fallback?: { path: string[]; queryParams: unknown },
pathContainsString?: string,
) {
return this.#breadcrumbService.getBreadcrumbByKey$(id)?.pipe(
map((breadcrumbs) => {
const lastCrumb = breadcrumbs
.filter((breadcrumb) => {
/**
* #4532 - Der optionale Filter wurde hinzugefügt Breadcrumbs mit fehlerhaften Pfad auszuschließen.
* Dieser Filter kann entfernt werden, sobald die Breadcrumbs korrekt gesetzt werden. Jedoch konnte man bisher nicht feststellen,
* woher die fehlerhaften Breadcrumbs kommen.
*/
if (!pathContainsString) {
// Wenn kein Filter gesetzt ist, dann wird der letzte Breadcrumb zurückgegeben
return true;
}
const pathStr = Array.isArray(breadcrumb.path)
? breadcrumb.path.join('/')
: breadcrumb.path;
return pathStr.includes(pathContainsString);
})
// eslint-disable-next-line no-prototype-builtins
.filter((breadcrumb) => !breadcrumb?.params?.hasOwnProperty('view'))
.filter((breadcrumb) => !breadcrumb?.tags?.includes('reservation'))
.filter((breadcrumb) => !breadcrumb?.tags?.includes('cleanup'))
.filter(
(breadcrumb) => !breadcrumb?.tags?.includes('wareneingangsliste'),
)
.filter((breadcrumb) => !breadcrumb?.tags?.includes('preview'))
.reduce((last, current) => {
if (!last) return current;
if (last.changed > current.changed) {
return last;
} else {
return current;
}
}, undefined);
if (!lastCrumb) {
return fallback;
}
// #4692 Return Fallback if Values contain undefined or null values, regardless if path is from type string or array
if (typeof lastCrumb?.path === 'string') {
if (
lastCrumb?.path?.includes('undefined') ||
lastCrumb?.path?.includes('null')
) {
return fallback;
}
} else {
const valuesToCheck = [];
// eslint-disable-next-line no-unsafe-optional-chaining
for (const value of lastCrumb?.path) {
if (
value?.outlets &&
value?.outlets?.primary &&
value?.outlets?.side
) {
valuesToCheck.push(
...Object.values(value?.outlets?.primary),
...Object.values(value?.outlets?.side),
);
} else {
valuesToCheck.push(value);
}
}
if (this.checkIfArrayContainsUndefinedOrNull(valuesToCheck)) {
return fallback;
}
}
return { path: lastCrumb.path, queryParams: lastCrumb.params };
}),
);
}
checkIfArrayContainsUndefinedOrNull(array: unknown[]) {
return (
array?.includes(undefined) ||
array?.includes('undefined') ||
array?.includes(null) ||
array?.includes('null')
);
}
getLastActivatedCustomerProcessId$() {
return this.#app.getProcesses$('customer').pipe(
map((processes) => {
const lastCustomerProcess = processes
.filter((process) => process.type === 'cart')
.reduce((last, current) => {
if (!last) return current;
if (last.activated > current.activated) {
return last;
} else {
return current;
}
}, undefined);
return lastCustomerProcess?.id ?? Date.now();
}),
);
}
closeSideMenu() {
this.#shellService.closeSideMenu();
}
logout() {
this.#authService.logout();
}
async resetBranch() {
const process = await this.activeProcess$.pipe(first()).toPromise();
if (process?.id) {
this.#app.patchProcessData(process.id, { selectedBranch: undefined });
}
}
focusSearchBox() {
setTimeout(() => this.#document.getElementById('searchbox')?.focus(), 0);
}
async createProcess() {
const process = await this.createCartProcess();
this.navigateToCatalog(process);
}
async createCartProcess() {
const nextProcessName = await this.getNextProcessName();
const process: ApplicationProcess = {
id: this.getNextProcessId(),
type: 'cart',
name: nextProcessName,
section: 'customer',
closeable: true,
};
this.#app.createProcess(process);
return process;
}
async getNextProcessName() {
let processes = await this.#app
.getProcesses$('customer')
.pipe(first())
.toPromise();
processes = processes.filter(
(x) => x.type === 'cart' && x.name.startsWith('Vorgang '),
);
const maxProcessNumber = processes.reduce((max, process) => {
const number = parseInt(process.name.replace('Vorgang ', ''), 10);
return number > max ? number : max;
}, 0);
return `Vorgang ${maxProcessNumber + 1}`;
}
getNextProcessId() {
return Date.now();
}
async navigateToCatalog(process: ApplicationProcess) {
await this.#catalogNavigationService
.getArticleSearchBasePath(process.id)
.navigate();
}
navigateToDashboard() {
this.#router.navigate(['/kunde', 'dashboard']);
}
async closeAllProcesses() {
const processes = await this.processes$.pipe(take(1)).toPromise();
processes.forEach((process) => this.#app.removeProcess(process.id));
this.navigateToDashboard();
}
fetchAndOpenPackages = () =>
this.#wrongDestinationModalService.fetchAndOpen();
}

View File

@@ -9,6 +9,7 @@
@layer components {
@import "../../../libs/ui/buttons/src/buttons.scss";
@import "../../../libs/ui/bullet-list/src/bullet-list.scss";
@import "../../../libs/ui/datepicker/src/datepicker.scss";
@import "../../../libs/ui/dialog/src/dialog.scss";
@import "../../../libs/ui/input-controls/src/input-controls.scss";
@@ -17,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

@@ -17,6 +17,8 @@
}
.ui-tooltip-panel {
@apply pointer-events-auto;
.triangle {
width: 30px;
polygon {

View File

@@ -17,6 +17,7 @@ import { provideRouter } from '@angular/router';
type ProductInfoInputs = {
item: ProductInfoItem;
orientation: ProductInfoOrientation;
innerGridClass: string;
};
const meta: Meta<ProductInfoInputs> = {
@@ -50,8 +51,10 @@ const meta: Meta<ProductInfoInputs> = {
value: 19.99,
},
},
tag: 'Prio 2',
},
orientation: 'horizontal',
innerGridClass: 'grid-cols-[minmax(20rem,1fr),auto]',
},
argTypes: {
item: {
@@ -68,6 +71,16 @@ const meta: Meta<ProductInfoInputs> = {
},
},
},
innerGridClass: {
control: 'text',
description:
'Custom CSS classes for the inner grid layout. (Applies on vertical layout only)',
table: {
defaultValue: {
summary: 'grid-cols-[minmax(20rem,1fr),auto]',
},
},
},
},
render: (args) => ({
props: args,
@@ -95,6 +108,7 @@ export const Default: Story = {
value: 29.99,
},
},
tag: 'Prio 2',
},
},
};

View File

@@ -0,0 +1,66 @@
import { type Meta, type StoryObj, argsToTemplate } from '@storybook/angular';
import { ProductShelfMetaInfoComponent } from '@isa/remission/shared/product';
const meta: Meta<ProductShelfMetaInfoComponent> = {
component: ProductShelfMetaInfoComponent,
title: 'remission/shared/product/ProductShelfMetaInfoComponent',
args: {
department: 'Reise',
shelfLabel: 'Europa',
productGroupKey: '311',
productGroupValue: 'Romane TB',
assortment: 'Basissortiment|BPrämienartikel|n',
returnReason: 'Beschädigt',
},
argTypes: {
department: {
control: { type: 'text' },
description: 'The department of the product.',
defaultValue: undefined,
},
shelfLabel: {
control: { type: 'text' },
description: 'The shelf label of the product.',
defaultValue: undefined,
},
productGroupKey: {
control: { type: 'text' },
description: 'The key of the product group.',
defaultValue: undefined,
},
productGroupValue: {
control: { type: 'text' },
description: 'The value of the product group.',
defaultValue: undefined,
},
assortment: {
control: { type: 'text' },
description: 'The assortment of the product.',
defaultValue: undefined,
},
returnReason: {
control: { type: 'text' },
description: 'The reason for the return of the product.',
defaultValue: undefined,
},
},
render: (args) => ({
props: args,
template: `<remi-product-shelf-meta-info ${argsToTemplate(args)}></remi-product-shelf-meta-info>`,
}),
};
export default meta;
type Story = StoryObj<ProductShelfMetaInfoComponent>;
export const Default: Story = {
args: {
department: 'Reise',
shelfLabel: 'Europa',
productGroupKey: '311',
productGroupValue: 'Romane TB',
assortment: 'Basissortiment|BPrämienartikel|n',
returnReason: 'Beschädigt',
},
};

View File

@@ -1,74 +1,64 @@
import {
type Meta,
type StoryObj,
argsToTemplate,
moduleMetadata,
} from '@storybook/angular';
import { ProductStockInfoComponent } from '@isa/remission/shared/product';
import { provideProductImageUrl } from '@isa/shared/product-image';
import { provideProductRouterLinkBuilder } from '@isa/shared/product-router-link';
const meta: Meta<ProductStockInfoComponent> = {
component: ProductStockInfoComponent,
title: 'remission/shared/product/ProductStockInfoComponent',
decorators: [
moduleMetadata({
providers: [
provideProductImageUrl('https://produktbilder-test.paragon-data.net'),
provideProductRouterLinkBuilder((ean: string) => ean),
],
}),
],
args: {
stock: 92,
removedFromStock: 0,
predefinedReturnQuantity: 4,
remainingQuantityInStock: 0,
zob: 0,
},
argTypes: {
stock: {
control: { type: 'number' },
description: 'The current stock of the product.',
defaultValue: 0,
},
removedFromStock: {
control: { type: 'number' },
description: 'The amount of stock that has been removed.',
defaultValue: 0,
},
predefinedReturnQuantity: {
control: { type: 'number' },
description: 'The predefined return quantity for the product.',
defaultValue: 0,
},
remainingQuantityInStock: {
control: { type: 'number' },
description: 'The remaining quantity in stock after returns.',
defaultValue: 0,
},
zob: {
control: { type: 'number' },
description: 'Min Stock Category Management Information.',
defaultValue: 0,
},
},
render: (args) => ({
props: args,
template: `<remi-product-stock-info ${argsToTemplate(args)}></remi-product-stock-info>`,
}),
};
export default meta;
type Story = StoryObj<ProductStockInfoComponent>;
export const Default: Story = {
args: {
stock: 92,
removedFromStock: 0,
predefinedReturnQuantity: 4,
remainingQuantityInStock: 0,
zob: 0,
},
};
import {
type Meta,
type StoryObj,
argsToTemplate,
moduleMetadata,
} from '@storybook/angular';
import { ProductStockInfoComponent } from '@isa/remission/shared/product';
import { provideProductImageUrl } from '@isa/shared/product-image';
import { provideProductRouterLinkBuilder } from '@isa/shared/product-router-link';
const meta: Meta<ProductStockInfoComponent> = {
component: ProductStockInfoComponent,
title: 'remission/shared/product/ProductStockInfoComponent',
decorators: [
moduleMetadata({
providers: [
provideProductImageUrl('https://produktbilder-test.paragon-data.net'),
provideProductRouterLinkBuilder((ean: string) => ean),
],
}),
],
args: {
availableStock: 92,
stockToRemit: 91,
targetStock: 1,
zob: 0,
},
argTypes: {
availableStock: {
control: { type: 'number' },
description: 'Total available stock for the product.',
},
stockToRemit: {
control: { type: 'number' },
description: 'Stock quantity to remit.',
},
targetStock: {
control: { type: 'number' },
description: 'Target stock level after remittance.',
},
zob: {
control: { type: 'number' },
description: 'Min Stock Category Management Information.',
defaultValue: 0,
},
},
render: (args) => ({
props: args,
template: `<remi-product-stock-info ${argsToTemplate(args)}></remi-product-stock-info>`,
}),
};
export default meta;
type Story = StoryObj<ProductStockInfoComponent>;
export const Default: Story = {
args: {
availableStock: 92,
stockToRemit: 91,
targetStock: 1,
zob: 0,
},
};

View File

@@ -1,10 +1,16 @@
import { argsToTemplate, type Meta, type StoryObj, moduleMetadata } from '@storybook/angular';
import { EmptyStateComponent } from '@isa/ui/empty-state';
import {
argsToTemplate,
type Meta,
type StoryObj,
moduleMetadata,
} from '@storybook/angular';
import { EmptyStateAppearance, EmptyStateComponent } from '@isa/ui/empty-state';
import { ButtonComponent } from '@isa/ui/buttons';
type EmptyStateComponentInputs = {
title: string;
description: string;
appearance: EmptyStateAppearance;
};
const meta: Meta<EmptyStateComponentInputs> = {
@@ -22,6 +28,10 @@ const meta: Meta<EmptyStateComponentInputs> = {
description: {
control: 'text',
},
appearance: {
control: 'select',
options: Object.values(EmptyStateAppearance),
},
},
render: (args) => ({
props: args,
@@ -40,5 +50,6 @@ export const Default: Story = {
args: {
title: 'Keine Suchergebnisse',
description: 'Suchen Sie nach einer Rechnungsnummer oder Kundennamen.',
appearance: EmptyStateAppearance.NoResults,
},
};

View File

@@ -31,7 +31,7 @@ const meta: Meta<ClientRowComponent> = {
</ui-item-row-data-value>
</ui-item-row-data-row>
<ui-item-row-data-row>
<ui-item-row-data-label>Rechnugsnr.</ui-item-row-data-label>
<ui-item-row-data-label>Beleg-Nr.</ui-item-row-data-label>
<ui-item-row-data-value>
<span class="isa-text-body-2-bold">
1234567890

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

@@ -9,10 +9,10 @@ trigger:
variables:
# Major Version einstellen
- name: 'Major'
value: '3'
value: '4'
# Minor Version einstellen
- name: 'Minor'
value: '4'
value: '1'
- name: 'Patch'
value: "$[counter(format('{0}.{1}', variables['Major'], variables['Minor']),0)]"
- name: 'BuildUniqueID'
@@ -89,7 +89,7 @@ jobs:
condition: and(ne(variables['Build.SourceBranch'], 'refs/heads/integration'), ne(variables['Build.SourceBranch'], 'refs/heads/master'), not(startsWith(variables['Build.SourceBranch'], 'refs/heads/hotfix/')), not(startsWith(variables['Build.SourceBranch'], 'refs/heads/release/')))
variables:
- name: DockerTagSourceBranch
value: $[replace(variables['Build.SourceBranch'], '/', '_')]
value: $[replace(variables['Build.SourceBranch'], '/', '-')]
- name: 'DockerTag'
value: |
$(Build.BuildNumber)-$(Build.SourceVersion)

View File

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,3 @@
export * from './models';
export * from './schemas';
export * from './services';

View File

@@ -0,0 +1,222 @@
import { TestBed } from '@angular/core/testing';
import { of } from 'rxjs';
import { CatalougeSearchService } from './catalouge-search.service';
import { SearchService } from '@generated/swagger/cat-search-api';
import { Item } from '../models';
import { SearchByTermInput } from '../schemas/catalouge-search.schemas';
describe('CatalougeSearchService', () => {
let service: CatalougeSearchService;
let searchServiceSpy: jest.Mocked<SearchService>;
beforeEach(() => {
const searchServiceMock = {
SearchByEAN: jest.fn(),
SearchSearch: jest.fn(),
};
TestBed.configureTestingModule({
providers: [
CatalougeSearchService,
{ provide: SearchService, useValue: searchServiceMock },
],
});
service = TestBed.inject(CatalougeSearchService);
searchServiceSpy = TestBed.inject(SearchService) as jest.Mocked<SearchService>;
});
it('should be created', () => {
expect(service).toBeTruthy();
});
describe('searchByEans', () => {
it('should return items when search is successful', (done) => {
// Arrange
const mockItems: Item[] = [
{ id: 1, product: { name: 'Item 1' }, catalogAvailability: { available: true } } as unknown as Item,
{ id: 2, product: { name: 'Item 2' }, catalogAvailability: { available: true } } as unknown as Item,
];
const mockResponse = {
error: false,
result: mockItems,
};
searchServiceSpy.SearchByEAN.mockReturnValue(of(mockResponse));
// Act
service.searchByEans('123456789', '987654321').subscribe({
next: (result) => {
// Assert
expect(result).toEqual(mockItems);
expect(searchServiceSpy.SearchByEAN).toHaveBeenCalledWith(['123456789', '987654321']);
done();
},
error: done.fail,
});
});
it('should throw error when response has error', (done) => {
// Arrange
const mockResponse = {
error: true,
message: 'Search failed',
};
searchServiceSpy.SearchByEAN.mockReturnValue(of(mockResponse));
// Act
service.searchByEans('123456789').subscribe({
next: () => done.fail('Should have thrown error'),
error: (error) => {
// Assert
expect(error).toBeInstanceOf(Error);
expect(error.message).toBe('Search failed');
done();
},
});
});
it('should handle single EAN', (done) => {
// Arrange
const mockItems: Item[] = [{ id: 1, product: { name: 'Item 1' }, catalogAvailability: { available: true } } as unknown as Item];
const mockResponse = {
error: false,
result: mockItems,
};
searchServiceSpy.SearchByEAN.mockReturnValue(of(mockResponse));
// Act
service.searchByEans('123456789').subscribe({
next: (result) => {
// Assert
expect(result).toEqual(mockItems);
expect(searchServiceSpy.SearchByEAN).toHaveBeenCalledWith(['123456789']);
done();
},
error: done.fail,
});
});
it('should handle empty EAN array', (done) => {
// Arrange
const mockResponse = {
error: false,
result: [],
};
searchServiceSpy.SearchByEAN.mockReturnValue(of(mockResponse));
// Act
service.searchByEans().subscribe({
next: (result) => {
// Assert
expect(result).toEqual([]);
expect(searchServiceSpy.SearchByEAN).toHaveBeenCalledWith([]);
done();
},
error: done.fail,
});
});
});
describe('searchByTerm', () => {
it('should return search results when successful', async () => {
// Arrange
const mockItems: Item[] = [
{ id: 1, product: { name: 'Test Item' }, catalogAvailability: { available: true } } as unknown as Item,
];
const mockResponse = {
error: false,
result: mockItems,
total: 1,
};
searchServiceSpy.SearchSearch.mockReturnValue(of(mockResponse));
const params: SearchByTermInput = {
searchTerm: 'test',
skip: 0,
take: 10,
};
const abortController = new AbortController();
// Act
const result = await service.searchByTerm(params, abortController.signal);
// Assert
expect(result).toEqual(mockResponse);
expect(searchServiceSpy.SearchSearch).toHaveBeenCalledWith({
input: { qs: 'test' },
skip: 0,
take: 10,
doNotTrack: true,
});
});
it('should throw error when response has error', async () => {
// Arrange
const mockResponse = {
error: true,
message: 'Search failed',
};
searchServiceSpy.SearchSearch.mockReturnValue(of(mockResponse));
const params: SearchByTermInput = {
searchTerm: 'test',
skip: 0,
take: 10,
};
const abortController = new AbortController();
// Act & Assert
await expect(service.searchByTerm(params, abortController.signal))
.rejects
.toThrow('Search failed');
});
it('should handle abort signal', async () => {
// Arrange
const abortController = new AbortController();
const mockResponse = {
error: false,
result: [],
};
searchServiceSpy.SearchSearch.mockReturnValue(of(mockResponse));
const params: SearchByTermInput = {
searchTerm: 'test',
skip: 0,
take: 10,
};
// Act
const result = await service.searchByTerm(params, abortController.signal);
// Assert
expect(result).toEqual(mockResponse);
expect(searchServiceSpy.SearchSearch).toHaveBeenCalled();
});
it('should use default values when not provided', async () => {
// Arrange
const mockResponse = {
error: false,
result: [],
};
searchServiceSpy.SearchSearch.mockReturnValue(of(mockResponse));
const params: SearchByTermInput = {
searchTerm: 'test',
};
const abortController = new AbortController();
// Act
await service.searchByTerm(params, abortController.signal);
// Assert
expect(searchServiceSpy.SearchSearch).toHaveBeenCalledWith({
input: { qs: 'test' },
skip: 0,
take: 20,
doNotTrack: true,
});
});
});
});

View File

@@ -1,52 +1,53 @@
import { inject, Injectable } from '@angular/core';
import { SearchService } from '@generated/swagger/cat-search-api';
import { firstValueFrom, map, Observable } from 'rxjs';
import { takeUntilAborted } from '@isa/common/data-access';
import { Item } from '../models';
import {
SearchByTermInput,
SearchByTermSchema,
} from '../schemas/catalouge-search.schemas';
import { ListResponseArgs } from '@isa/common/data-access';
@Injectable({ providedIn: 'root' })
export class CatalougeSearchService {
#searchService = inject(SearchService);
searchByEans(...ean: string[]): Observable<Item[]> {
return this.#searchService.SearchByEAN(ean).pipe(
map((res) => {
if (res.error) {
throw new Error(res.message);
}
return res.result as Item[];
}),
);
}
async searchByTerm(
params: SearchByTermInput,
abortSignal: AbortSignal,
): Promise<ListResponseArgs<Item>> {
const { searchTerm, skip, take } = SearchByTermSchema.parse(params);
const req$ = this.#searchService
.SearchSearch({
filter: {
qs: searchTerm,
},
skip,
take,
})
.pipe(takeUntilAborted(abortSignal));
const res = await firstValueFrom(req$);
if (res.error) {
throw new Error(res.message);
}
return res as ListResponseArgs<Item>;
}
}
import { inject, Injectable } from '@angular/core';
import { SearchService } from '@generated/swagger/cat-search-api';
import { firstValueFrom, map, Observable } from 'rxjs';
import { takeUntilAborted } from '@isa/common/data-access';
import { Item } from '../models';
import {
SearchByTermInput,
SearchByTermSchema,
} from '../schemas/catalouge-search.schemas';
import { ListResponseArgs } from '@isa/common/data-access';
@Injectable({ providedIn: 'root' })
export class CatalougeSearchService {
#searchService = inject(SearchService);
searchByEans(...ean: string[]): Observable<Item[]> {
return this.#searchService.SearchByEAN(ean).pipe(
map((res) => {
if (res.error) {
throw new Error(res.message);
}
return res.result as Item[];
}),
);
}
async searchByTerm(
params: SearchByTermInput,
abortSignal: AbortSignal,
): Promise<ListResponseArgs<Item>> {
const { searchTerm, skip, take } = SearchByTermSchema.parse(params);
const req$ = this.#searchService
.SearchSearch({
input: {
qs: searchTerm,
},
skip,
take,
doNotTrack: true,
})
.pipe(takeUntilAborted(abortSignal));
const res = await firstValueFrom(req$);
if (res.error) {
throw new Error(res.message);
}
return res as ListResponseArgs<Item>;
}
}

View File

@@ -44,5 +44,8 @@ export class DataAccessError<TCode extends string, TData = void> extends Error {
public readonly data: TData,
) {
super(message);
// Set the prototype explicitly to maintain the correct prototype chain
Object.setPrototypeOf(this, new.target.prototype);
this.name = this.constructor.name;
}
}

View File

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

View File

@@ -0,0 +1,143 @@
import { BatchResponseArgs } from './batch-response-args';
import { ReturnValue } from './return-value';
describe('BatchResponseArgs', () => {
describe('interface structure', () => {
it('should support all properties', () => {
// Arrange
const testData: BatchResponseArgs<string> = {
alreadyProcessed: [
{ error: false, result: 'processed1' },
{ error: false, result: 'processed2' },
],
ambiguous: ['ambiguous1', 'ambiguous2'],
completed: true,
duplicates: [
{ key: 'key1', value: 1 },
{ key: 'key2', value: 2 },
],
error: false,
failed: [
{ error: true, message: 'Failed', result: 'failed1' },
],
invalidProperties: { field1: 'Invalid value' },
message: 'Success',
requestId: 12345,
successful: [
{ key: 'key1', value: 'value1' },
{ key: 'key2', value: 'value2' },
],
total: 10,
unknown: [
{ error: false, result: 'unknown1' },
],
};
// Assert
expect(testData.alreadyProcessed).toHaveLength(2);
expect(testData.ambiguous).toHaveLength(2);
expect(testData.completed).toBe(true);
expect(testData.duplicates).toHaveLength(2);
expect(testData.error).toBe(false);
expect(testData.failed).toHaveLength(1);
expect(testData.invalidProperties).toEqual({ field1: 'Invalid value' });
expect(testData.message).toBe('Success');
expect(testData.requestId).toBe(12345);
expect(testData.successful).toHaveLength(2);
expect(testData.total).toBe(10);
expect(testData.unknown).toHaveLength(1);
});
it('should support required properties only', () => {
// Arrange
const testData: BatchResponseArgs<number> = {
completed: false,
error: true,
total: 0,
};
// Assert
expect(testData.completed).toBe(false);
expect(testData.error).toBe(true);
expect(testData.total).toBe(0);
expect(testData.alreadyProcessed).toBeUndefined();
expect(testData.ambiguous).toBeUndefined();
expect(testData.duplicates).toBeUndefined();
expect(testData.failed).toBeUndefined();
expect(testData.invalidProperties).toBeUndefined();
expect(testData.message).toBeUndefined();
expect(testData.requestId).toBeUndefined();
expect(testData.successful).toBeUndefined();
expect(testData.unknown).toBeUndefined();
});
it('should support generic type parameter', () => {
// Arrange
interface TestObject {
id: number;
name: string;
}
const testData: BatchResponseArgs<TestObject> = {
completed: true,
error: false,
total: 1,
successful: [
{ key: { id: 1, name: 'test' }, value: { id: 1, name: 'test' } },
],
};
// Assert
expect(testData.successful?.[0].key.id).toBe(1);
expect(testData.successful?.[0].key.name).toBe('test');
expect(testData.successful?.[0].value.id).toBe(1);
expect(testData.successful?.[0].value.name).toBe('test');
});
it('should support ReturnValue arrays', () => {
// Arrange
const returnValue: ReturnValue<string> = {
error: false,
result: 'test result',
message: 'Success',
};
const testData: BatchResponseArgs<string> = {
completed: true,
error: false,
total: 1,
alreadyProcessed: [returnValue],
failed: [returnValue],
unknown: [returnValue],
};
// Assert
expect(testData.alreadyProcessed?.[0]).toEqual(returnValue);
expect(testData.failed?.[0]).toEqual(returnValue);
expect(testData.unknown?.[0]).toEqual(returnValue);
});
it('should support empty arrays', () => {
// Arrange
const testData: BatchResponseArgs<string> = {
completed: true,
error: false,
total: 0,
alreadyProcessed: [],
ambiguous: [],
duplicates: [],
failed: [],
successful: [],
unknown: [],
};
// Assert
expect(testData.alreadyProcessed).toHaveLength(0);
expect(testData.ambiguous).toHaveLength(0);
expect(testData.duplicates).toHaveLength(0);
expect(testData.failed).toHaveLength(0);
expect(testData.successful).toHaveLength(0);
expect(testData.unknown).toHaveLength(0);
});
});
});

View File

@@ -0,0 +1,16 @@
import { ReturnValue } from './return-value';
export interface BatchResponseArgs<T> {
alreadyProcessed?: Array<ReturnValue<T>>;
ambiguous?: Array<T>;
completed: boolean;
duplicates?: Array<{ key: T; value: number }>;
error: boolean;
failed?: Array<ReturnValue<T>>;
invalidProperties?: { [key: string]: string };
message?: string;
requestId?: number;
successful?: Array<{ key: T; value: T }>;
total: number;
unknown?: Array<ReturnValue<T>>;
}

View File

@@ -1,5 +1,7 @@
export * from './async-result';
export * from './callback-result';
export * from './entity-cotnainer';
export * from './list-response-args';
export * from './response-args';
export * from './async-result';
export * from './batch-response-args';
export * from './callback-result';
export * from './entity-cotnainer';
export * from './list-response-args';
export * from './response-args';
export * from './return-value';

View File

@@ -0,0 +1,128 @@
import { ReturnValue } from './return-value';
describe('ReturnValue', () => {
describe('interface structure', () => {
it('should support all properties', () => {
// Arrange
const testData: ReturnValue<string> = {
error: false,
invalidProperties: { field1: 'Invalid value', field2: 'Another error' },
message: 'Operation successful',
result: 'test result',
};
// Assert
expect(testData.error).toBe(false);
expect(testData.invalidProperties).toEqual({
field1: 'Invalid value',
field2: 'Another error'
});
expect(testData.message).toBe('Operation successful');
expect(testData.result).toBe('test result');
});
it('should support required properties only', () => {
// Arrange
const testData: ReturnValue<number> = {
error: true,
result: 42,
};
// Assert
expect(testData.error).toBe(true);
expect(testData.result).toBe(42);
expect(testData.invalidProperties).toBeUndefined();
expect(testData.message).toBeUndefined();
});
it('should support generic type parameter', () => {
// Arrange
interface TestObject {
id: number;
name: string;
}
const testObject: TestObject = { id: 1, name: 'test' };
const testData: ReturnValue<TestObject> = {
error: false,
result: testObject,
};
// Assert
expect(testData.result.id).toBe(1);
expect(testData.result.name).toBe('test');
});
it('should support arrays as generic type', () => {
// Arrange
const testData: ReturnValue<string[]> = {
error: false,
result: ['item1', 'item2', 'item3'],
message: 'Array operation successful',
};
// Assert
expect(testData.result).toHaveLength(3);
expect(testData.result[0]).toBe('item1');
expect(testData.result[1]).toBe('item2');
expect(testData.result[2]).toBe('item3');
});
it('should support null result', () => {
// Arrange
const testData: ReturnValue<string | null> = {
error: false,
result: null,
};
// Assert
expect(testData.result).toBeNull();
});
it('should support error state with message', () => {
// Arrange
const testData: ReturnValue<string> = {
error: true,
message: 'Operation failed',
result: '',
};
// Assert
expect(testData.error).toBe(true);
expect(testData.message).toBe('Operation failed');
expect(testData.result).toBe('');
});
it('should support complex invalidProperties', () => {
// Arrange
const testData: ReturnValue<any> = {
error: true,
invalidProperties: {
'user.email': 'Invalid email format',
'user.age': 'Age must be a positive number',
'nested.field.value': 'Required field missing',
},
result: null,
};
// Assert
expect(testData.invalidProperties).toEqual({
'user.email': 'Invalid email format',
'user.age': 'Age must be a positive number',
'nested.field.value': 'Required field missing',
});
});
it('should support empty invalidProperties', () => {
// Arrange
const testData: ReturnValue<string> = {
error: false,
invalidProperties: {},
result: 'success',
};
// Assert
expect(testData.invalidProperties).toEqual({});
});
});
});

View File

@@ -0,0 +1,6 @@
export interface ReturnValue<T> {
error: boolean;
invalidProperties?: { [key: string]: string };
message?: string;
result: T;
}

View File

@@ -0,0 +1,277 @@
# Common Decorators Library
A collection of TypeScript decorators for common cross-cutting concerns in Angular applications.
## Installation
This library is already configured in the project's `tsconfig.base.json`. Import decorators using:
```typescript
import { InFlight, InFlightWithKey, InFlightWithCache } from '@isa/common/decorators';
```
## Available Decorators
### 🚀 InFlight Decorators
Prevent multiple simultaneous calls to the same async method. All concurrent calls receive the same Promise result.
#### Basic Usage
```typescript
import { InFlight } from '@isa/common/decorators';
@Injectable()
class DataService {
@InFlight()
async fetchData(): Promise<Data> {
// Even if called multiple times simultaneously,
// only one API call will be made
return await this.http.get<Data>('/api/data').toPromise();
}
}
```
**Benefits:**
- Prevents duplicate API calls
- Reduces server load
- Improves application performance
- All callers receive the same result
### 🔑 InFlightWithKey
Prevents duplicate calls while considering method arguments. Each unique set of arguments gets its own in-flight tracking.
```typescript
import { InFlightWithKey } from '@isa/common/decorators';
@Injectable()
class UserService {
@InFlightWithKey({
keyGenerator: (userId: string) => userId
})
async fetchUser(userId: string): Promise<User> {
// Multiple calls with same userId share the same request
// Different userIds can execute simultaneously
return await this.http.get<User>(`/api/users/${userId}`).toPromise();
}
@InFlightWithKey() // Uses JSON.stringify by default
async searchUsers(query: string, page: number): Promise<User[]> {
return await this.http.get<User[]>(`/api/users/search`, {
params: { query, page: page.toString() }
}).toPromise();
}
}
```
**Configuration Options:**
- `keyGenerator?: (...args) => string` - Custom key generation function
- If not provided, uses `JSON.stringify(args)` as the key
### 🗄️ InFlightWithCache
Combines in-flight request deduplication with result caching.
```typescript
import { InFlightWithCache } from '@isa/common/decorators';
@Injectable()
class ProductService {
@InFlightWithCache({
cacheTime: 5 * 60 * 1000, // Cache for 5 minutes
keyGenerator: (productId: string) => productId
})
async getProduct(productId: string): Promise<Product> {
// Results are cached for 5 minutes
// Multiple calls within cache time return cached result
return await this.http.get<Product>(`/api/products/${productId}`).toPromise();
}
}
```
**Configuration Options:**
- `cacheTime?: number` - Cache duration in milliseconds
- `keyGenerator?: (...args) => string` - Custom key generation function
## How It Works
### Memory Management
All decorators use `WeakMap` for memory efficiency:
- Automatic garbage collection when instances are destroyed
- No memory leaks
- Per-instance state isolation
### Error Handling
- Failed requests are not cached
- In-flight tracking is cleaned up on both success and error
- All concurrent callers receive the same error
### Thread Safety
- Decorators are instance-aware
- Each service instance has its own in-flight tracking
- No shared state between instances
## Real-World Examples
### Solving Your Original Problem
```typescript
// Before: Multiple simultaneous calls
@Injectable({ providedIn: 'root' })
export class RemissionProductGroupService {
async fetchProductGroups(): Promise<KeyValueStringAndString[]> {
// Multiple calls = multiple API requests
return await this.apiCall();
}
}
// After: Using InFlight decorator
@Injectable({ providedIn: 'root' })
export class RemissionProductGroupService {
@InFlight()
async fetchProductGroups(): Promise<KeyValueStringAndString[]> {
// Multiple simultaneous calls = single API request
return await this.apiCall();
}
}
```
### Advanced Scenarios
```typescript
@Injectable()
class OrderService {
// Different cache times for different data types
@InFlightWithCache({ cacheTime: 30 * 1000 }) // 30 seconds
async getOrderStatus(orderId: string): Promise<OrderStatus> {
return await this.http.get<OrderStatus>(`/api/orders/${orderId}/status`).toPromise();
}
@InFlightWithCache({ cacheTime: 10 * 60 * 1000 }) // 10 minutes
async getOrderHistory(customerId: string): Promise<Order[]> {
return await this.http.get<Order[]>(`/api/customers/${customerId}/orders`).toPromise();
}
// Custom key generation for complex parameters
@InFlightWithKey({
keyGenerator: (filter: OrderFilter) =>
`${filter.status}-${filter.dateFrom}-${filter.dateTo}`
})
async searchOrders(filter: OrderFilter): Promise<Order[]> {
return await this.http.post<Order[]>('/api/orders/search', filter).toPromise();
}
}
```
## Best Practices
### ✅ Do
- Use `@InFlight()` for simple methods without parameters
- Use `@InFlightWithKey()` for methods with parameters
- Use `@InFlightWithCache()` for expensive operations with stable results
- Provide custom `keyGenerator` for complex parameter objects
- Set appropriate cache times based on data volatility
### ❌ Don't
- Use on methods that return different results for the same input
- Use excessively long cache times for dynamic data
- Use on methods that have side effects (POST, PUT, DELETE)
- Rely on argument order for default key generation
## Performance Considerations
### Memory Usage
- `InFlight`: Minimal memory overhead (one Promise per instance)
- `InFlightWithKey`: Memory usage scales with unique parameter combinations
- `InFlightWithCache`: Additional memory for cached results
### Cleanup
- In-flight requests are automatically cleaned up on completion
- Cache entries are cleaned up on expiry
- WeakMap ensures instances can be garbage collected
## Testing
The decorators are fully tested with comprehensive unit tests. Key test scenarios include:
- Multiple simultaneous calls deduplication
- Error handling and cleanup
- Cache expiration
- Instance isolation
- Key generation
Run tests with:
```bash
npx nx test common-decorators
```
## Migration Guide
### From Manual Implementation
```typescript
// Before: Manual in-flight tracking
class MyService {
private inFlight: Promise<Data> | null = null;
async fetchData(): Promise<Data> {
if (this.inFlight) {
return this.inFlight;
}
this.inFlight = this.doFetch();
try {
return await this.inFlight;
} finally {
this.inFlight = null;
}
}
}
// After: Using decorator
class MyService {
@InFlight()
async fetchData(): Promise<Data> {
return await this.doFetch();
}
}
```
### From RxJS shareReplay
```typescript
// Before: RxJS approach
class MyService {
private data$ = this.http.get<Data>('/api/data').pipe(
shareReplay({ bufferSize: 1, refCount: true })
);
getData(): Observable<Data> {
return this.data$;
}
}
// After: Promise-based with decorator
class MyService {
@InFlightWithCache({ cacheTime: 5 * 60 * 1000 })
async getData(): Promise<Data> {
return await this.http.get<Data>('/api/data').toPromise();
}
}
```
## Contributing
When adding new decorators:
1. Add implementation in `src/lib/`
2. Include comprehensive unit tests
3. Update this documentation
4. Export from `src/index.ts`

View File

@@ -3,20 +3,6 @@ const baseConfig = require('../../../eslint.config.js');
module.exports = [
...baseConfig,
{
files: ['**/*.json'],
rules: {
'@nx/dependency-checks': [
'error',
{
ignoredFiles: ['{projectRoot}/eslint.config.{js,cjs,mjs}'],
},
],
},
languageOptions: {
parser: require('jsonc-eslint-parser'),
},
},
...nx.configs['flat/angular'],
...nx.configs['flat/angular-template'],
{
@@ -26,7 +12,7 @@ module.exports = [
'error',
{
type: 'attribute',
prefix: 'remi',
prefix: 'common',
style: 'camelCase',
},
],
@@ -34,7 +20,7 @@ module.exports = [
'error',
{
type: 'element',
prefix: 'remi',
prefix: 'common',
style: 'kebab-case',
},
],
@@ -45,10 +31,4 @@ module.exports = [
// Override or add rules here
rules: {},
},
{
files: ['**/*.ts'],
rules: {
'@angular-eslint/prefer-standalone': 'off',
},
},
];

View File

@@ -0,0 +1,20 @@
{
"name": "common-decorators",
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/common/decorators/src",
"prefix": "common",
"projectType": "library",
"tags": [],
"targets": {
"test": {
"executor": "@nx/vite:test",
"outputs": ["{options.reportsDirectory}"],
"options": {
"reportsDirectory": "../../../coverage/libs/common/decorators"
}
},
"lint": {
"executor": "@nx/eslint:lint"
}
}
}

View File

@@ -0,0 +1,2 @@
export * from './lib/in-flight.decorator';
export * from './lib/cache.decorator';

View File

@@ -0,0 +1,383 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { Cache } from './cache.decorator';
describe('Cache Decorator', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.restoreAllMocks();
vi.clearAllTimers();
});
describe('Cache', () => {
class DataService {
callCount = 0;
@Cache({
ttl: 1000, // 1 second cache
keyGenerator: (query: string) => query
})
async search(query: string): Promise<string[]> {
this.callCount++;
await new Promise(resolve => setTimeout(resolve, 100));
return [`result-${query}-${this.callCount}`];
}
@Cache({
ttl: 500
})
async fetchWithExpiry(id: number): Promise<string> {
this.callCount++;
await new Promise(resolve => setTimeout(resolve, 50));
return `data-${id}-${this.callCount}`;
}
@Cache()
async fetchWithNoExpiry(value: string): Promise<string> {
this.callCount++;
return `permanent-${value}-${this.callCount}`;
}
}
it('should cache results for specified time', async () => {
const service = new DataService();
// First call
const promise1 = service.search('test');
await vi.runAllTimersAsync();
const result1 = await promise1;
expect(result1).toEqual(['result-test-1']);
expect(service.callCount).toBe(1);
// Second call within cache time - should return cached result
const result2 = await service.search('test');
expect(result2).toEqual(['result-test-1']);
expect(service.callCount).toBe(1); // No new call
// Advance time past cache expiry
vi.advanceTimersByTime(1100);
// Third call after cache expiry - should make new call
const promise3 = service.search('test');
await vi.runAllTimersAsync();
const result3 = await promise3;
expect(result3).toEqual(['result-test-2']);
expect(service.callCount).toBe(2);
});
it('should cache without expiry when no ttl specified', async () => {
const service = new DataService();
// First call
const result1 = await service.fetchWithNoExpiry('test');
expect(result1).toBe('permanent-test-1');
expect(service.callCount).toBe(1);
// Advance time significantly
vi.advanceTimersByTime(10000);
// Second call should still use cache
const result2 = await service.fetchWithNoExpiry('test');
expect(result2).toBe('permanent-test-1');
expect(service.callCount).toBe(1);
});
it('should cache different keys separately', async () => {
const service = new DataService();
// First call
const promise1 = service.search('query1');
await vi.runAllTimersAsync();
const result1 = await promise1;
expect(result1).toEqual(['result-query1-1']);
expect(service.callCount).toBe(1);
// Second call with different key
const promise2 = service.search('query2');
await vi.runAllTimersAsync();
const result2 = await promise2;
expect(result2).toEqual(['result-query2-2']);
expect(service.callCount).toBe(2);
// Subsequent calls should use cache
const result3 = await service.search('query1');
const result4 = await service.search('query2');
expect(result3).toEqual(['result-query1-1']);
expect(result4).toEqual(['result-query2-2']);
expect(service.callCount).toBe(2); // No new calls
});
it('should clean up expired cache entries', async () => {
const service = new DataService();
// Make a call
const promise1 = service.fetchWithExpiry(1);
await vi.runAllTimersAsync();
await promise1;
// Advance time past cache expiry
vi.advanceTimersByTime(600);
// Make another call - should not use expired cache
service.callCount = 0; // Reset for clarity
const promise2 = service.fetchWithExpiry(1);
await vi.runAllTimersAsync();
const result2 = await promise2;
expect(result2).toBe('data-1-1');
expect(service.callCount).toBe(1); // New call was made
});
it('should not cache errors', async () => {
class ErrorService {
callCount = 0;
@Cache({ ttl: 1000 })
async fetchWithError(): Promise<string> {
this.callCount++;
await new Promise(resolve => setTimeout(resolve, 50));
throw new Error('API Error');
}
}
const service = new ErrorService();
// First call that errors
try {
const promise1 = service.fetchWithError();
await vi.runAllTimersAsync();
await promise1;
expect.fail('Should have thrown an error');
} catch (error) {
expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toBe('API Error');
}
expect(service.callCount).toBe(1);
// Second call should not use cache (errors aren't cached)
try {
const promise2 = service.fetchWithError();
await vi.runAllTimersAsync();
await promise2;
expect.fail('Should have thrown an error');
} catch (error) {
expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toBe('API Error');
}
expect(service.callCount).toBe(2);
});
it('should use JSON.stringify as default key generator', async () => {
class DefaultKeyService {
callCount = 0;
@Cache({ ttl: 1000 })
async fetch(_param1: string, _param2: number): Promise<string> {
this.callCount++;
return `result-${this.callCount}`;
}
}
const service = new DefaultKeyService();
// First call with specific args
const result1 = await service.fetch('test', 123);
expect(result1).toBe('result-1');
// Same args should use cache
const result2 = await service.fetch('test', 123);
expect(result2).toBe('result-1');
expect(service.callCount).toBe(1);
// Different args should make new call
const result3 = await service.fetch('test', 456);
expect(result3).toBe('result-2');
expect(service.callCount).toBe(2);
});
});
describe('Sync function caching', () => {
class MathService {
callCount = 0;
@Cache({
ttl: 1000,
keyGenerator: (n: number) => n.toString()
})
fibonacci(n: number): number {
this.callCount++;
if (n <= 1) return n;
return this.fibonacci(n - 1) + this.fibonacci(n - 2);
}
@Cache({
ttl: 500
})
expensiveCalculation(a: number, b: number): number {
this.callCount++;
// Simulate expensive calculation
return a * b + Math.sqrt(a + b);
}
@Cache()
permanentCalculation(value: number): number {
this.callCount++;
return value * 2;
}
}
it('should cache sync function results', () => {
const service = new MathService();
// First call
const result1 = service.fibonacci(5);
expect(result1).toBe(5);
expect(service.callCount).toBe(6); // fibonacci(5) calls fibonacci(4), fibonacci(3), etc.
// Second call should use cache
const result2 = service.fibonacci(5);
expect(result2).toBe(5);
expect(service.callCount).toBe(6); // No new calls
});
it('should cache sync functions with multiple arguments', () => {
const service = new MathService();
// First call
const result1 = service.expensiveCalculation(10, 20);
expect(result1).toBe(10 * 20 + Math.sqrt(10 + 20));
expect(service.callCount).toBe(1);
// Second call with same arguments should use cache
const result2 = service.expensiveCalculation(10, 20);
expect(result2).toBe(result1);
expect(service.callCount).toBe(1);
// Call with different arguments should execute
const result3 = service.expensiveCalculation(5, 10);
expect(result3).toBe(5 * 10 + Math.sqrt(5 + 10));
expect(service.callCount).toBe(2);
});
it('should handle sync function cache expiry', () => {
const service = new MathService();
// First call
const result1 = service.expensiveCalculation(3, 4);
expect(result1).toBe(3 * 4 + Math.sqrt(3 + 4));
expect(service.callCount).toBe(1);
// Second call within TTL should use cache
const result2 = service.expensiveCalculation(3, 4);
expect(result2).toBe(result1);
expect(service.callCount).toBe(1);
// Advance time past TTL
vi.advanceTimersByTime(600);
// Call after expiry should execute again
const result3 = service.expensiveCalculation(3, 4);
expect(result3).toBe(3 * 4 + Math.sqrt(3 + 4));
expect(service.callCount).toBe(2);
});
it('should cache sync functions without expiry', () => {
const service = new MathService();
// First call
const result1 = service.permanentCalculation(42);
expect(result1).toBe(84);
expect(service.callCount).toBe(1);
// Advance time significantly
vi.advanceTimersByTime(10000);
// Second call should still use cache
const result2 = service.permanentCalculation(42);
expect(result2).toBe(84);
expect(service.callCount).toBe(1);
});
it('should not cache sync function errors', () => {
class ErrorService {
callCount = 0;
@Cache({ ttl: 1000 })
errorFunction(shouldError: boolean): string {
this.callCount++;
if (shouldError) {
throw new Error('Sync Error');
}
return 'success';
}
}
const service = new ErrorService();
// First call that errors
expect(() => service.errorFunction(true)).toThrow('Sync Error');
expect(service.callCount).toBe(1);
// Second call should not use cache (errors aren't cached)
expect(() => service.errorFunction(true)).toThrow('Sync Error');
expect(service.callCount).toBe(2);
// Call with success should work
const result = service.errorFunction(false);
expect(result).toBe('success');
expect(service.callCount).toBe(3);
// Subsequent success call should use cache
const result2 = service.errorFunction(false);
expect(result2).toBe('success');
expect(service.callCount).toBe(3);
});
});
describe('Mixed sync/async usage', () => {
class MixedService {
callCount = 0;
@Cache({ ttl: 1000 })
async asyncMethod(value: string): Promise<string> {
this.callCount++;
await new Promise(resolve => setTimeout(resolve, 10));
return `async-${value}`;
}
@Cache({ ttl: 1000 })
syncMethod(value: string): string {
this.callCount++;
return `sync-${value}`;
}
}
it('should handle mixed sync and async methods in same class', async () => {
const service = new MixedService();
// Test async method
const promise1 = service.asyncMethod('test');
await vi.runAllTimersAsync();
const result1 = await promise1;
expect(result1).toBe('async-test');
expect(service.callCount).toBe(1);
// Test sync method
const result2 = service.syncMethod('test');
expect(result2).toBe('sync-test');
expect(service.callCount).toBe(2);
// Test caching for both
const result3 = await service.asyncMethod('test');
expect(result3).toBe('async-test');
expect(service.callCount).toBe(2); // No new call
const result4 = service.syncMethod('test');
expect(result4).toBe('sync-test');
expect(service.callCount).toBe(2); // No new call
});
});
});

View File

@@ -0,0 +1,149 @@
export const CacheTimeToLive = {
oneMinute: 60 * 1000, // 1 minute
fiveMinutes: 5 * 60 * 1000, // 5 minutes
tenMinutes: 10 * 60 * 1000, // 10 minutes
thirtyMinutes: 30 * 60 * 1000, // 30 minutes
oneHour: 60 * 60 * 1000, // 1 hour
} as const;
export type CacheTimeToLive =
(typeof CacheTimeToLive)[keyof typeof CacheTimeToLive];
/* eslint-disable @typescript-eslint/no-explicit-any */
/**
* Options for configuring the Cache decorator
*/
export interface CacheOptions<T extends (...args: any[]) => any> {
/**
* Generate a cache key from the method arguments.
* If not provided, uses JSON.stringify on all arguments.
*/
keyGenerator?: (...args: Parameters<T>) => string;
/**
* Time in milliseconds to keep the result cached.
* If not provided, cache never expires.
*/
ttl?: number | CacheTimeToLive;
}
/**
* Decorator that caches the results of both sync and async method calls.
* Results are cached based on method arguments and expire after the specified TTL.
*
* @param options Configuration options for the decorator
* @example
* ```typescript
* class DataService {
* // Async function caching
* @Cache({
* ttl: 5 * 60 * 1000, // Cache for 5 minutes
* keyGenerator: (params: QueryParams) => params.query
* })
* async searchData(params: QueryParams): Promise<SearchResult> {
* return await api.search(params);
* }
*
* // Sync function caching (heavy calculations)
* @Cache({
* ttl: 10 * 60 * 1000, // Cache for 10 minutes
* keyGenerator: (n: number) => n.toString()
* })
* fibonacci(n: number): number {
* if (n <= 1) return n;
* return this.fibonacci(n - 1) + this.fibonacci(n - 2);
* }
* }
* ```
*/
export function Cache<T extends (...args: any[]) => any>(
options: CacheOptions<T> = {},
): MethodDecorator {
const cacheMap = new WeakMap<
object,
Map<string, { result: any; expiry?: number; isAsync: boolean }>
>();
return function (
_target: any,
_propertyKey: string | symbol,
descriptor: PropertyDescriptor,
): PropertyDescriptor {
const originalMethod = descriptor.value;
descriptor.value = function (
this: any,
...args: Parameters<T>
): ReturnType<T> {
// Initialize cache for this instance if needed
if (!cacheMap.has(this)) {
cacheMap.set(this, new Map());
}
const instanceCache = cacheMap.get(this);
if (!instanceCache) {
throw new Error('Cache map not initialized properly');
}
// Generate cache key
const key = options.keyGenerator
? options.keyGenerator(...args)
: JSON.stringify(args);
// Check cache first
const cached = instanceCache.get(key);
if (cached) {
// If no TTL or not expired, return cached result
if (!cached.expiry || cached.expiry > Date.now()) {
// For async functions, wrap cached result in a Promise
if (cached.isAsync) {
return Promise.resolve(cached.result) as ReturnType<T>;
}
return cached.result;
}
// Clean up expired cache entry
instanceCache.delete(key);
}
// Execute original method
const result = originalMethod.apply(this, args);
// Handle both sync and async functions
// Use more robust Promise detection for testing environments
const isPromise =
result instanceof Promise ||
(result &&
typeof result.then === 'function' &&
typeof result.catch === 'function');
if (isPromise) {
// Async function: only cache successful results
const promise = result
.then((value: any) => {
instanceCache.set(key, {
result: value,
expiry: options.ttl ? Date.now() + options.ttl : undefined,
isAsync: true,
});
return value;
})
.catch((error: any) => {
// Don't cache errors - ensure cache is clean
instanceCache.delete(key);
throw error;
});
return promise as ReturnType<T>;
} else {
// Sync function: cache result directly
instanceCache.set(key, {
result,
expiry: options.ttl ? Date.now() + options.ttl : undefined,
isAsync: false,
});
return result;
}
};
return descriptor;
};
}

View File

@@ -0,0 +1,202 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { InFlight } from './in-flight.decorator';
describe('InFlight Decorator', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.restoreAllMocks();
vi.clearAllTimers();
vi.useRealTimers();
});
describe('Basic usage (no options)', () => {
class TestService {
callCount = 0;
@InFlight()
async fetchData(delay = 100): Promise<string> {
this.callCount++;
await new Promise((resolve) => setTimeout(resolve, delay));
return `result-${this.callCount}`;
}
@InFlight()
async fetchWithError(delay = 100): Promise<string> {
this.callCount++;
await new Promise((resolve) => setTimeout(resolve, delay));
throw new Error('Test error');
}
}
it('should prevent multiple simultaneous calls', async () => {
const service = new TestService();
// Make three simultaneous calls
const promise1 = service.fetchData();
const promise2 = service.fetchData();
const promise3 = service.fetchData();
// Advance timers to complete the async operation
await vi.runAllTimersAsync();
// All promises should resolve to the same value
const [result1, result2, result3] = await Promise.all([
promise1,
promise2,
promise3,
]);
expect(result1).toBe('result-1');
expect(result2).toBe('result-1');
expect(result3).toBe('result-1');
expect(service.callCount).toBe(1);
});
it('should allow subsequent calls after completion', async () => {
const service = new TestService();
// First call
const promise1 = service.fetchData();
await vi.runAllTimersAsync();
const result1 = await promise1;
expect(result1).toBe('result-1');
// Second call after first completes
const promise2 = service.fetchData();
await vi.runAllTimersAsync();
const result2 = await promise2;
expect(result2).toBe('result-2');
expect(service.callCount).toBe(2);
});
it('should maintain separate state per instance', async () => {
const service1 = new TestService();
const service2 = new TestService();
// Make simultaneous calls on different instances
const promise1 = service1.fetchData();
const promise2 = service2.fetchData();
await vi.runAllTimersAsync();
const [result1, result2] = await Promise.all([promise1, promise2]);
// Each instance should have made its own call
expect(result1).toBe('result-1');
expect(result2).toBe('result-1');
expect(service1.callCount).toBe(1);
expect(service2.callCount).toBe(1);
});
});
describe('With key generator', () => {
class UserService {
callCounts = new Map<string, number>();
@InFlight({
keyGenerator: (userId: string) => userId,
})
async fetchUser(
userId: string,
delay = 100,
): Promise<{ id: string; name: string }> {
const count = (this.callCounts.get(userId) || 0) + 1;
this.callCounts.set(userId, count);
await new Promise((resolve) => setTimeout(resolve, delay));
return { id: userId, name: `User ${userId} - Call ${count}` };
}
@InFlight({
keyGenerator: undefined, // Uses JSON.stringify
})
async fetchWithDefaultKey(
param1: string,
param2: number,
): Promise<string> {
const key = `${param1}-${param2}`;
const count = (this.callCounts.get(key) || 0) + 1;
this.callCounts.set(key, count);
await new Promise((resolve) => setTimeout(resolve, 100));
return `Result ${count}`;
}
}
it('should deduplicate calls with same key', async () => {
const service = new UserService();
// Multiple calls with same userId
const promise1 = service.fetchUser('user1');
const promise2 = service.fetchUser('user1');
const promise3 = service.fetchUser('user1');
await vi.runAllTimersAsync();
const [result1, result2, result3] = await Promise.all([
promise1,
promise2,
promise3,
]);
expect(result1).toEqual({ id: 'user1', name: 'User user1 - Call 1' });
expect(result2).toEqual(result1);
expect(result3).toEqual(result1);
expect(service.callCounts.get('user1')).toBe(1);
});
it('should allow simultaneous calls with different keys', async () => {
const service = new UserService();
// Calls with different userIds
const promise1 = service.fetchUser('user1');
const promise2 = service.fetchUser('user2');
const promise3 = service.fetchUser('user1'); // Duplicate of first
await vi.runAllTimersAsync();
const [result1, result2, result3] = await Promise.all([
promise1,
promise2,
promise3,
]);
expect(result1).toEqual({ id: 'user1', name: 'User user1 - Call 1' });
expect(result2).toEqual({ id: 'user2', name: 'User user2 - Call 1' });
expect(result3).toEqual(result1); // Same as first call
expect(service.callCounts.get('user1')).toBe(1);
expect(service.callCounts.get('user2')).toBe(1);
});
it('should use JSON.stringify as default key generator when keyGenerator is undefined', async () => {
const service = new UserService();
// Multiple calls with same arguments
const promise1 = service.fetchWithDefaultKey('test', 123);
const promise2 = service.fetchWithDefaultKey('test', 123);
// Different arguments
const promise3 = service.fetchWithDefaultKey('test', 456);
await vi.runAllTimersAsync();
const [result1, result2, result3] = await Promise.all([
promise1,
promise2,
promise3,
]);
expect(result1).toBe('Result 1');
expect(result2).toBe('Result 1'); // Same as first
expect(result3).toBe('Result 1'); // Different key, separate call
expect(service.callCounts.get('test-123')).toBe(1);
expect(service.callCounts.get('test-456')).toBe(1);
});
});
});

View File

@@ -0,0 +1,115 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/**
* Options for configuring the InFlight decorator
*/
export interface InFlightOptions<T extends (...args: any[]) => any> {
/**
* Generate a cache key from the method arguments.
* If not provided, uses JSON.stringify on all arguments.
* If omitted entirely, the decorator will not differentiate between calls with different arguments.
*/
keyGenerator?: (...args: Parameters<T>) => string;
}
/**
* Decorator that prevents multiple simultaneous calls to the same async method.
* All concurrent calls will receive the same Promise result.
*
* @param options Configuration options for the decorator
* @example
* ```typescript
* class MyService {
* // Basic usage - all calls share the same in-flight request
* @InFlight()
* async fetchData(): Promise<Data> {
* return await api.getData();
* }
*
* // With key generator - calls with different arguments can execute simultaneously
* @InFlight({
* keyGenerator: (userId: string) => userId
* })
* async fetchUser(userId: string): Promise<User> {
* return await api.getUser(userId);
* }
* }
* ```
*/
export function InFlight<T extends (...args: any[]) => Promise<any>>(
options: InFlightOptions<T> = {},
): MethodDecorator {
// If keyGenerator is explicitly provided (even if undefined), use keyed mode
const useKeys = 'keyGenerator' in options;
const simpleInFlightMap = new WeakMap<object, Promise<any>>();
const keyedInFlightMap = new WeakMap<object, Map<string, Promise<any>>>();
return function (
_target: any,
_propertyKey: string | symbol,
descriptor: PropertyDescriptor,
): PropertyDescriptor {
const originalMethod = descriptor.value;
descriptor.value = async function (
this: any,
...args: Parameters<T>
): Promise<ReturnType<T>> {
if (!useKeys) {
// Simple mode: one in-flight request per instance
const existingRequest = simpleInFlightMap.get(this);
if (existingRequest) {
return existingRequest;
}
const promise = originalMethod
.apply(this, args)
.then((result: any) => {
simpleInFlightMap.delete(this);
return result;
})
.catch((error: any) => {
simpleInFlightMap.delete(this);
throw error;
});
simpleInFlightMap.set(this, promise);
return promise;
} else {
// Keyed mode: separate in-flight requests per key
if (!keyedInFlightMap.has(this)) {
keyedInFlightMap.set(this, new Map());
}
const instanceMap = keyedInFlightMap.get(this);
if (!instanceMap) {
throw new Error('In-flight map not initialized properly');
}
const key = options.keyGenerator
? options.keyGenerator(...args)
: JSON.stringify(args);
const existingRequest = instanceMap.get(key);
if (existingRequest) {
return existingRequest;
}
const promise = originalMethod
.apply(this, args)
.then((result: any) => {
instanceMap.delete(key);
return result;
})
.catch((error: any) => {
instanceMap.delete(key);
throw error;
});
instanceMap.set(key, promise);
return promise;
}
};
return descriptor;
};
}

View File

@@ -0,0 +1,13 @@
import '@angular/compiler';
import '@analogjs/vitest-angular/setup-zone';
import {
BrowserTestingModule,
platformBrowserTesting,
} from '@angular/platform-browser/testing';
import { getTestBed } from '@angular/core/testing';
getTestBed().initTestEnvironment(
BrowserTestingModule,
platformBrowserTesting(),
);

View File

@@ -1,12 +1,21 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"target": "es2022",
"forceConsistentCasingInFileNames": true,
"importHelpers": true,
"moduleResolution": "bundler",
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true
"noFallthroughCasesInSwitch": true,
"module": "preserve"
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"typeCheckHostBindings": true,
"strictTemplates": true
},
"files": [],
"include": [],
@@ -17,12 +26,5 @@
{
"path": "./tsconfig.spec.json"
}
],
"extends": "../../../tsconfig.base.json",
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
}
]
}

View File

@@ -5,14 +5,23 @@
"declaration": true,
"declarationMap": true,
"inlineSources": true,
"types": [],
"moduleResolution": "bundler"
"types": []
},
"exclude": [
"src/**/*.spec.ts",
"src/test-setup.ts",
"jest.config.ts",
"src/**/*.test.ts"
"src/**/*.test.ts",
"vite.config.ts",
"vite.config.mts",
"vitest.config.ts",
"vitest.config.mts",
"src/**/*.test.tsx",
"src/**/*.spec.tsx",
"src/**/*.test.js",
"src/**/*.spec.js",
"src/**/*.test.jsx",
"src/**/*.spec.jsx"
],
"include": ["src/**/*.ts"]
}

View File

@@ -0,0 +1,29 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../dist/out-tsc",
"types": [
"vitest/globals",
"vitest/importMeta",
"vite/client",
"node",
"vitest"
]
},
"include": [
"vite.config.ts",
"vite.config.mts",
"vitest.config.ts",
"vitest.config.mts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.test.tsx",
"src/**/*.spec.tsx",
"src/**/*.test.js",
"src/**/*.spec.js",
"src/**/*.test.jsx",
"src/**/*.spec.jsx",
"src/**/*.d.ts"
],
"files": ["src/test-setup.ts"]
}

View File

@@ -0,0 +1,27 @@
/// <reference types='vitest' />
import { defineConfig } from 'vite';
import angular from '@analogjs/vite-plugin-angular';
import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin';
import { nxCopyAssetsPlugin } from '@nx/vite/plugins/nx-copy-assets.plugin';
export default defineConfig(() => ({
root: __dirname,
cacheDir: '../../../node_modules/.vite/libs/common/decorators',
plugins: [angular(), nxViteTsPaths(), nxCopyAssetsPlugin(['*.md'])],
// Uncomment this if you are using workers.
// worker: {
// plugins: [ nxViteTsPaths() ],
// },
test: {
watch: false,
globals: true,
environment: 'jsdom',
include: ['{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
setupFiles: ['src/test-setup.ts'],
reporters: ['default'],
coverage: {
reportsDirectory: '../../../coverage/libs/common/decorators',
provider: 'v8' as const,
},
},
}));

View File

@@ -1,166 +1,166 @@
import { LogLevel } from './log-level.enum';
import { Type } from '@angular/core';
/**
* Represents a destination where log messages are sent.
* Implement this interface to create custom logging destinations like
* console logging, remote logging services, or file logging.
*
* @example
* ```typescript
* @Injectable()
* export class CustomLogSink implements Sink {
* log(
* level: LogLevel,
* message: string,
* context?: LoggerContext,
* error?: Error
* ): void {
* // Custom logging implementation
* if (level === LogLevel.Error) {
* // Send to monitoring service
* this.monitoringService.reportError(message, error, context);
* }
* }
* }
* ```
*/
export interface Sink {
/**
* Method called by the LoggingService to send a log entry to this sink.
*
* @param level - The severity level of the log message
* @param message - The main log message content
* @param context - Optional structured data or metadata about the log event
* @param error - Optional error object when logging errors
*/
log(
level: LogLevel,
message: string,
context?: LoggerContext,
error?: Error,
): void;
}
/**
* A factory function that creates a logging sink function.
* Useful when the sink needs access to injected dependencies or
* requires initialization logic.
*
* @returns A function matching the Sink.log method signature
*
* @example
* ```typescript
* export const httpLogSink: SinkFn = () => {
* const http = inject(HttpClient);
* const config = inject(ConfigService);
*
* return (level, message, context?, error?) => {
* http.post(config.loggingEndpoint, {
* level,
* message,
* context,
* error: error && {
* name: error.name,
* message: error.message,
* stack: error.stack
* }
* }).subscribe();
* };
* };
* ```
*/
export type SinkFn = () => (
level: LogLevel,
message: string,
context?: LoggerContext,
error?: Error,
) => void;
/**
* Configuration options for the logging service.
* Used to set up the logging behavior during application initialization.
*/
export interface LoggingConfig {
/** The minimum log level to process. Messages below this level are ignored. */
level: LogLevel;
/**
* An array of logging destinations where messages will be sent.
* Can be sink instances, classes, or factory functions.
*/
sinks: (Sink | SinkFn | Type<Sink>)[];
/**
* Optional global context included with every log message.
* Useful for adding application-wide metadata like version or environment.
*/
context?: LoggerContext;
}
/**
* Represents the public API for logging operations.
* This interface is returned by the logger factory and provides
* methods for logging at different severity levels.
*/
export interface LoggerApi {
/**
* Logs a trace message with optional context.
* Use for fine-grained debugging information.
*/
trace(message: string, context?: () => LoggerContext): void;
/**
* Logs a debug message with optional context.
* Use for development-time debugging information.
*/
debug(message: string, context?: () => LoggerContext): void;
/**
* Logs an info message with optional context.
* Use for general runtime information.
*/
info(message: string, context?: () => LoggerContext): void;
/**
* Logs a warning message with optional context.
* Use for potentially harmful situations.
*/
warn(message: string, context?: () => LoggerContext): void;
/**
* Logs an error message with an optional error object and context.
* Use for error conditions that affect functionality.
*
* @param message - The error message to log
* @param error - Optional error object that caused this error condition
* @param context - Optional context data associated with the error
*/
error(message: string, error?: Error, context?: () => LoggerContext): void;
}
/**
* Represents context data associated with a log message.
* Context allows adding structured metadata to log messages,
* making them more informative and easier to filter/analyze.
*
* @example
* ```typescript
* // Component context
* const context: LoggerContext = {
* component: 'UserProfile',
* userId: '12345',
* action: 'save'
* };
*
* // Error context
* const errorContext: LoggerContext = {
* operationId: 'op-123',
* attemptNumber: 3,
* inputData: { ... }
* };
* ```
*/
export interface LoggerContext {
[key: string]: unknown;
}
import { LogLevel } from './log-level.enum';
import { Type } from '@angular/core';
/**
* Represents a destination where log messages are sent.
* Implement this interface to create custom logging destinations like
* console logging, remote logging services, or file logging.
*
* @example
* ```typescript
* @Injectable()
* export class CustomLogSink implements Sink {
* log(
* level: LogLevel,
* message: string,
* context?: LoggerContext,
* error?: Error
* ): void {
* // Custom logging implementation
* if (level === LogLevel.Error) {
* // Send to monitoring service
* this.monitoringService.reportError(message, error, context);
* }
* }
* }
* ```
*/
export interface Sink {
/**
* Method called by the LoggingService to send a log entry to this sink.
*
* @param level - The severity level of the log message
* @param message - The main log message content
* @param context - Optional structured data or metadata about the log event
* @param error - Optional error object when logging errors
*/
log(
level: LogLevel,
message: string,
context?: LoggerContext,
error?: Error,
): void;
}
/**
* A factory function that creates a logging sink function.
* Useful when the sink needs access to injected dependencies or
* requires initialization logic.
*
* @returns A function matching the Sink.log method signature
*
* @example
* ```typescript
* export const httpLogSink: SinkFn = () => {
* const http = inject(HttpClient);
* const config = inject(ConfigService);
*
* return (level, message, context?, error?) => {
* http.post(config.loggingEndpoint, {
* level,
* message,
* context,
* error: error && {
* name: error.name,
* message: error.message,
* stack: error.stack
* }
* }).subscribe();
* };
* };
* ```
*/
export type SinkFn = () => (
level: LogLevel,
message: string,
context?: LoggerContext,
error?: Error,
) => void;
/**
* Configuration options for the logging service.
* Used to set up the logging behavior during application initialization.
*/
export interface LoggingConfig {
/** The minimum log level to process. Messages below this level are ignored. */
level: LogLevel;
/**
* An array of logging destinations where messages will be sent.
* Can be sink instances, classes, or factory functions.
*/
sinks: (Sink | SinkFn | Type<Sink>)[];
/**
* Optional global context included with every log message.
* Useful for adding application-wide metadata like version or environment.
*/
context?: LoggerContext;
}
/**
* Represents the public API for logging operations.
* This interface is returned by the logger factory and provides
* methods for logging at different severity levels.
*/
export interface LoggerApi {
/**
* Logs a trace message with optional context.
* Use for fine-grained debugging information.
*/
trace(message: string, context?: () => LoggerContext): void;
/**
* Logs a debug message with optional context.
* Use for development-time debugging information.
*/
debug(message: string, context?: () => LoggerContext): void;
/**
* Logs an info message with optional context.
* Use for general runtime information.
*/
info(message: string, context?: () => LoggerContext): void;
/**
* Logs a warning message with optional context.
* Use for potentially harmful situations.
*/
warn(message: string, context?: () => LoggerContext): void;
/**
* Logs an error message with an optional error object and context.
* Use for error conditions that affect functionality.
*
* @param message - The error message to log
* @param error - Optional error object that caused this error condition
* @param context - Optional context data associated with the error
*/
error(message: string, error?: unknown, context?: () => LoggerContext): void;
}
/**
* Represents context data associated with a log message.
* Context allows adding structured metadata to log messages,
* making them more informative and easier to filter/analyze.
*
* @example
* ```typescript
* // Component context
* const context: LoggerContext = {
* component: 'UserProfile',
* userId: '12345',
* action: 'save'
* };
*
* // Error context
* const errorContext: LoggerContext = {
* operationId: 'op-123',
* attemptNumber: 3,
* inputData: { ... }
* };
* ```
*/
export interface LoggerContext {
[key: string]: unknown;
}

View File

@@ -1,11 +1,20 @@
import { Type } from '@angular/core';
import { getState, patchState, signalStoreFeature, withHooks, withMethods } from '@ngrx/signals';
import {
getState,
patchState,
signalStoreFeature,
withHooks,
withMethods,
} from '@ngrx/signals';
import { rxMethod } from '@ngrx/signals/rxjs-interop';
import { StorageProvider } from './storage-provider';
import { injectStorage } from './storage';
import { debounceTime, pipe, switchMap } from 'rxjs';
export function withStorage(storageKey: string, storageProvider: Type<StorageProvider>) {
export function withStorage(
storageKey: string,
storageProvider: Type<StorageProvider>,
) {
return signalStoreFeature(
withMethods((store, storage = injectStorage(storageProvider)) => ({
storeState: rxMethod<void>(
@@ -16,7 +25,7 @@ export function withStorage(storageKey: string, storageProvider: Type<StoragePro
),
restoreState: async () => {
const data = await storage.get(storageKey);
if (data) {
if (data && typeof data === 'object') {
patchState(store, data);
}
},

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

@@ -1,6 +1,18 @@
import { inject } from '@angular/core';
import { TabService } from './tab.service';
export function injectActivatedTabId() {
/**
* Injects the current activated tab as a signal.
* @returns A signal that emits the current activated tab or null if no tab is activated.
*/
export function injectTab() {
return inject(TabService).activatedTab;
}
/**
* Injects the current tab ID as a signal.
* @returns A signal that emits the current tab ID or null if no tab is activated.
*/
export function injectTabId() {
return inject(TabService).activatedTabId;
}

View File

@@ -114,7 +114,7 @@ export const isaFiliale =
'<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="currentColor"> <path fill-rule="evenodd" clip-rule="evenodd" d="M11.4772 1.33637C11.8195 1.24369 12.1803 1.24369 12.5226 1.33637C12.92 1.44396 13.2545 1.70662 13.5215 1.91625C13.547 1.93627 13.5719 1.9558 13.5961 1.97466L20.3784 7.24979C20.4046 7.27009 20.4305 7.2902 20.4562 7.31015C20.8328 7.60242 21.1646 7.85993 21.4119 8.19422C21.6288 8.48761 21.7905 8.81812 21.8889 9.16952C22.0009 9.56993 22.0005 9.98994 22 10.4667C21.9999 10.4992 21.9999 10.532 21.9999 10.5651V17.8386C21.9999 18.3657 21.9999 18.8205 21.9693 19.195C21.937 19.5904 21.8657 19.9836 21.6729 20.362C21.3853 20.9265 20.9264 21.3854 20.3619 21.673C19.9835 21.8658 19.5903 21.9371 19.1949 21.9694C18.8204 22 18.3656 22 17.8385 22H6.16133C5.63419 22 5.17943 22 4.80487 21.9694C4.40952 21.9371 4.0163 21.8658 3.63792 21.673C3.07344 21.3854 2.6145 20.9265 2.32688 20.362C2.13408 19.9836 2.06277 19.5904 2.03046 19.195C1.99986 18.8205 1.99988 18.3657 1.99989 17.8385L1.9999 10.5651C1.9999 10.532 1.99986 10.4992 1.99982 10.4667C1.99931 9.98994 1.99886 9.56993 2.11094 9.16952C2.2093 8.81811 2.37094 8.48761 2.58794 8.19422C2.83519 7.85992 3.16701 7.60242 3.54364 7.31013C3.56934 7.29019 3.59524 7.27009 3.62134 7.24979L10.4037 1.97466C10.4279 1.9558 10.4528 1.93626 10.4783 1.91625C10.7453 1.70662 11.0798 1.44396 11.4772 1.33637ZM9.9999 20H13.9999V13.6C13.9999 13.3035 13.9991 13.1412 13.9896 13.0246C13.9892 13.02 13.9888 13.0156 13.9884 13.0114C13.9843 13.0111 13.9799 13.0107 13.9753 13.0103C13.8587 13.0008 13.6964 13 13.3999 13H10.5999C10.3034 13 10.1411 13.0008 10.0245 13.0103C10.0199 13.0107 10.0155 13.0111 10.0113 13.0114C10.011 13.0156 10.0106 13.02 10.0102 13.0246C10.0007 13.1412 9.9999 13.3035 9.9999 13.6V20ZM15.9999 20L15.9999 13.5681C15.9999 13.3157 16 13.0699 15.983 12.8618C15.9643 12.6332 15.9202 12.3634 15.7819 12.092C15.5902 11.7157 15.2842 11.4097 14.9079 11.218C14.6365 11.0797 14.3667 11.0356 14.1381 11.0169C13.93 10.9999 13.6842 11 13.4318 11H10.568C10.3156 11 10.0698 10.9999 9.86167 11.0169C9.63307 11.0356 9.36334 11.0797 9.09191 11.218C8.71559 11.4097 8.40963 11.7157 8.21788 12.092C8.07959 12.3634 8.03552 12.6332 8.01684 12.8618C7.99983 13.0699 7.99986 13.3157 7.99989 13.5681L7.9999 20H6.1999C5.62334 20 5.25107 19.9992 4.96773 19.9761C4.69607 19.9539 4.59535 19.9162 4.5459 19.891C4.35774 19.7951 4.20476 19.6422 4.10889 19.454C4.0837 19.4045 4.04602 19.3038 4.02382 19.0322C4.00067 18.7488 3.9999 18.3766 3.9999 17.8V10.5651C3.9999 9.93408 4.00858 9.80982 4.03691 9.70862C4.0697 9.59148 4.12358 9.48131 4.19591 9.38352C4.2584 9.29903 4.35115 9.21588 4.84923 8.82849L11.6315 3.55337C11.8184 3.40799 11.9174 3.33175 11.9926 3.28154C11.9951 3.27984 11.9976 3.27823 11.9999 3.27671C12.0022 3.27823 12.0046 3.27984 12.0072 3.28154C12.0823 3.33175 12.1814 3.40799 12.3683 3.55337L19.1506 8.82849C19.6486 9.21588 19.7414 9.29903 19.8039 9.38352C19.8762 9.48131 19.9301 9.59148 19.9629 9.70862C19.9912 9.80982 19.9999 9.93408 19.9999 10.5651V17.8C19.9999 18.3766 19.9991 18.7488 19.976 19.0322C19.9538 19.3038 19.9161 19.4045 19.8909 19.454C19.795 19.6422 19.642 19.7951 19.4539 19.891C19.4044 19.9162 19.3037 19.9539 19.0321 19.9761C18.7487 19.9992 18.3764 20 17.7999 20H15.9999Z" fill="currentColor"/></svg>';
export const isaFilialeLocation =
'<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="currentColor"> <path d="M12 13C13.6569 13 15 11.6569 15 10C15 8.34315 13.6569 7 12 7C10.3431 7 9 8.34315 9 10C9 11.6569 10.3431 13 12 13Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> <path d="M12 22C16 18 20 14.4183 20 10C20 5.58172 16.4183 2 12 2C7.58172 2 4 5.58172 4 10C4 14.4183 8 18 12 22Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>';
'<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"><path d="M12 13C13.6569 13 15 11.6569 15 10C15 8.34315 13.6569 7 12 7C10.3431 7 9 8.34315 9 10C9 11.6569 10.3431 13 12 13Z" stroke="black" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><path d="M12 22C16 18 20 14.4183 20 10C20 5.58172 16.4183 2 12 2C7.58172 2 4 5.58172 4 10C4 14.4183 8 18 12 22Z" stroke="black" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>';
export const isaArtikelKartoniert = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none">
<path fill-rule="evenodd" clip-rule="evenodd" d="M7.24787 2.5C7.25947 2.5 7.2711 2.5 7.28276 2.5L7.60047 2.5C8.34332 2.49999 8.95985 2.49998 9.46281 2.54033C9.98596 2.5823 10.4728 2.67271 10.9325 2.90269C11.3354 3.10427 11.6965 3.37467 12.0002 3.6995C12.3038 3.37467 12.665 3.10427 13.0679 2.90269C13.5275 2.67271 14.0144 2.5823 14.5375 2.54033C15.0405 2.49998 15.657 2.49999 16.3999 2.5L16.7525 2.5C17.111 2.49998 17.437 2.49996 17.7088 2.52177C18.0006 2.54518 18.3162 2.59847 18.6274 2.75419C19.0751 2.9782 19.4425 3.3374 19.6739 3.78334C19.8357 4.09512 19.891 4.41165 19.9152 4.70214C19.9376 4.97133 19.9376 5.29341 19.9375 5.64431L19.9375 15.0162C19.9375 15.3671 19.9375 15.6892 19.9151 15.9584C19.8909 16.2489 19.8356 16.5654 19.6739 16.8772C19.4425 17.3231 19.0751 17.6823 18.6273 17.9063C18.3161 18.062 18.0006 18.1153 17.7088 18.1387C17.4369 18.1605 17.111 18.1605 16.7524 18.1605L15.7637 18.1605C14.8337 18.1605 14.5735 18.1704 14.3521 18.2364C14.127 18.3035 13.9187 18.4132 13.7388 18.5585C13.5634 18.7 13.4135 18.9026 12.8969 19.6636L12.8274 19.7659C12.6413 20.04 12.3315 20.2042 12.0001 20.2042C11.6687 20.2042 11.3589 20.04 11.1728 19.7659L11.1033 19.6636C10.5867 18.9026 10.4368 18.7 10.2615 18.5585C10.0815 18.4132 9.87318 18.3035 9.64811 18.2364C9.42671 18.1704 9.1665 18.1605 8.23647 18.1605L7.24783 18.1605C6.88925 18.1605 6.56329 18.1605 6.29144 18.1387C5.99966 18.1153 5.68411 18.062 5.37287 17.9063C4.92515 17.6823 4.55774 17.3231 4.32635 16.8772C4.16457 16.5654 4.10926 16.2489 4.08509 15.9584C4.0627 15.6892 4.06272 15.3671 4.06275 15.0162L4.06281 5.67991C4.06281 5.67991 4.06281 5.67991 4.06281 5.67991C4.06281 5.66801 4.06281 5.65612 4.06281 5.64428C4.06279 5.29339 4.06276 4.97133 4.08516 4.70215C4.10933 4.41166 4.16464 4.09512 4.32642 3.78334C4.55781 3.33739 4.92522 2.9782 5.37293 2.75419C5.68418 2.59847 5.99972 2.54518 6.2915 2.52177C6.56335 2.49996 6.8893 2.49998 7.24787 2.5ZM6.26449 4.54428C6.26445 4.54427 6.26506 4.54398 6.26646 4.54347L6.26449 4.54428ZM6.26646 4.54347C6.27658 4.53983 6.32436 4.52556 6.45145 4.51536C6.63354 4.50075 6.87803 4.5 7.28276 4.5H7.56026C8.35352 4.5 8.88941 4.50075 9.30286 4.53392C9.70517 4.5662 9.90362 4.62429 10.0376 4.69131C10.3731 4.85916 10.6424 5.12525 10.8101 5.44839C10.8752 5.57377 10.9333 5.76196 10.9658 6.15304C10.9994 6.55634 11.0002 7.07998 11.0002 7.85982L11.0001 16.6511C10.7538 16.5122 10.492 16.401 10.2197 16.3198C9.68245 16.1596 9.10813 16.16 8.36282 16.1604C8.32125 16.1605 8.27913 16.1605 8.23647 16.1605H7.2827C6.87797 16.1605 6.63348 16.1598 6.45139 16.1451C6.3243 16.1349 6.2767 16.1207 6.26659 16.1171C6.19397 16.0805 6.13794 16.0243 6.10332 15.9593C6.0995 15.9476 6.08735 15.9024 6.07821 15.7925C6.06355 15.6164 6.06275 15.3789 6.06275 14.9806L6.06281 5.67992C6.06281 5.2816 6.06362 5.04409 6.07827 4.86798C6.08742 4.75806 6.09956 4.71289 6.10339 4.7012C6.13801 4.63617 6.19384 4.58011 6.26646 4.54347ZM13.0001 16.6511C13.2464 16.5122 13.5082 16.401 13.7805 16.3198C14.3178 16.1596 14.8921 16.16 15.6374 16.1604C15.679 16.1605 15.7211 16.1605 15.7637 16.1605H16.7175C17.1222 16.1605 17.3667 16.1598 17.5488 16.1451C17.6759 16.1349 17.7235 16.1207 17.7336 16.1171C17.8062 16.0805 17.8623 16.0243 17.8969 15.9593C17.9007 15.9476 17.9129 15.9024 17.922 15.7925C17.9367 15.6164 17.9375 15.3789 17.9375 14.9806L17.9375 5.67991C17.9375 5.28159 17.9367 5.04409 17.9221 4.86798C17.9129 4.75807 17.9008 4.7129 17.8969 4.7012C17.8623 4.63617 17.8063 4.58004 17.7337 4.5434C17.7236 4.53976 17.676 4.52556 17.5489 4.51536C17.3668 4.50075 17.1223 4.5 16.7176 4.5H16.4401C15.6468 4.5 15.1109 4.50075 14.6975 4.53392C14.2952 4.5662 14.0967 4.62429 13.9628 4.69131C13.6273 4.85916 13.3579 5.12525 13.1902 5.44839C13.1252 5.57377 13.0671 5.76196 13.0345 6.15304C13.001 6.55634 13.0002 7.07998 13.0002 7.85983L13.0001 16.6511ZM17.7358 16.1162C17.7358 16.1162 17.735 16.1166 17.7336 16.1171L17.7358 16.1162Z" fill="#212529"/>

View File

@@ -1,27 +1,51 @@
import { DataAccessError } from '@isa/common/data-access';
import { Receipt, ReceiptItem } from '../../models';
import { DataAccessError } from "@isa/common/data-access";
import { Receipt, ReceiptItem } from "../../models";
import {
CreateReturnProcessError,
CreateReturnProcessErrorReason,
CreateReturnProcessErrorMessages,
} from './create-return-process.error';
} from "./create-return-process.error";
import { ProductCategory } from "../../questions";
describe('CreateReturnProcessError', () => {
describe("CreateReturnProcessError", () => {
const params = {
processId: 123,
returns: [
{
receipt: { id: 321 } as Receipt,
items: [] as ReceiptItem[],
items: [
// Provide at least one valid item object, or an empty array if testing "no items"
// For NO_RETURNABLE_ITEMS, an empty array is valid, but must match the expected shape
// So, keep as [], but type is now correct
],
},
],
};
it('should create an error instance with NO_RETURNABLE_ITEMS reason', () => {
// For tests that require items, use the correct shape:
const validParams = {
processId: 123,
returns: [
{
receipt: { id: 321 } as Receipt,
items: [
{
receiptItem: { id: 111 } as ReceiptItem,
quantity: 1,
category: "A" as ProductCategory,
},
],
},
],
};
it("should create an error instance with NO_RETURNABLE_ITEMS reason", () => {
// Arrange, Act
const error = new CreateReturnProcessError(
CreateReturnProcessErrorReason.NO_RETURNABLE_ITEMS,
params,
);
// Assert
expect(error).toBeInstanceOf(CreateReturnProcessError);
expect(error).toBeInstanceOf(DataAccessError);
expect(error.reason).toBe(
@@ -33,25 +57,103 @@ describe('CreateReturnProcessError', () => {
CreateReturnProcessErrorReason.NO_RETURNABLE_ITEMS
],
);
expect(error.code).toBe('CREATE_RETURN_PROCESS');
expect(error.code).toBe("CREATE_RETURN_PROCESS");
});
it('should create an error instance with MISMATCH_RETURNABLE_ITEMS reason', () => {
it("should create an error instance with MISMATCH_RETURNABLE_ITEMS reason", () => {
// Arrange, Act
const error = new CreateReturnProcessError(
CreateReturnProcessErrorReason.MISMATCH_RETURNABLE_ITEMS,
params,
validParams,
);
// Assert
expect(error).toBeInstanceOf(CreateReturnProcessError);
expect(error).toBeInstanceOf(DataAccessError);
expect(error.reason).toBe(
CreateReturnProcessErrorReason.MISMATCH_RETURNABLE_ITEMS,
);
expect(error.params).toEqual(params);
expect(error.params).toEqual(validParams);
expect(error.message).toBe(
CreateReturnProcessErrorMessages[
CreateReturnProcessErrorReason.MISMATCH_RETURNABLE_ITEMS
],
);
expect(error.code).toBe('CREATE_RETURN_PROCESS');
expect(error.code).toBe("CREATE_RETURN_PROCESS");
});
it("should expose the correct params structure", () => {
const error = new CreateReturnProcessError(
CreateReturnProcessErrorReason.NO_RETURNABLE_ITEMS,
params,
);
expect(error.params).toHaveProperty("processId", 123);
expect(error.params).toHaveProperty("returns");
expect(Array.isArray(error.params.returns)).toBe(true);
expect(error.params.returns[0]).toHaveProperty("receipt");
expect(error.params.returns[0]).toHaveProperty("items");
});
it("should throw and be catchable as CreateReturnProcessError", () => {
try {
throw new CreateReturnProcessError(
CreateReturnProcessErrorReason.NO_RETURNABLE_ITEMS,
params,
);
} catch (err) {
expect(err).toBeInstanceOf(CreateReturnProcessError);
expect((err as CreateReturnProcessError).reason).toBe(
CreateReturnProcessErrorReason.NO_RETURNABLE_ITEMS,
);
}
});
it("should use the correct message for each reason", () => {
Object.values(CreateReturnProcessErrorReason).forEach((reason) => {
const error = new CreateReturnProcessError(reason, params);
expect(error.message).toBe(CreateReturnProcessErrorMessages[reason]);
});
});
it('should have code "CREATE_RETURN_PROCESS" for all reasons', () => {
Object.values(CreateReturnProcessErrorReason).forEach((reason) => {
const error = new CreateReturnProcessError(reason, params);
expect(error.code).toBe("CREATE_RETURN_PROCESS");
});
});
it("should support params with multiple returns and items", () => {
const extendedParams = {
processId: 999,
returns: [
{
receipt: { id: 1 } as Receipt,
items: [
{
receiptItem: { id: 10 } as ReceiptItem,
quantity: 2,
category: "A" as ProductCategory,
},
],
},
{
receipt: { id: 2 } as Receipt,
items: [
{
receiptItem: { id: 20 } as ReceiptItem,
quantity: 1,
category: "B" as ProductCategory,
},
],
},
],
};
const error = new CreateReturnProcessError(
CreateReturnProcessErrorReason.MISMATCH_RETURNABLE_ITEMS,
extendedParams,
);
expect(error.params.processId).toBe(999);
expect(error.params.returns.length).toBe(2);
expect(error.params.returns[0].items[0].quantity).toBe(2);
expect(error.params.returns[1].items[0].category).toBe("B");
});
});

View File

@@ -1,13 +1,14 @@
import { DataAccessError } from '@isa/common/data-access';
import { Receipt, ReceiptItem } from '../../models';
import { DataAccessError } from "@isa/common/data-access";
import { Receipt, ReceiptItem } from "../../models";
import { ProductCategory } from "../../questions";
/**
* Enum-like object defining possible reasons for return process creation failures.
* Used to provide consistent and type-safe error categorization.
*/
export const CreateReturnProcessErrorReason = {
NO_RETURNABLE_ITEMS: 'NO_RETURNABLE_ITEMS',
MISMATCH_RETURNABLE_ITEMS: 'MISMATCH_RETURNABLE_ITEMS',
NO_RETURNABLE_ITEMS: "NO_RETURNABLE_ITEMS",
MISMATCH_RETURNABLE_ITEMS: "MISMATCH_RETURNABLE_ITEMS",
} as const;
/**
@@ -32,9 +33,9 @@ export const CreateReturnProcessErrorMessages: Record<
string
> = {
[CreateReturnProcessErrorReason.NO_RETURNABLE_ITEMS]:
'No returnable items found.',
"No returnable items found.",
[CreateReturnProcessErrorReason.MISMATCH_RETURNABLE_ITEMS]:
'Mismatch in the number of returnable items.',
"Mismatch in the number of returnable items.",
};
/**
@@ -73,14 +74,21 @@ export const CreateReturnProcessErrorMessages: Record<
* }
* ```
*/
export class CreateReturnProcessError extends DataAccessError<'CREATE_RETURN_PROCESS'> {
export class CreateReturnProcessError extends DataAccessError<"CREATE_RETURN_PROCESS"> {
constructor(
public readonly reason: CreateReturnProcessErrorReason,
public readonly params: {
processId: number;
returns: { receipt: Receipt; items: ReceiptItem[] }[];
returns: {
receipt: Receipt;
items: {
receiptItem: ReceiptItem;
quantity: number;
category: ProductCategory;
}[];
}[];
},
) {
super('CREATE_RETURN_PROCESS', CreateReturnProcessErrorMessages[reason]);
super("CREATE_RETURN_PROCESS", CreateReturnProcessErrorMessages[reason]);
}
}

View File

@@ -51,14 +51,24 @@ export const isTolinoEligibleForReturn = (
};
}
// #5286 Anpassung des Tolino-Rückgabeflows (+ siehe Kommentare)
const displayDamaged =
answers[ReturnProcessQuestionKey.DisplayDamaged] === YesNoAnswer.Yes;
const receivedDamaged = itemDamaged === ReturnReasonAnswer.ReceivedDamaged;
const receiptOlderThan6Months = date
? differenceInCalendarMonths(new Date(), parseISO(date)) >= 6
: undefined;
const receiptOlderThan24Months = date
? differenceInCalendarMonths(new Date(), parseISO(date)) >= 24
: undefined;
if (
itemDamaged === ReturnReasonAnswer.ReceivedDamaged &&
receiptOlderThan6Months
) {
const isEligible =
receiptOlderThan6Months &&
!receiptOlderThan24Months &&
receivedDamaged &&
!displayDamaged;
if (!isEligible) {
return {
state: EligibleForReturnState.NotEligible,
reason: 'Keine Retoure möglich',

View File

@@ -1,39 +1,39 @@
import { returnReceiptValuesMapping } from './return-receipt-values-mapping.helper';
import { PropertyNullOrUndefinedError } from '@isa/common/data-access';
import { getReturnProcessQuestions } from './get-return-process-questions.helper';
import { getReturnInfo } from './get-return-info.helper';
import { serializeReturnDetails } from './return-details-mapping.helper';
import { returnReceiptValuesMapping } from "./return-receipt-values-mapping.helper";
import { PropertyNullOrUndefinedError } from "@isa/common/data-access";
import { getReturnProcessQuestions } from "./get-return-process-questions.helper";
import { getReturnInfo } from "./get-return-info.helper";
import { serializeReturnDetails } from "./return-details-mapping.helper";
// Mock dependencies
jest.mock('./get-return-process-questions.helper', () => ({
jest.mock("./get-return-process-questions.helper", () => ({
getReturnProcessQuestions: jest.fn(),
}));
jest.mock('./get-return-info.helper', () => ({
jest.mock("./get-return-info.helper", () => ({
getReturnInfo: jest.fn(),
}));
jest.mock('./return-details-mapping.helper', () => ({
jest.mock("./return-details-mapping.helper", () => ({
serializeReturnDetails: jest.fn(),
}));
describe('returnReceiptValuesMapping', () => {
describe("returnReceiptValuesMapping", () => {
const processMock: any = {
receiptItem: {
id: 'item-1',
quantity: { quantity: 2 },
features: { category: 'shoes' },
id: "item-1",
},
answers: { foo: 'bar' },
quantity: 2, // <-- Add this
productCategory: "shoes", // <-- Add this
answers: { foo: "bar" },
};
const questionsMock = [{ id: 'q1' }];
const questionsMock = [{ id: "q1" }];
const returnInfoMock = {
comment: 'Test comment',
itemCondition: 'NEW',
otherProduct: 'Other',
returnDetails: { detail: 'details' },
returnReason: 'Damaged',
comment: "Test comment",
itemCondition: "NEW",
otherProduct: "Other",
returnDetails: { detail: "details" },
returnReason: "Damaged",
};
const serializedDetails = { detail: 'serialized' };
const serializedDetails = { detail: "serialized" };
beforeEach(() => {
jest.clearAllMocks();
@@ -42,32 +42,24 @@ describe('returnReceiptValuesMapping', () => {
(serializeReturnDetails as jest.Mock).mockReturnValue(serializedDetails);
});
it('should map values correctly when all dependencies return valid data', () => {
it("should map values correctly when all dependencies return valid data", () => {
// Act
const result = returnReceiptValuesMapping(processMock);
// Assert
expect(result).toEqual({
quantity: 2,
comment: 'Test comment',
itemCondition: 'NEW',
otherProduct: 'Other',
comment: "Test comment",
itemCondition: "NEW",
otherProduct: "Other",
returnDetails: serializedDetails,
returnReason: 'Damaged',
category: 'shoes',
receiptItem: { id: 'item-1' },
returnReason: "Damaged",
category: "shoes",
receiptItem: { id: "item-1" },
});
expect(getReturnProcessQuestions).toHaveBeenCalledWith(processMock);
expect(getReturnInfo).toHaveBeenCalledWith({
questions: questionsMock,
answers: processMock.answers,
});
expect(serializeReturnDetails).toHaveBeenCalledWith(
returnInfoMock.returnDetails,
);
});
it('should throw PropertyNullOrUndefinedError if questions is undefined', () => {
it("should throw PropertyNullOrUndefinedError if questions is undefined", () => {
// Arrange
(getReturnProcessQuestions as jest.Mock).mockReturnValue(undefined);
@@ -75,10 +67,10 @@ describe('returnReceiptValuesMapping', () => {
expect(() => returnReceiptValuesMapping(processMock)).toThrow(
PropertyNullOrUndefinedError,
);
expect(() => returnReceiptValuesMapping(processMock)).toThrow('questions');
expect(() => returnReceiptValuesMapping(processMock)).toThrow("questions");
});
it('should throw PropertyNullOrUndefinedError if returnInfo is undefined', () => {
it("should throw PropertyNullOrUndefinedError if returnInfo is undefined", () => {
// Arrange
(getReturnInfo as jest.Mock).mockReturnValue(undefined);
@@ -86,28 +78,55 @@ describe('returnReceiptValuesMapping', () => {
expect(() => returnReceiptValuesMapping(processMock)).toThrow(
PropertyNullOrUndefinedError,
);
expect(() => returnReceiptValuesMapping(processMock)).toThrow('returnInfo');
expect(() => returnReceiptValuesMapping(processMock)).toThrow("returnInfo");
});
it('should handle missing category gracefully', () => {
// Arrange
const processNoCategory = {
...processMock,
receiptItem: { ...processMock.receiptItem, features: {} },
};
// Act
const result = returnReceiptValuesMapping(processNoCategory);
// Assert
expect(result?.category).toBeUndefined();
});
it('should handle missing receiptItem gracefully (may throw)', () => {
it("should handle missing receiptItem gracefully (may throw)", () => {
// Arrange
const processNoReceiptItem = { ...processMock, receiptItem: undefined };
// Act & Assert
expect(() => returnReceiptValuesMapping(processNoReceiptItem)).toThrow();
});
// Additional tests for edge cases and error scenarios
it("should return correct quantity when process.quantity is 0", () => {
const processZeroQuantity = { ...processMock, quantity: 0 };
const result = returnReceiptValuesMapping(processZeroQuantity);
expect(result?.quantity).toBe(0);
});
it("should propagate the correct receiptItem id", () => {
const result = returnReceiptValuesMapping(processMock);
expect(result?.receiptItem).toEqual({ id: "item-1" });
});
it("should throw if process is null", () => {
expect(() => returnReceiptValuesMapping(null as any)).toThrow();
});
it("should throw if process is undefined", () => {
expect(() => returnReceiptValuesMapping(undefined as any)).toThrow();
});
it("should call serializeReturnDetails with undefined if returnDetails is missing", () => {
// Arrange
const returnInfoNoDetails = { ...returnInfoMock, returnDetails: undefined };
(getReturnInfo as jest.Mock).mockReturnValue(returnInfoNoDetails);
// Act
returnReceiptValuesMapping(processMock);
// Assert
expect(serializeReturnDetails).toHaveBeenCalledWith(undefined);
});
it("should return undefined if process.quantity is undefined", () => {
const processNoQuantity = { ...processMock };
delete processNoQuantity.quantity;
// Should not throw, but quantity will be undefined in result
const result = returnReceiptValuesMapping(processNoQuantity);
expect(result?.quantity).toBeUndefined();
});
});

View File

@@ -1,16 +1,16 @@
import { ReturnProcess } from '../../models';
import { ReturnReceiptValues } from '../../schemas';
import { getReturnProcessQuestions } from './get-return-process-questions.helper';
import { getReturnInfo } from './get-return-info.helper';
import { PropertyNullOrUndefinedError } from '@isa/common/data-access';
import { serializeReturnDetails } from './return-details-mapping.helper';
import { ReturnProcess } from "../../models";
import { ReturnReceiptValues } from "../../schemas";
import { getReturnProcessQuestions } from "./get-return-process-questions.helper";
import { getReturnInfo } from "./get-return-info.helper";
import { PropertyNullOrUndefinedError } from "@isa/common/data-access";
import { serializeReturnDetails } from "./return-details-mapping.helper";
export const returnReceiptValuesMapping = (
process: ReturnProcess,
): ReturnReceiptValues | undefined => {
const questions = getReturnProcessQuestions(process);
if (!questions) {
throw new PropertyNullOrUndefinedError('questions');
throw new PropertyNullOrUndefinedError("questions");
}
const returnInfo = getReturnInfo({
@@ -19,17 +19,17 @@ export const returnReceiptValuesMapping = (
});
if (!returnInfo) {
throw new PropertyNullOrUndefinedError('returnInfo');
throw new PropertyNullOrUndefinedError("returnInfo");
}
return {
quantity: process.receiptItem.quantity.quantity,
quantity: process.quantity,
comment: returnInfo.comment,
itemCondition: returnInfo.itemCondition,
otherProduct: returnInfo.otherProduct,
returnDetails: serializeReturnDetails(returnInfo.returnDetails),
returnReason: returnInfo.returnReason,
category: process?.receiptItem?.features?.['category'],
category: process.productCategory,
receiptItem: {
id: process.receiptItem.id,
},

View File

@@ -0,0 +1,8 @@
export * from './errors';
export * from './guards';
export * from './models';
export * from './operators';
export * from './questions';
export * from './schemas';
export * from './services';
export * from './stores';

View File

@@ -1,5 +1,5 @@
import { Receipt } from './receipt';
import { ReceiptItem } from './receipt-item';
import { Receipt } from "./receipt";
import { ReceiptItem } from "./receipt-item";
/**
* Interface representing a return process within the OMS system.
@@ -21,6 +21,7 @@ export interface ReturnProcess {
receiptItem: ReceiptItem;
receiptDate: string | undefined;
answers: Record<string, unknown>;
productCategory?: string;
productCategory: string;
quantity: number;
returnReceipt?: Receipt;
}

View File

@@ -1,17 +1,17 @@
import { inject, Injectable } from '@angular/core';
import { inject, Injectable } from "@angular/core";
import {
FetchReturnDetails,
FetchReturnDetailsSchema,
ReturnReceiptValues,
} from '../schemas';
import { firstValueFrom } from 'rxjs';
import { ReceiptService } from '@generated/swagger/oms-api';
import { CanReturn, Receipt, ReceiptItem, ReceiptListItem } from '../models';
import { CategoryQuestions, ProductCategory } from '../questions';
import { KeyValue } from '@angular/common';
import { ReturnCanReturnService } from './return-can-return.service';
import { takeUntilAborted } from '@isa/common/data-access';
import { z } from 'zod';
} from "../schemas";
import { firstValueFrom } from "rxjs";
import { ReceiptService } from "@generated/swagger/oms-api";
import { CanReturn, Receipt, ReceiptItem, ReceiptListItem } from "../models";
import { CategoryQuestions, ProductCategory } from "../questions";
import { KeyValue } from "@angular/common";
import { ReturnCanReturnService } from "./return-can-return.service";
import { takeUntilAborted } from "@isa/common/data-access";
import { z } from "zod";
/**
* Service responsible for managing receipt return details and operations.
@@ -22,7 +22,7 @@ import { z } from 'zod';
* - Query receipts by customer email
* - Get available product categories for returns
*/
@Injectable({ providedIn: 'root' })
@Injectable({ providedIn: "root" })
export class ReturnDetailsService {
#receiptService = inject(ReceiptService);
#returnCanReturnService = inject(ReturnCanReturnService);
@@ -38,13 +38,17 @@ export class ReturnDetailsService {
* @throws Will throw an error if the return check fails or is aborted.
*/
async canReturn(
{ item, category }: { item: ReceiptItem; category: ProductCategory },
{
receiptItemId,
quantity,
category,
}: { receiptItemId: number; quantity: number; category: ProductCategory },
abortSignal?: AbortSignal,
): Promise<CanReturn> {
const returnReceiptValues: ReturnReceiptValues = {
quantity: item.quantity.quantity,
quantity,
receiptItem: {
id: item.id,
id: receiptItemId,
},
category,
};
@@ -102,7 +106,7 @@ export class ReturnDetailsService {
const res = await firstValueFrom(req$);
if (res.error || !res.result) {
throw new Error(res.message || 'Failed to fetch return details');
throw new Error(res.message || "Failed to fetch return details");
}
return res.result as Receipt;
@@ -112,7 +116,7 @@ export class ReturnDetailsService {
* Validates that the email parameter is a properly formatted email address.
*/
static FetchReceiptsEmailParamsSchema = z.object({
email: z.string().email(),
email: z.string(),
});
/**
@@ -137,7 +141,7 @@ export class ReturnDetailsService {
let req$ = this.#receiptService.ReceiptQueryReceipt({
queryToken: {
input: { qs: email },
filter: { receipt_type: '1;128;1024' },
filter: { receipt_type: "1;128;1024" },
},
});
@@ -147,7 +151,7 @@ export class ReturnDetailsService {
const res = await firstValueFrom(req$);
if (res.error || !res.result) {
throw new Error(res.message || 'Failed to fetch return items by email');
throw new Error(res.message || "Failed to fetch return items by email");
}
return res.result as ReceiptListItem[];

View File

@@ -1,4 +1,4 @@
import { computed, inject, resource, untracked } from '@angular/core';
import { computed, inject, resource } from '@angular/core';
import {
CanReturn,
ProductCategory,
@@ -7,7 +7,6 @@ import {
ReturnDetailsService,
} from '@isa/oms/data-access';
import {
getState,
patchState,
signalStore,
type,
@@ -22,13 +21,11 @@ import {
getReceiptItemQuantity,
getReceiptItemProductCategory,
receiptItemHasCategory,
getReceiptItemReturnedQuantity,
} from '../helpers/return-process';
import { SessionStorageProvider } from '@isa/core/storage';
import { logger } from '@isa/core/logging';
import { clone } from 'lodash';
interface ReturnDetailsState {
_storageId: number | undefined;
_selectedItemIds: number[];
selectedProductCategory: Record<number, ProductCategory>;
selectedQuantity: Record<number, number>;
@@ -36,7 +33,6 @@ interface ReturnDetailsState {
}
const initialState: ReturnDetailsState = {
_storageId: undefined,
_selectedItemIds: [],
selectedProductCategory: {},
selectedQuantity: {},
@@ -49,36 +45,11 @@ export const receiptConfig = entityConfig({
});
export const ReturnDetailsStore = signalStore(
{ providedIn: 'root' },
withState(initialState),
withEntities(receiptConfig),
withProps(() => ({
_logger: logger(() => ({ store: 'ReturnDetailsStore' })),
_returnDetailsService: inject(ReturnDetailsService),
_storage: inject(SessionStorageProvider),
})),
withMethods((store) => ({
_storageKey: () => `ReturnDetailsStore:${store._storageId}`,
})),
withMethods((store) => ({
_storeState: () => {
const state = getState(store);
if (!store._storageId) {
return;
}
store._storage.set(store._storageKey(), state);
store._logger.debug('State stored:', () => state);
},
_restoreState: async () => {
const data = await store._storage.get(store._storageKey());
if (data) {
patchState(store, data);
store._logger.debug('State restored:', () => ({ data }));
} else {
patchState(store, { ...initialState, _storageId: store._storageId() });
store._logger.debug('No state found, initialized with default state');
}
},
})),
withComputed((store) => ({
items: computed<Array<ReceiptItem>>(() =>
@@ -86,43 +57,56 @@ export const ReturnDetailsStore = signalStore(
.receiptsEntities()
.map((receipt) => receipt.items)
.flat()
.map((container) => {
const item = container.data;
if (!item) {
const err = new Error('Item data is undefined');
store._logger.error('Item data is undefined', err, () => ({
item: container,
}));
throw err;
}
const itemData = clone(item);
const quantityMap = store.selectedQuantity();
if (quantityMap[itemData.id]) {
itemData.quantity = { quantity: quantityMap[itemData.id] };
} else {
const quantity = getReceiptItemQuantity(itemData);
if (!itemData.quantity) {
itemData.quantity = { quantity };
} else {
itemData.quantity.quantity = quantity;
}
}
if (!itemData.features) {
itemData.features = {};
}
itemData.features['category'] =
store.selectedProductCategory()[itemData.id] ||
getReceiptItemProductCategory(itemData);
return itemData;
}),
.map((container) => container.data!),
),
})),
withComputed((store) => ({
availableQuantityMap: computed(() => {
const items = store.items();
const availableQuantity: Record<number, number> = {};
items.forEach((item) => {
const itemId = item.id;
const quantity = getReceiptItemQuantity(item);
const returnedQuantity = getReceiptItemReturnedQuantity(item);
availableQuantity[itemId] = quantity - returnedQuantity;
});
return availableQuantity;
}),
itemCategoryMap: computed(() => {
const items = store.items();
const categoryMap: Record<number, ProductCategory> = {};
items.forEach((item) => {
const itemId = item.id;
const selectedCategory = store.selectedProductCategory()[itemId];
const category = getReceiptItemProductCategory(item);
categoryMap[itemId] = selectedCategory ?? category;
});
return categoryMap;
}),
})),
withComputed((store) => ({
selectedQuantityMap: computed(() => {
const items = store.items();
const selectedQuantity: Record<number, number> = {};
items.forEach((item) => {
const itemId = item.id;
const quantity =
store.selectedQuantity()[itemId] ||
store.availableQuantityMap()[itemId];
selectedQuantity[itemId] = quantity;
});
return selectedQuantity;
}),
})),
withComputed((store) => ({
selectedItemIds: computed(() => {
const selectedIds = store._selectedItemIds();
@@ -167,8 +151,8 @@ export const ReturnDetailsStore = signalStore(
{ receiptId: params },
abortSignal,
);
patchState(store, setEntity(receipt, receiptConfig));
store._storeState();
return receipt;
},
}),
@@ -182,18 +166,21 @@ export const ReturnDetailsStore = signalStore(
return undefined;
}
const receiptItemId = item.id;
const quantity = store.selectedQuantityMap()[receiptItemId];
const category = store.itemCategoryMap()[receiptItemId];
return {
item: item,
category:
store.selectedProductCategory()[item.id] ||
getReceiptItemProductCategory(item),
receiptItemId,
quantity,
category,
};
},
loader: async ({ params, abortSignal }) => {
if (params === undefined) {
return undefined;
}
const key = `${params.item.id}:${params.category}`;
const key = `${params.receiptItemId}:${params.category}`;
if (store.canReturn()[key]) {
return store.canReturn()[key];
@@ -207,7 +194,6 @@ export const ReturnDetailsStore = signalStore(
canReturn: { ...store.canReturn(), [key]: res },
});
store._storeState();
return res;
},
}),
@@ -248,37 +234,25 @@ export const ReturnDetailsStore = signalStore(
})),
withMethods((store) => ({
selectStorage: (id: number) => {
untracked(() => {
patchState(store, { _storageId: id });
store._restoreState();
store._storeState();
store._logger.debug('Storage ID set:', () => ({ id }));
});
},
addSelectedItems(itemIds: number[]) {
const currentIds = store.selectedItemIds();
const newIds = Array.from(new Set([...currentIds, ...itemIds]));
patchState(store, { _selectedItemIds: newIds });
store._storeState();
},
removeSelectedItems(itemIds: number[]) {
const currentIds = store.selectedItemIds();
const newIds = currentIds.filter((id) => !itemIds.includes(id));
patchState(store, { _selectedItemIds: newIds });
store._storeState();
},
async setProductCategory(itemId: number, category: ProductCategory) {
const currentCategory = store.selectedProductCategory();
const newCategory = { ...currentCategory, [itemId]: category };
patchState(store, { selectedProductCategory: newCategory });
store._storeState();
},
setQuantity(itemId: number, quantity: number) {
const currentQuantity = store.selectedQuantity();
const newQuantity = { ...currentQuantity, [itemId]: quantity };
patchState(store, { selectedQuantity: newQuantity });
store._storeState();
},
})),
);

View File

@@ -7,6 +7,7 @@ import { setAllEntities, setEntity } from '@ngrx/signals/entities';
import { unprotected } from '@ngrx/signals/testing';
import { Product, ReturnProcess } from '../models';
import { CreateReturnProcessError } from '../errors/return-process';
import { ProductCategory } from '../questions';
const TEST_ITEMS: Record<number, ReturnProcess['receiptItem']> = {
1: {
@@ -77,7 +78,8 @@ describe('ReturnProcessStore', () => {
receiptItem: TEST_ITEMS[1],
receiptDate: '',
answers: {},
productCategory: undefined,
productCategory: ProductCategory.BookCalendar,
quantity: 1,
returnReceipt: undefined,
},
{
@@ -87,7 +89,8 @@ describe('ReturnProcessStore', () => {
receiptItem: TEST_ITEMS[2],
receiptDate: '',
answers: {},
productCategory: undefined,
productCategory: ProductCategory.BookCalendar,
quantity: 1,
returnReceipt: undefined,
},
{
@@ -97,7 +100,8 @@ describe('ReturnProcessStore', () => {
receiptItem: TEST_ITEMS[3],
receiptDate: '',
answers: {},
productCategory: undefined,
productCategory: ProductCategory.BookCalendar,
quantity: 1,
returnReceipt: undefined,
},
] as ReturnProcess[]),
@@ -122,7 +126,8 @@ describe('ReturnProcessStore', () => {
receiptItem: TEST_ITEMS[1],
receiptDate: '',
answers: {},
productCategory: undefined,
productCategory: ProductCategory.BookCalendar,
quantity: 1,
returnReceipt: undefined,
},
] as ReturnProcess[]),
@@ -148,7 +153,8 @@ describe('ReturnProcessStore', () => {
receiptDate: new Date().toJSON(),
receiptItem: TEST_ITEMS[1],
receiptId: 123,
productCategory: undefined,
productCategory: ProductCategory.BookCalendar,
quantity: 1,
returnReceipt: undefined,
} as ReturnProcess),
);
@@ -173,7 +179,13 @@ describe('ReturnProcessStore', () => {
items: [],
buyer: { buyerNumber: '' },
},
items: [TEST_ITEMS[1]],
items: [
{
receiptItem: TEST_ITEMS[1],
quantity: 1,
category: ProductCategory.BookCalendar,
},
],
},
{
receipt: {
@@ -182,12 +194,22 @@ describe('ReturnProcessStore', () => {
items: [],
buyer: { buyerNumber: '' },
},
items: [TEST_ITEMS[3]],
items: [
{
receiptItem: TEST_ITEMS[3],
quantity: 1,
category: ProductCategory.BookCalendar,
},
],
},
],
});
expect(store.entities()).toHaveLength(2);
expect(store.entities()[0].productCategory).toBe(
ProductCategory.BookCalendar,
);
expect(store.entities()[0].quantity).toBe(1);
});
it('should throw an error if no returnable items are found', () => {
@@ -205,7 +227,13 @@ describe('ReturnProcessStore', () => {
items: [],
buyer: { buyerNumber: '' },
},
items: [TEST_ITEMS[2]], // Non-returnable item
items: [
{
receiptItem: TEST_ITEMS[2], // Non-returnable item
quantity: 1,
category: ProductCategory.BookCalendar,
},
],
},
],
});
@@ -227,7 +255,23 @@ describe('ReturnProcessStore', () => {
items: [],
buyer: { buyerNumber: '' },
},
items: [TEST_ITEMS[1], TEST_ITEMS[2], TEST_ITEMS[3]],
items: [
{
receiptItem: TEST_ITEMS[1],
quantity: 1,
category: ProductCategory.BookCalendar,
},
{
receiptItem: TEST_ITEMS[2],
quantity: 1,
category: ProductCategory.BookCalendar,
},
{
receiptItem: TEST_ITEMS[3],
quantity: 1,
category: ProductCategory.BookCalendar,
},
],
},
],
});

View File

@@ -21,13 +21,21 @@ import {
} from '../errors/return-process';
import { logger } from '@isa/core/logging';
import { canReturnReceiptItem } from '../helpers/return-process';
import { ProductCategory } from '../questions';
/**
* Interface representing the parameters required to start a return process.
*/
export type StartProcess = {
processId: number;
returns: { receipt: Receipt; items: ReceiptItem[] }[];
returns: {
receipt: Receipt;
items: {
receiptItem: ReceiptItem;
quantity: number;
category: ProductCategory;
}[];
}[];
};
/**
@@ -142,6 +150,7 @@ export const ReturnProcessStore = signalStore(
const returnableItems = params.returns
.flatMap((r) => r.items)
.map((item) => item.receiptItem)
.filter(canReturnReceiptItem);
if (returnableItems.length === 0) {
@@ -170,9 +179,10 @@ export const ReturnProcessStore = signalStore(
id: nextId + entities.length,
processId: params.processId,
receiptId: receipt.id,
productCategory: item.features?.['category'],
productCategory: item.category,
quantity: item.quantity,
receiptDate: receipt.printedDate,
receiptItem: item,
receiptItem: item.receiptItem,
answers: {},
});
}

View File

@@ -1,9 +1,11 @@
<div class="flex flex-row w-full">
<div
class="flex flex-row justify-end -mb-4 desktop:mb-0 w-[13.4375rem] desktop:w-full"
>
@if (quantityDropdownValues().length > 1) {
<ui-dropdown
class="quantity-dropdown"
[disabled]="!canReturnReceiptItem()"
[value]="availableQuantity()"
[value]="selectedQuantity()"
(valueChange)="setQuantity($event)"
>
@for (quantity of quantityDropdownValues(); track quantity) {

View File

@@ -1,9 +1,13 @@
:host {
@apply flex flex-col-reverse items-end desktop:flex-row desktop:justify-center desktop:items-center gap-4;
@apply flex flex-col-reverse items-end desktop:flex-row desktop:justify-center desktop:items-center gap-4;
.product-dropdown.ui-dropdown {
@apply max-w-[13.4375rem] desktop:max-w-full;
}
:has(.product-dropdown):has(.quantity-dropdown) {
.quantity-dropdown.ui-dropdown {
@apply border-r-0 pr-4;
@apply border-r-0 pr-4 pl-5 max-w-20 desktop:max-w-full;
border-top-right-radius: 0;
border-bottom-right-radius: 0;
@@ -15,7 +19,7 @@
}
.product-dropdown.ui-dropdown {
@apply border-l-0 pl-4;
@apply border-l-0 max-w-[8.75rem] desktop:max-w-full pr-5 pl-4;
border-top-left-radius: 0;
border-bottom-left-radius: 0;

View File

@@ -1,50 +1,52 @@
import { createComponentFactory, Spectator } from '@ngneat/spectator/jest';
import { MockDirective } from 'ng-mocks';
import { createComponentFactory, Spectator } from "@ngneat/spectator/jest";
import { MockDirective } from "ng-mocks";
import {
ReceiptItem,
ReturnDetailsService,
ReturnDetailsStore,
} from '@isa/oms/data-access';
} from "@isa/oms/data-access";
import { ProductImageDirective } from '@isa/shared/product-image';
import { ReturnDetailsOrderGroupItemControlsComponent } from '../return-details-order-group-item-controls/return-details-order-group-item-controls.component';
import { CheckboxComponent } from '@isa/ui/input-controls';
import { signal } from '@angular/core';
import { ProductImageDirective } from "@isa/shared/product-image";
import { ReturnDetailsOrderGroupItemControlsComponent } from "../return-details-order-group-item-controls/return-details-order-group-item-controls.component";
import { CheckboxComponent } from "@isa/ui/input-controls";
import { signal } from "@angular/core";
// Helper function to create mock ReceiptItem data
const createMockItem = (
ean: string,
canReturn: boolean,
name = 'Test Product',
category = 'BOOK', // Add default category that's not 'unknown'
name = "Test Product",
category = "BOOK", // Add default category that's not 'unknown'
availableQuantity = 2,
selectedQuantity = 1,
): ReceiptItem =>
({
id: 123,
receiptNumber: 'R-123456', // Add the required receiptNumber property
quantity: { quantity: 1 },
receiptNumber: "R-123456",
quantity: { quantity: availableQuantity },
price: {
value: { value: 19.99, currency: 'EUR' },
value: { value: 19.99, currency: "EUR" },
vat: { inPercent: 19 },
},
product: {
ean: ean,
name: name,
contributors: 'Test Author',
format: 'HC',
formatDetail: 'Hardcover',
manufacturer: 'Test Publisher',
publicationDate: '2024-01-01T00:00:00Z',
catalogProductNumber: '1234567890',
volume: '1',
contributors: "Test Author",
format: "HC",
formatDetail: "Hardcover",
manufacturer: "Test Publisher",
publicationDate: "2024-01-01T00:00:00Z",
catalogProductNumber: "1234567890",
volume: "1",
},
actions: [{ key: 'canReturn', value: String(canReturn) }],
features: { category: category }, // Add the features property with category
actions: [{ key: "canReturn", value: String(canReturn) }],
features: { category: category },
}) as ReceiptItem;
describe('ReturnDetailsOrderGroupItemControlsComponent', () => {
describe("ReturnDetailsOrderGroupItemControlsComponent", () => {
let spectator: Spectator<ReturnDetailsOrderGroupItemControlsComponent>;
const mockItemSelectable = createMockItem('1234567890123', true);
const mockItemSelectable = createMockItem("1234567890123", true);
const mockIsSelectable = signal<boolean>(true);
const mockGetItemSelectted = signal<boolean>(false);
@@ -52,6 +54,11 @@ describe('ReturnDetailsOrderGroupItemControlsComponent', () => {
isLoading: signal<boolean>(true),
};
// Mocks for availableQuantityMap and selectedQuantityMap
const mockAvailableQuantityMap = { [mockItemSelectable.id]: 2 };
const mockSelectedQuantityMap = { [mockItemSelectable.id]: 1 };
const mockItemCategoryMap = { [mockItemSelectable.id]: "BOOK" };
function resetMocks() {
mockIsSelectable.set(true);
mockGetItemSelectted.set(false);
@@ -68,12 +75,16 @@ describe('ReturnDetailsOrderGroupItemControlsComponent', () => {
isSelectable: jest.fn(() => mockIsSelectable),
getItemSelected: jest.fn(() => mockGetItemSelectted),
canReturnResource: jest.fn(() => mockCanReturnResource),
availableQuantityMap: jest.fn(() => mockAvailableQuantityMap),
selectedQuantityMap: jest.fn(() => mockSelectedQuantityMap),
itemCategoryMap: jest.fn(() => mockItemCategoryMap),
setProductCategory: jest.fn(),
setQuantity: jest.fn(),
addSelectedItems: jest.fn(),
removeSelectedItems: jest.fn(),
},
},
],
// Spectator automatically stubs standalone dependencies like ItemRowComponent, CheckboxComponent etc.
// We don't need deep interaction, just verify the host component renders correctly.
// If specific interactions were needed, we could provide mocks or use overrideComponents.
overrideComponents: [
[
ReturnDetailsOrderGroupItemControlsComponent,
@@ -85,50 +96,41 @@ describe('ReturnDetailsOrderGroupItemControlsComponent', () => {
},
],
],
detectChanges: false, // Control initial detection manually
detectChanges: false,
});
beforeEach(() => {
// Default setup with a selectable item
spectator = createComponent({
props: {
item: mockItemSelectable, // Use signal for input
item: mockItemSelectable,
},
});
});
afterEach(() => {
resetMocks(); // Reset mocks after each test
resetMocks();
});
it('should create', () => {
// Arrange
spectator.detectChanges(); // Trigger initial render
// Assert
it("should create", () => {
spectator.detectChanges();
expect(spectator.component).toBeTruthy();
});
it('should display the checkbox when item is selectable', () => {
// Arrange
mockCanReturnResource.isLoading.set(false); // Simulate the resource being ready
mockIsSelectable.set(true); // Simulate the item being selectable
it("should display the checkbox when item is selectable and not loading", () => {
mockCanReturnResource.isLoading.set(false);
mockIsSelectable.set(true);
spectator.detectChanges();
// Assert
expect(spectator.component.selectable()).toBe(true);
const checkbox = spectator.query(CheckboxComponent);
expect(checkbox).toBeTruthy();
expect(spectator.query(CheckboxComponent)).toBeTruthy();
expect(
spectator.query(`input[data-what="return-item-checkbox"]`),
).toExist();
});
it('should NOT display the checkbox when item is not selectable', () => {
// Arrange
mockIsSelectable.set(false); // Simulate the item not being selectable
spectator.detectChanges();
spectator.detectComponentChanges();
// Assert
it("should NOT display the checkbox when item is not selectable", () => {
mockIsSelectable.set(false);
mockCanReturnResource.isLoading.set(false);
spectator.detectChanges();
expect(spectator.component.selectable()).toBe(false);
expect(
spectator.query(`input[data-what="return-item-checkbox"]`),
@@ -136,27 +138,73 @@ describe('ReturnDetailsOrderGroupItemControlsComponent', () => {
expect(spectator.query(CheckboxComponent)).toBeFalsy();
});
it('should be false when no canReturn action is present', () => {
// Arrange
const item = { ...createMockItem('0001', true), actions: [] };
spectator.setInput('item', item as any);
// Act
it("should show spinner when canReturnResource is loading", () => {
mockCanReturnResource.isLoading.set(true);
spectator.detectChanges();
expect(
spectator.query('ui-icon-button[data-what="load-spinner"]'),
).toExist();
});
// Assert
it("should render correct quantity dropdown values", () => {
spectator.detectChanges();
expect(spectator.component.quantityDropdownValues()).toEqual([1, 2]);
});
it("should call setQuantity when dropdown value changes", () => {
const store = spectator.inject(ReturnDetailsStore);
const spy = jest.spyOn(store, "setQuantity");
spectator.detectChanges();
// Simulate dropdown value change
spectator.component.setQuantity(2);
expect(spy).toHaveBeenCalledWith(mockItemSelectable.id, 2);
});
it("should call setProductCategory when product category changes", () => {
const store = spectator.inject(ReturnDetailsStore);
const spy = jest.spyOn(store, "setProductCategory");
spectator.detectChanges();
spectator.component.setProductCategory("Buch/Kalender");
expect(spy).toHaveBeenCalledWith(mockItemSelectable.id, "Buch/Kalender");
});
it("should call addSelectedItems when setSelected(true) is called", () => {
const store = spectator.inject(ReturnDetailsStore);
const spy = jest.spyOn(store, "addSelectedItems");
spectator.detectChanges();
spectator.component.setSelected(true);
expect(spy).toHaveBeenCalledWith([mockItemSelectable.id]);
});
it("should call removeSelectedItems when setSelected(false) is called", () => {
const store = spectator.inject(ReturnDetailsStore);
const spy = jest.spyOn(store, "removeSelectedItems");
spectator.detectChanges();
spectator.component.setSelected(false);
expect(spy).toHaveBeenCalledWith([mockItemSelectable.id]);
});
it("should be false when no canReturn action is present", () => {
const item = { ...createMockItem("0001", true), actions: [] };
spectator.setInput("item", item as any);
spectator.detectChanges();
expect(spectator.component.canReturnReceiptItem()).toBe(false);
});
it('should be false when canReturn action has falsy value', () => {
// Arrange
const item = createMockItem('0001', false);
spectator.setInput('item', item);
// Act
it("should be false when canReturn action has falsy value", () => {
const item = createMockItem("0001", false);
spectator.setInput("item", item);
spectator.detectChanges();
// Assert
expect(spectator.component.canReturnReceiptItem()).toBe(false);
});
it("should display correct selected quantity", () => {
spectator.detectChanges();
expect(spectator.component.selectedQuantity()).toBe(1);
});
it("should display correct product category", () => {
spectator.detectChanges();
expect(spectator.component.productCategory()).toBe("BOOK");
});
});

View File

@@ -5,30 +5,27 @@ import {
inject,
input,
signal,
} from '@angular/core';
import { provideLoggerContext } from '@isa/core/logging';
} from "@angular/core";
import { provideLoggerContext } from "@isa/core/logging";
import {
canReturnReceiptItem,
getReceiptItemReturnedQuantity,
getReceiptItemProductCategory,
getReceiptItemQuantity,
ProductCategory,
ReceiptItem,
ReturnDetailsService,
ReturnDetailsStore,
} from '@isa/oms/data-access';
import { IconButtonComponent } from '@isa/ui/buttons';
} from "@isa/oms/data-access";
import { IconButtonComponent } from "@isa/ui/buttons";
import {
CheckboxComponent,
DropdownButtonComponent,
DropdownOptionComponent,
} from '@isa/ui/input-controls';
import { FormsModule } from '@angular/forms';
} from "@isa/ui/input-controls";
import { FormsModule } from "@angular/forms";
@Component({
selector: 'oms-feature-return-details-order-group-item-controls',
templateUrl: './return-details-order-group-item-controls.component.html',
styleUrls: ['./return-details-order-group-item-controls.component.scss'],
selector: "oms-feature-return-details-order-group-item-controls",
templateUrl: "./return-details-order-group-item-controls.component.html",
styleUrls: ["./return-details-order-group-item-controls.component.scss"],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [
@@ -40,7 +37,7 @@ import { FormsModule } from '@angular/forms';
],
providers: [
provideLoggerContext({
component: 'ReturnDetailsOrderGroupItemControlsComponent',
component: "ReturnDetailsOrderGroupItemControlsComponent",
}),
],
})
@@ -66,38 +63,11 @@ export class ReturnDetailsOrderGroupItemControlsComponent {
availableCategories = this.#returnDetailsService.availableCategories();
/**
* Computes the quantity of the current receipt item that has already been returned.
*
* This value is derived from the item's return history and is used to indicate
* how many units have already been processed for return.
*
* @returns The number of units already returned for this receipt item.
*/
returnedQuantity = computed(() => {
selectedQuantity = computed(() => {
const item = this.item();
return getReceiptItemReturnedQuantity(item);
return this.#store.selectedQuantityMap()[item.id];
});
/**
* Computes the total quantity for the current receipt item.
* Represents the original quantity as recorded in the receipt.
*
* @returns The total quantity for the item.
*/
quantity = computed(() => {
const item = this.item();
return getReceiptItemQuantity(item);
});
/**
* Computes the quantity of the item that is still available for return.
* Calculated as the difference between the total quantity and the returned quantity.
*
* @returns The number of units available to be returned.
*/
availableQuantity = computed(() => this.quantity() - this.returnedQuantity());
/**
* Generates the list of selectable quantities for the dropdown.
* The values range from 1 up to the available quantity.
@@ -105,13 +75,14 @@ export class ReturnDetailsOrderGroupItemControlsComponent {
* @returns An array of selectable quantity values.
*/
quantityDropdownValues = computed(() => {
const itemQuantity = this.availableQuantity();
const item = this.item();
const itemQuantity = this.#store.availableQuantityMap()[item.id];
return Array.from({ length: itemQuantity }, (_, i) => i + 1);
});
productCategory = computed(() => {
const item = this.item();
return getReceiptItemProductCategory(item);
return this.#store.itemCategoryMap()[item.id];
});
selectable = this.#store.isSelectable(this.item);
@@ -127,8 +98,9 @@ export class ReturnDetailsOrderGroupItemControlsComponent {
}
setQuantity(quantity: number | undefined) {
const item = this.item();
if (quantity === undefined) {
quantity = this.item().quantity.quantity;
quantity = this.#store.availableQuantityMap()[item.id];
}
this.#store.setQuantity(this.item().id, quantity);
}

View File

@@ -57,7 +57,7 @@
{{ i.product.manufacturer }} | {{ i.product.ean }}
</div>
<div class="text-isa-neutral-600 isa-text-body-2-regular">
{{ i.product.publicationDate | date: 'dd. MMM yyyy' }}
{{ i.product.publicationDate | date: "dd. MMM yyyy" }}
</div>
</div>
<oms-feature-return-details-order-group-item-controls [item]="i">
@@ -73,11 +73,11 @@
</div>
}
@if (returnedQuantity() > 0 && itemQuantity() !== returnedQuantity()) {
@if (availableQuantity() !== quantity()) {
<div
class="flex items-center self-start text-isa-neutral-600 isa-text-body-2-bold pb-6"
>
Es wurden bereits {{ returnedQuantity() }} von {{ itemQuantity() }} Artikel
zurückgegeben.
Es wurden bereits {{ quantity() - availableQuantity() }} von
{{ quantity() }} Artikel zurückgegeben.
</div>
}

View File

@@ -1,29 +1,28 @@
import { CurrencyPipe, DatePipe, LowerCasePipe } from '@angular/common';
import { CurrencyPipe, DatePipe, LowerCasePipe } from "@angular/common";
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
input,
} from '@angular/core';
import { isaActionClose, ProductFormatIconGroup } from '@isa/icons';
} from "@angular/core";
import { isaActionClose, ProductFormatIconGroup } from "@isa/icons";
import {
getReceiptItemAction,
getReceiptItemReturnedQuantity,
getReceiptItemQuantity,
ReceiptItem,
ReturnDetailsStore,
} from '@isa/oms/data-access';
import { ProductImageDirective } from '@isa/shared/product-image';
import { ItemRowComponent } from '@isa/ui/item-rows';
import { NgIconComponent, provideIcons } from '@ng-icons/core';
import { ReturnDetailsOrderGroupItemControlsComponent } from '../return-details-order-group-item-controls/return-details-order-group-item-controls.component';
import { ProductRouterLinkDirective } from '@isa/shared/product-router-link';
} from "@isa/oms/data-access";
import { ProductImageDirective } from "@isa/shared/product-image";
import { ItemRowComponent } from "@isa/ui/item-rows";
import { NgIconComponent, provideIcons } from "@ng-icons/core";
import { ReturnDetailsOrderGroupItemControlsComponent } from "../return-details-order-group-item-controls/return-details-order-group-item-controls.component";
import { ProductRouterLinkDirective } from "@isa/shared/product-router-link";
@Component({
selector: 'oms-feature-return-details-order-group-item',
templateUrl: './return-details-order-group-item.component.html',
styleUrls: ['./return-details-order-group-item.component.scss'],
selector: "oms-feature-return-details-order-group-item",
templateUrl: "./return-details-order-group-item.component.html",
styleUrls: ["./return-details-order-group-item.component.scss"],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [
@@ -82,7 +81,7 @@ export class ReturnDetailsOrderGroupItemComponent {
*/
canReturnMessage = computed(() => {
const item = this.item();
const canReturnAction = getReceiptItemAction(item, 'canReturn');
const canReturnAction = getReceiptItemAction(item, "canReturn");
if (canReturnAction?.description) {
return canReturnAction.description;
@@ -90,30 +89,32 @@ export class ReturnDetailsOrderGroupItemComponent {
const canReturnMessage = this.canReturn()?.message;
return canReturnMessage ?? '';
return canReturnMessage ?? "";
});
/**
* Computes the quantity of the current receipt item that has already been returned.
* The original quantity of the item as recorded in the order.
* This value is retrieved from the store and represents the total number of units
* initially purchased for this receipt item.
*
* This value is derived using the item's return history and is used to display
* how many units of this item have been processed for return so far.
*
* @returns The number of units already returned for this receipt item.
* @readonly
* @returns {number} The original quantity of the item in the order.
*/
returnedQuantity = computed(() => {
const item = this.item();
return getReceiptItemReturnedQuantity(item);
});
/**
* Computes the total quantity for the current receipt item.
* Represents the original quantity of the item as recorded in the receipt.
*
* @returns The total quantity for the item.
*/
itemQuantity = computed(() => {
quantity = computed(() => {
const item = this.item();
return getReceiptItemQuantity(item);
});
/**
* The currently available quantity of the item for return.
* This value is computed based on the item's current state and may be less than
* the original quantity if some units have already been returned or are otherwise unavailable.
*
* @readonly
* @returns {number} The number of units available for return.
*/
availableQuantity = computed(() => {
const item = this.item();
return this.#store.availableQuantityMap()[item.id];
});
}

View File

@@ -22,7 +22,7 @@
></oms-feature-return-details-static>
@if (customerReceiptsResource.isLoading()) {
<ui-progress-bar class="w-full" mode="indeterminate"></ui-progress-bar>
} @else {
} @else if (!customerReceiptsResource.error()) {
@for (receipt of customerReceiptsResource.value(); track receipt.id) {
@if (r.id !== receipt.id) {
<oms-feature-return-details-lazy

View File

@@ -1,147 +1,157 @@
import {
ChangeDetectionStrategy,
Component,
computed,
effect,
inject,
resource,
} from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { toSignal } from '@angular/core/rxjs-interop';
import { z } from 'zod';
import { NgIconComponent, provideIcons } from '@ng-icons/core';
import { isaActionChevronLeft } from '@isa/icons';
import { ButtonComponent } from '@isa/ui/buttons';
import { injectActivatedTabId } from '@isa/core/tabs';
import { Location } from '@angular/common';
import { ExpandableDirectives } from '@isa/ui/expandable';
import { ProgressBarComponent } from '@isa/ui/progress-bar';
import {
ReturnDetailsService,
ReturnProcessStore,
ReturnDetailsStore,
} from '@isa/oms/data-access';
import { ReturnDetailsStaticComponent } from './return-details-static/return-details-static.component';
import { ReturnDetailsLazyComponent } from './return-details-lazy/return-details-lazy.component';
import { logger } from '@isa/core/logging';
import { groupBy } from 'lodash';
@Component({
selector: 'oms-feature-return-details',
templateUrl: './return-details.component.html',
styleUrls: ['./return-details.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
ReturnDetailsStaticComponent,
ReturnDetailsLazyComponent,
NgIconComponent,
ButtonComponent,
ExpandableDirectives,
ProgressBarComponent,
],
providers: [provideIcons({ isaActionChevronLeft })],
})
export class ReturnDetailsComponent {
#logger = logger(() => ({
component: ReturnDetailsComponent.name,
itemId: this.receiptId(),
processId: this.processId(),
params: this.params(),
}));
#store = inject(ReturnDetailsStore);
#returnDetailsService = inject(ReturnDetailsService);
#returnProcessStore = inject(ReturnProcessStore);
private processId = injectActivatedTabId();
private _router = inject(Router);
private _activatedRoute = inject(ActivatedRoute);
location = inject(Location);
params = toSignal(this._activatedRoute.params);
receiptId = computed<number>(() => {
const params = this.params();
if (params) {
return z.coerce.number().parse(params['receiptId']);
}
throw new Error('No receiptId found in route params');
});
// Effect resets the Store's state when the receiptId changes
// This ensures that the store is always in sync with the current receiptId
receiptIdEffect = effect(() => this.#store.selectStorage(this.receiptId()));
receiptResource = this.#store.receiptResource(this.receiptId);
customerReceiptsResource = resource({
params: this.receiptResource.value,
loader: async ({ params, abortSignal }) => {
const email = params.buyer?.communicationDetails?.email;
if (!email) {
return [];
}
return await this.#returnDetailsService.fetchReceiptsByEmail(
{ email },
abortSignal,
);
},
});
canStartProcess = computed(() => {
return (
this.#store.selectedItemIds().length > 0 && this.processId() !== undefined
);
});
startProcess() {
if (!this.canStartProcess()) {
this.#logger.warn(
'Cannot start process: No items selected or no process ID',
);
return;
}
const processId = this.processId();
const selectedItems = this.#store.selectedItems();
this.#logger.info('Starting return process', () => ({
processId: processId,
selectedItems: selectedItems.map((item) => item.id),
}));
if (!selectedItems.length || !processId) {
return;
}
const itemsGrouptByReceiptId = groupBy(
selectedItems,
(item) => item.receipt?.id,
);
const receipts = this.#store.receiptsEntityMap();
const returns = Object.entries(itemsGrouptByReceiptId).map(
([receiptId, items]) => ({
receipt: receipts[Number(receiptId)],
items,
}),
);
this.#logger.info('Starting return process with returns', () => ({
processId,
returns,
}));
this.#returnProcessStore.startProcess({
processId,
returns,
});
this._router.navigate(['../../', 'process'], {
relativeTo: this._activatedRoute,
});
}
}
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
resource,
} from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { toSignal } from '@angular/core/rxjs-interop';
import { z } from 'zod';
import { NgIconComponent, provideIcons } from '@ng-icons/core';
import { isaActionChevronLeft } from '@isa/icons';
import { ButtonComponent } from '@isa/ui/buttons';
import { injectTabId } from '@isa/core/tabs';
import { Location } from '@angular/common';
import { ExpandableDirectives } from '@isa/ui/expandable';
import { ProgressBarComponent } from '@isa/ui/progress-bar';
import {
ReturnDetailsService,
ReturnProcessStore,
ReturnDetailsStore,
} from '@isa/oms/data-access';
import { ReturnDetailsStaticComponent } from './return-details-static/return-details-static.component';
import { ReturnDetailsLazyComponent } from './return-details-lazy/return-details-lazy.component';
import { logger } from '@isa/core/logging';
import { groupBy } from 'lodash';
@Component({
selector: 'oms-feature-return-details',
templateUrl: './return-details.component.html',
styleUrls: ['./return-details.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
ReturnDetailsStaticComponent,
ReturnDetailsLazyComponent,
NgIconComponent,
ButtonComponent,
ExpandableDirectives,
ProgressBarComponent,
],
providers: [provideIcons({ isaActionChevronLeft }), ReturnDetailsStore],
})
export class ReturnDetailsComponent {
#logger = logger(() => ({
component: 'ReturnDetailsComponent',
itemId: this.receiptId(),
processId: this.processId(),
params: this.params(),
}));
#store = inject(ReturnDetailsStore);
#returnDetailsService = inject(ReturnDetailsService);
#returnProcessStore = inject(ReturnProcessStore);
private processId = injectTabId();
private _router = inject(Router);
private _activatedRoute = inject(ActivatedRoute);
location = inject(Location);
params = toSignal(this._activatedRoute.params);
receiptId = computed<number>(() => {
const params = this.params();
if (params) {
return z.coerce.number().parse(params['receiptId']);
}
throw new Error('No receiptId found in route params');
});
receiptResource = this.#store.receiptResource(this.receiptId);
customerReceiptsResource = resource({
params: this.receiptResource.value,
loader: async ({ params, abortSignal }) => {
const email = params.buyer?.communicationDetails?.email;
if (!email) {
return [];
}
try {
return await this.#returnDetailsService.fetchReceiptsByEmail(
{ email },
abortSignal,
);
} catch (error) {
this.#logger.error('Failed to fetch customer receipts', error);
return [];
}
},
});
canStartProcess = computed(() => {
return (
this.#store.selectedItemIds().length > 0 && this.processId() !== undefined
);
});
startProcess() {
if (!this.canStartProcess()) {
this.#logger.warn(
'Cannot start process: No items selected or no process ID',
);
return;
}
const processId = this.processId();
const selectedItems = this.#store.selectedItems();
const selectedQuantites = this.#store.selectedQuantityMap();
const selectedProductCategories = this.#store.itemCategoryMap();
this.#logger.info('Starting return process', () => ({
processId: processId,
selectedItems: selectedItems.map((item) => item.id),
}));
if (!selectedItems.length || !processId) {
return;
}
const itemsGrouptByReceiptId = groupBy(
selectedItems,
(item) => item.receipt?.id,
);
const receipts = this.#store.receiptsEntityMap();
const returns = Object.entries(itemsGrouptByReceiptId).map(
([receiptId, items]) => ({
receipt: receipts[Number(receiptId)],
items: items.map((item) => {
const receiptItem = item;
return {
receiptItem,
quantity: selectedQuantites[receiptItem.id],
category: selectedProductCategories[receiptItem.id],
};
}),
}),
);
this.#logger.info('Starting return process with returns', () => ({
processId,
returns,
}));
this.#returnProcessStore.startProcess({
processId,
returns,
});
this._router.navigate(['../../', 'process'], {
relativeTo: this._activatedRoute,
});
}
}

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

@@ -21,7 +21,7 @@ import { ReturnProcessComponent } from './return-process.component';
const mockActivatedProcessIdSignal = signal<number | null>(123);
jest.mock('@isa/core/tabs', () => ({
injectActivatedTabId: jest.fn(() => mockActivatedProcessIdSignal),
injectTabId: jest.fn(() => mockActivatedProcessIdSignal),
}));
jest.mock('scandit-web-datacapture-core', () => ({}));

View File

@@ -18,7 +18,7 @@ import {
ReturnProcessService,
ReturnProcessStore,
} from '@isa/oms/data-access';
import { injectActivatedTabId } from '@isa/core/tabs';
import { injectTabId } from '@isa/core/tabs';
import { ReturnProcessItemComponent } from './return-process-item/return-process-item.component';
import { Location } from '@angular/common';
import { RouterLink } from '@angular/router';
@@ -58,7 +58,7 @@ export class ReturnProcessComponent {
#logger = logger();
/** Signal emitting the numeric ID of the currently active return process, derived from the route parameters. Null if no ID is present. */
processId = injectActivatedTabId();
processId = injectTabId();
#returnCanReturnService = inject(ReturnCanReturnService);

View File

@@ -1,21 +1,19 @@
import { computed, inject, Injectable } from '@angular/core';
import { CanDeactivate } from '@angular/router';
import { firstValueFrom } from 'rxjs';
import { injectActivatedTabId } from '@isa/core/tabs';
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 = injectActivatedTabId();
processId = injectTabId();
uncompletedTaskListItems = computed(() => {
const processId = this.processId();
@@ -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

@@ -1,6 +1,6 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { PrintReceiptsService, ReturnProcessStore } from '@isa/oms/data-access';
import { injectActivatedTabId } from '@isa/core/tabs';
import { injectTabId } from '@isa/core/tabs';
import { ReturnTaskListComponent } from '@isa/oms/shared/task-list';
import { ReturnReviewHeaderComponent } from './return-review-header/return-review-header.component';
@@ -15,7 +15,7 @@ import { ReturnReviewHeaderComponent } from './return-review-header/return-revie
export class ReturnReviewComponent {
#printReceiptsService = inject(PrintReceiptsService);
#returnProcessStore = inject(ReturnProcessStore);
processId = injectActivatedTabId();
processId = injectTabId();
async printReceipt() {
const processId = this.processId();

View File

@@ -6,7 +6,7 @@ import {
} from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { CallbackResult, ListResponseArgs } from '@isa/common/data-access';
import { injectActivatedTabId } from '@isa/core/tabs';
import { injectTabId } from '@isa/core/tabs';
import {
ReceiptListItem,
ReturnSearchStatus,
@@ -38,7 +38,7 @@ export class ReturnSearchMainComponent {
#route = inject(ActivatedRoute);
#router = inject(Router);
private _processId = injectActivatedTabId();
private _processId = injectTabId();
private _filterService = inject(FilterService);
private _returnSearchStore = inject(ReturnSearchStore);
@@ -77,10 +77,10 @@ export class ReturnSearchMainComponent {
}: CallbackResult<ListResponseArgs<ReceiptListItem>>) => {
if (data) {
if (data.result.length === 1) {
this.navigate(['receipt', data.result[0].id]);
} else if (data.result.length > 1) {
this.navigate(['receipts']);
return this.navigate(['receipt', data.result[0].id]);
}
return this.navigate(['receipts']);
}
};

View File

@@ -1,4 +1,7 @@
<ui-client-row data-what="search-result-item" [attr.data-which]="receiptNumber()">
<ui-client-row
data-what="search-result-item"
[attr.data-which]="receiptNumber()"
>
<ui-client-row-content>
<h3 class="isa-text-subtitle-1-regular">{{ name() }}</h3>
</ui-client-row-content>
@@ -7,12 +10,12 @@
<ui-item-row-data-label>Belegdatum</ui-item-row-data-label>
<ui-item-row-data-value>
<span class="isa-text-body-2-bold">
{{ receiptDate() | date: 'dd.MM.yy' }}
{{ receiptDate() | date: "dd.MM.yy" }}
</span>
</ui-item-row-data-value>
</ui-item-row-data-row>
<ui-item-row-data-row>
<ui-item-row-data-label>Rechnugsnr.</ui-item-row-data-label>
<ui-item-row-data-label>Beleg-Nr.</ui-item-row-data-label>
<ui-item-row-data-value>
<span class="isa-text-body-2-bold"> {{ receiptNumber() }} </span>
</ui-item-row-data-value>

View File

@@ -6,7 +6,7 @@ import {
inject,
linkedSignal,
} from '@angular/core';
import { injectActivatedTabId } from '@isa/core/tabs';
import { injectTabId } from '@isa/core/tabs';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import {
@@ -72,7 +72,7 @@ export class ReturnSearchResultComponent implements AfterViewInit {
restoreScrollPosition = injectRestoreScrollPosition();
/** Current process ID from the activated route */
processId = injectActivatedTabId();
processId = injectTabId();
/** Store for managing return search data and operations */
returnSearchStore = inject(ReturnSearchStore);

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

@@ -10,7 +10,7 @@ import { ReturnSummaryItemComponent } from './return-summary-item/return-summary
import { ActivatedRoute, Router } from '@angular/router';
jest.mock('@isa/core/tabs', () => ({
injectActivatedTabId: () => jest.fn(() => 1),
injectTabId: () => jest.fn(() => 1),
}));
const MOCK_RETURN_PROCESSES: ReturnProcess[] = [

View File

@@ -6,7 +6,7 @@ import {
signal,
} from '@angular/core';
import { ReturnSummaryItemComponent } from './return-summary-item/return-summary-item.component';
import { injectActivatedTabId } from '@isa/core/tabs';
import { injectTabId } from '@isa/core/tabs';
import {
ReturnProcess,
ReturnProcessService,
@@ -55,7 +55,7 @@ export class ReturnSummaryComponent {
location = inject(Location);
/** The active process ID from the current route */
processId = injectActivatedTabId();
processId = injectTabId();
/** Filtered list of return processes for the current process ID */
returnProcesses = computed<ReturnProcess[]>(() => {
@@ -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
@@ -118,9 +126,13 @@ export class ReturnSummaryComponent {
relativeTo: this.#activatedRoute,
});
} catch (error) {
this.#logger.error('Error completing return process', error as Error, () => ({
function: 'returnItemsAndPrintRecipt',
}));
this.#logger.error(
'Error completing return process',
error as Error,
() => ({
function: 'returnItemsAndPrintRecipt',
}),
);
this.returnItemsAndPrintReciptStatus.set('error');
}
}

View File

@@ -3,12 +3,35 @@
}
.oms-shared-return-task-list-item__review {
@apply grid grid-cols-[1fr,1fr] desktop:grid-cols-[1fr,1fr,minmax(20rem,auto)] gap-x-6 py-6 text-isa-secondary-900 items-center border-b border-solid border-isa-neutral-300 last:pb-0 last:border-none;
@apply grid grid-cols-[1fr,1fr] desktop:grid-cols-[1fr,1fr,minmax(20rem,auto)] gap-x-6 desktop:gap-y-6 py-6 text-isa-secondary-900 items-center border-b border-solid border-isa-neutral-300 last:pb-0 last:border-none;
&:has(.task-unknown-actions):has(.tolino-print-cta) {
.tolino-print-cta {
@apply desktop:justify-self-start;
}
}
@media screen and (max-width: 1024px) {
grid-template-areas:
'product infos'
'unknown-comment actions';
"product infos"
"unknown-comment actions";
.tolino-print-cta,
.task-unknown-actions {
@apply mt-6 desktop:mt-0;
grid-area: actions;
}
&:has(.task-unknown-actions):has(.tolino-print-cta) {
.tolino-print-cta {
@apply mt-0 self-start;
grid-area: print;
}
grid-template-areas:
"product print"
"unknown-comment actions";
}
.product-info {
grid-area: product;
@@ -18,12 +41,6 @@
grid-area: infos;
}
.tolino-print-cta,
.task-unknown-actions {
@apply mt-6 desktop:mt-0;
grid-area: actions;
}
.processing-comment-unknown {
@apply mt-6 desktop:mt-0;
grid-area: unknown-comment;
@@ -48,7 +65,7 @@
}
.task-unknown-actions {
@apply flex flex-row gap-3 h-full py-2 items-center;
@apply flex flex-row gap-3 h-full py-2 items-center justify-self-end;
}
.processing-comment {

View File

@@ -20,7 +20,7 @@ import {
} from '@isa/oms/data-access';
import { IconButtonComponent } from '@isa/ui/buttons';
import { firstValueFrom } from 'rxjs';
import { injectActivatedTabId } from '@isa/core/tabs';
import { injectTabId } from '@isa/core/tabs';
import { logger, provideLoggerContext } from '@isa/core/logging';
// TODO: Komponente und logik benötigt review
@@ -45,7 +45,7 @@ export class ReturnTaskListComponent {
#returnTaskListStore = inject(ReturnTaskListStore);
#logger = logger();
processId = injectActivatedTabId();
processId = injectTabId();
appearanceClass = computed(
() => `oms-shared-return-task-list__${this.appearance()}`,

View File

@@ -1,2 +1,5 @@
export * from './lib/services';
export * from './lib/models';
export * from './lib/stores';
export * from './lib/schemas';
export * from './lib/helpers';

View File

@@ -1,4 +1 @@
export const ASSIGNED_STOCK_STORAGE_KEY =
'd8a11dd9-1f32-4646-881d-6ec856cbe9d0';
export const SUPPLIER_STORAGE_KEY = '48872c78-ad7f-455d-b775-07b00920f80d';
export const SUPPLIER_STORAGE_KEY = '48872c78-ad7f-455d-b775-07b00920f80d';

View File

@@ -0,0 +1,80 @@
import { calculateAvailableStock } from './calc-available-stock.helper';
describe('calculateAvailableStock', () => {
it('should return stock when removedFromStock is undefined', () => {
// Arrange
const input = { stock: 10 };
// Act
const result = calculateAvailableStock(input);
// Assert
expect(result).toBe(10);
});
it('should return 0 when stock is undefined and removedFromStock is undefined', () => {
// Arrange
const input = { stock: undefined };
// Act
const result = calculateAvailableStock(input);
// Assert
expect(result).toBe(0);
});
it('should subtract removedFromStock from stock', () => {
// Arrange
const input = { stock: 20, removedFromStock: 5 };
// Act
const result = calculateAvailableStock(input);
// Assert
expect(result).toBe(15);
});
it('should return 0 if result is negative', () => {
// Arrange
const input = { stock: 3, removedFromStock: 5 };
// Act
const result = calculateAvailableStock(input);
// Assert
expect(result).toBe(0);
});
it('should treat undefined stock as 0', () => {
// Arrange
const input = { stock: undefined, removedFromStock: 2 };
// Act
const result = calculateAvailableStock(input);
// Assert
expect(result).toBe(0);
});
it('should treat undefined removedFromStock as 0', () => {
// Arrange
const input = { stock: 7, removedFromStock: undefined };
// Act
const result = calculateAvailableStock(input);
// Assert
expect(result).toBe(7);
});
it('should return 0 if both stock and removedFromStock are undefined', () => {
// Arrange
const input = { stock: undefined, removedFromStock: undefined };
// Act
const result = calculateAvailableStock(input);
// Assert
expect(result).toBe(0);
});
});

View File

@@ -0,0 +1,18 @@
/**
* Current available stock.
* Calculation: stock - removedFromStock
* Returns 0 if result is negative.
*
* @remarks
* Used as the base for further stock calculations.
*/
export const calculateAvailableStock = ({
stock,
removedFromStock,
}: {
stock: number | undefined;
removedFromStock?: number;
}): number => {
const availableStock = (stock ?? 0) - (removedFromStock ?? 0);
return availableStock < 0 ? 0 : availableStock;
};

View File

@@ -0,0 +1,207 @@
import {
calculateCapacity,
calculateMaxCapacity,
} from './calc-capacity.helper';
describe('calculateCapacity', () => {
it('should return capacityValue2 when it is smaller than capacityValue3', () => {
// Arrange
const input = { capacityValue2: 5, capacityValue3: 10 };
// Act
const result = calculateCapacity(input);
// Assert
expect(result).toBe(5);
});
it('should return capacityValue3 when it is smaller than capacityValue2', () => {
// Arrange
const input = { capacityValue2: 15, capacityValue3: 8 };
// Act
const result = calculateCapacity(input);
// Assert
expect(result).toBe(8);
});
it('should return capacityValue3 when both values are equal', () => {
// Arrange
const input = { capacityValue2: 10, capacityValue3: 10 };
// Act
const result = calculateCapacity(input);
// Assert
expect(result).toBe(10);
});
it('should handle zero values correctly', () => {
// Arrange
const input = { capacityValue2: 0, capacityValue3: 5 };
// Act
const result = calculateCapacity(input);
// Assert
expect(result).toBe(0);
});
it('should handle negative values correctly', () => {
// Arrange
const input = { capacityValue2: -3, capacityValue3: 2 };
// Act
const result = calculateCapacity(input);
// Assert
expect(result).toBe(-3);
});
});
describe('calculateMaxCapacity', () => {
it('should return capacityValue2 when capacityValue4 is greater than capacityValue2', () => {
// Arrange
const input = {
capacityValue2: 10,
capacityValue3: 15,
capacityValue4: 20,
comparer: 5,
};
// Act
const result = calculateMaxCapacity(input);
// Assert
expect(result).toBe(10);
});
it('should return capacityValue4 when it is positive and less than capacityValue2', () => {
// Arrange
const input = {
capacityValue2: 10,
capacityValue3: 15,
capacityValue4: 8,
comparer: 5,
};
// Act
const result = calculateMaxCapacity(input);
// Assert
expect(result).toBe(8);
});
it('should return capacityValue2 when capacityValue4 is zero and capacityValue3 is greater than capacityValue2', () => {
// Arrange
const input = {
capacityValue2: 10,
capacityValue3: 15,
capacityValue4: 0,
comparer: 5,
};
// Act
const result = calculateMaxCapacity(input);
// Assert
expect(result).toBe(10);
});
it('should return capacityValue3 when capacityValue4 is zero and capacityValue3 is positive and less than capacityValue2', () => {
// Arrange
const input = {
capacityValue2: 10,
capacityValue3: 8,
capacityValue4: 0,
comparer: 5,
};
// Act
const result = calculateMaxCapacity(input);
// Assert
expect(result).toBe(8);
});
it('should return comparer when it is greater than calculated max capacity', () => {
// Arrange
const input = {
capacityValue2: 5,
capacityValue3: 3,
capacityValue4: 2,
comparer: 10,
};
// Act
const result = calculateMaxCapacity(input);
// Assert
expect(result).toBe(10);
});
it('should handle undefined capacityValue4 with default value 0', () => {
// Arrange
const input = {
capacityValue2: 10,
capacityValue3: 15,
capacityValue4: undefined,
comparer: 5,
};
// Act
const result = calculateMaxCapacity(input);
// Assert
expect(result).toBe(10);
});
it('should handle all zero capacity values', () => {
// Arrange
const input = {
capacityValue2: 0,
capacityValue3: 0,
capacityValue4: 0,
comparer: 3,
};
// Act
const result = calculateMaxCapacity(input);
// Assert
expect(result).toBe(3);
});
it('should handle negative capacity values', () => {
// Arrange
const input = {
capacityValue2: -5,
capacityValue3: -3,
capacityValue4: -2,
comparer: 1,
};
// Act
const result = calculateMaxCapacity(input);
// Assert
expect(result).toBe(1);
});
it('should use default values for optional parameters when not provided', () => {
// Arrange
const input = {
capacityValue2: 10,
capacityValue3: 8,
capacityValue4: undefined,
comparer: 5,
};
// Act
const result = calculateMaxCapacity(input);
// Assert
expect(result).toBe(8);
});
});

View File

@@ -0,0 +1,56 @@
/**
* Calculates the capacity based on the provided capacity values.
* It returns the minimum of the two capacity values.
* @param {Object} params - The parameters for the calculation
* @param {number} params.capacityValue2 - The second capacity value
* @param {number} params.capacityValue3 - The third capacity value
* @return {number} The calculated capacity
*/
export const calculateCapacity = ({
capacityValue2,
capacityValue3,
}: {
capacityValue2: number;
capacityValue3: number;
}): number => {
return capacityValue3 > capacityValue2 ? capacityValue2 : capacityValue3;
};
/**
* Calculates the maximum capacity based on the provided capacity values.
* It compares the values and returns the maximum capacity that is greater than or equal to the comparer
* or the maximum of the capacity values.
* @param {Object} params - The parameters for the calculation
* @param {number} params.capacityValue2 - The second capacity value
* @param {number} params.capacityValue3 - The third capacity value
* @param {number} params.capacityValue4 - The fourth capacity value (optional)
* @param {number} params.comparer - The value to compare against
* @return {number} The maximum capacity calculated
*/
export const calculateMaxCapacity = ({
capacityValue2 = 0,
capacityValue3 = 0,
capacityValue4 = 0,
comparer,
}: {
capacityValue2: number;
capacityValue3: number;
capacityValue4: number | undefined;
comparer: number;
}): number => {
let maxCapacity = 0;
if (capacityValue4 < capacityValue2) {
if (capacityValue4 > 0) {
maxCapacity = capacityValue4;
} else if (capacityValue3 > capacityValue2) {
maxCapacity = capacityValue2;
} else if (capacityValue3 > 0) {
maxCapacity = capacityValue3;
}
} else {
maxCapacity = capacityValue2;
}
return Math.max(comparer, maxCapacity);
};

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