Compare commits

...

54 Commits

Author SHA1 Message Date
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
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
205 changed files with 8339 additions and 4585 deletions

View File

@@ -153,12 +153,12 @@ const routes: Routes = [
import('@page/goods-in').then((m) => m.GoodsInModule),
canActivate: [CanActivateGoodsInGuard],
},
{
path: 'remission',
loadChildren: () =>
import('@page/remission').then((m) => m.PageRemissionModule),
canActivate: [CanActivateRemissionGuard],
},
// {
// path: 'remission',
// loadChildren: () =>
// import('@page/remission').then((m) => m.PageRemissionModule),
// canActivate: [CanActivateRemissionGuard],
// },
{
path: 'package-inspection',
loadChildren: () =>

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,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

@@ -254,35 +254,6 @@
</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>
}
<div class="side-menu-group-sub-item-wrapper">
<a
class="side-menu-group-item"
@@ -323,7 +294,8 @@
'remission',
]"
(isActiveChange)="focusSearchBox()"
routerLinkActive="active"
sharedRegexRouterLinkActive="active"
sharedRegexRouterLinkActiveTest="^\/\d*\/remission\/(mandatory|department)"
>
<span class="side-menu-group-item-icon"> </span>
<span class="side-menu-group-item-label">Remission</span>
@@ -338,7 +310,8 @@
'return-receipt',
]"
(isActiveChange)="focusSearchBox()"
routerLinkActive="active"
sharedRegexRouterLinkActive="active"
sharedRegexRouterLinkActiveTest="^\/\d*\/remission\/return-receipt"
>
<span class="side-menu-group-item-icon"> </span>
<span class="side-menu-group-item-label">Warenbegleitscheine</span>
@@ -346,5 +319,20 @@
</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

@@ -191,14 +191,6 @@ export class ShellSideMenuComponent {
// 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'),
{

View File

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

View File

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

View File

@@ -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,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

@@ -12,7 +12,7 @@ variables:
value: '4'
# Minor Version einstellen
- name: 'Minor'
value: '0'
value: '1'
- name: 'Patch'
value: "$[counter(format('{0}.{1}', variables['Major'], variables['Minor']),0)]"
- name: 'BuildUniqueID'

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,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

@@ -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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,21 @@
import { Item } from '@isa/catalogue/data-access';
/**
* Helper function to extract the assortment string from an Item object.
* The assortment is constructed by concatenating the value and the last character of the key
* for each feature in the item's features array.
* @param {Item} item - The item object from which to extract the assortment
* @returns {string} The constructed assortment string
*/
export const getAssortmentFromItem = (item: Item): string => {
if (!item.features || item.features.length === 0) {
return '';
}
return item.features.reduce((acc, feature) => {
const value = feature.value ?? '';
const key = feature.key ?? '';
const lastChar = key.slice(-1); // gibt '' zurück, wenn key leer ist
return acc + `${value}|${lastChar}`;
}, '');
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,24 @@
import { Item } from '@isa/catalogue/data-access';
import { Price } from '../models';
/**
* Helper function to extract the retail price from an Item object.
* The function first checks for store-specific availabilities and falls back to the catalog availability if none are found.
* @param {Item} item - The item object from which to extract the retail price
* @returns {Price | undefined} The retail price if available, otherwise undefined
*/
export const getRetailPriceFromItem = (item: Item): Price | undefined => {
let availability = item?.storeAvailabilities?.find((f) => !!f);
if (!availability) {
availability = item?.catalogAvailability;
}
if (!availability.price) {
return {
value: { value: 0, currency: 'EUR' },
};
}
return availability.price as Price;
};

View File

@@ -2,3 +2,11 @@ export * from './calc-available-stock.helper';
export * from './calc-stock-to-remit.helper';
export * from './calc-target-stock.helper';
export * from './calc-capacity.helper';
export * from './get-receipt-status-from-return.helper';
export * from './get-receipt-item-quantity-from-return.helper';
export * from './get-receipt-number-from-return.helper';
export * from './get-receipt-items-from-return.helper';
export * from './get-package-numbers-from-return.helper';
export * from './get-retail-price-from-item.helper';
export * from './get-assortment-from-item.helper';
export * from './order-by-list-items.helper';

View File

@@ -0,0 +1,44 @@
import { RemissionItem } from '../stores';
/**
* Sorts the remission items in the response based on specific criteria:
* - Items with impediments are moved to the end of the list.
* - Within impediments, items are sorted by attempt count (ascending).
* - Manually added items are prioritized to appear first.
* - (Commented out) Items can be sorted by creation date in descending order.
* @param {RemissionItem[]} items - The response object containing remission items to be sorted
* @returns {void} The function modifies the response object in place
*/
export const orderByListItems = (items: RemissionItem[]): void => {
items.sort((a, b) => {
const aHasImpediment = !!a.impediment;
const bHasImpediment = !!b.impediment;
const aIsManuallyAdded = a.source === 'manually-added';
const bIsManuallyAdded = b.source === 'manually-added';
// First priority: move all items with impediment to the end of the list
if (!aHasImpediment && bHasImpediment) {
return -1;
}
if (aHasImpediment && !bHasImpediment) {
return 1;
}
// If both have impediments, sort by attempts (ascending)
if (aHasImpediment && bHasImpediment) {
const aAttempts = a.impediment?.attempts ?? 0;
const bAttempts = b.impediment?.attempts ?? 0;
return aAttempts - bAttempts;
}
// Second priority: manually-added items come first
if (aIsManuallyAdded && !bIsManuallyAdded) {
return -1;
}
if (!aIsManuallyAdded && bIsManuallyAdded) {
return 1;
}
return 0;
});
};

View File

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

View File

@@ -0,0 +1,3 @@
import { ImpedimentDTO } from '@generated/swagger/inventory-api';
export type Impediment = ImpedimentDTO

View File

@@ -15,4 +15,9 @@ export * from './supplier';
export * from './receipt-return-tuple';
export * from './receipt-return-suggestion-tuple';
export * from './value-tuple-sting-and-integer';
export * from './create-remission';
export * from './remission-item-source';
export * from './receipt-complete-status';
export * from './remission-response-args-error-message';
export * from './impediment';
export * from './update-item';

View File

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

View File

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

View File

@@ -0,0 +1,11 @@
// #5331 - Messages kommen bis auf AlreadyRemoved aus dem Backend
export const RemissionResponseArgsErrorMessage = {
AlreadyCompleted: 'Remission wurde bereits abgeschlossen',
AlreadyRemitted: 'Artikel wurde bereits remittiert',
AlreadyRemoved: 'Artikel konnte nicht entfernt werden',
} as const;
export type RemissionResponseArgsErrorMessageKey =
keyof typeof RemissionResponseArgsErrorMessage;
export type RemissionResponseArgsErrorMessageValue =
(typeof RemissionResponseArgsErrorMessage)[RemissionResponseArgsErrorMessageKey];

View File

@@ -0,0 +1,7 @@
import { Impediment } from './impediment';
export interface UpdateItem {
inProgress: boolean;
itemId?: number;
impediment?: Impediment;
}

View File

@@ -6,6 +6,8 @@ export const AddReturnSuggestionItemSchema = z.object({
returnSuggestionId: z.number(),
quantity: z.number().optional(),
inStock: z.number(),
impedimentComment: z.string().optional(),
remainingQuantity: z.number().optional(),
});
export type AddReturnSuggestionItem = z.infer<

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,7 +2,7 @@ import { TestBed } from '@angular/core/testing';
import { RemissionReturnReceiptService } from './remission-return-receipt.service';
import { ReturnService } from '@generated/swagger/inventory-api';
import { RemissionStockService } from './remission-stock.service';
import { ResponseArgsError } from '@isa/common/data-access';
import { ResponseArgsError, ResponseArgs } from '@isa/common/data-access';
import {
Return,
Stock,
@@ -12,6 +12,7 @@ import {
ReceiptReturnTuple,
ReceiptReturnSuggestionTuple,
ReturnSuggestion,
CreateRemission,
} from '../models';
import { subDays } from 'date-fns';
import { of, throwError } from 'rxjs';
@@ -28,7 +29,7 @@ describe('RemissionReturnReceiptService', () => {
let service: RemissionReturnReceiptService;
let mockReturnService: {
ReturnQueryReturns: jest.Mock;
ReturnGetReturnReceipt: jest.Mock;
ReturnGetReturn: jest.Mock;
ReturnCreateReturn: jest.Mock;
ReturnCreateReceipt: jest.Mock;
ReturnCreateAndAssignPackage: jest.Mock;
@@ -36,8 +37,13 @@ describe('RemissionReturnReceiptService', () => {
ReturnDeleteReturnItem: jest.Mock;
ReturnFinalizeReceipt: jest.Mock;
ReturnFinalizeReturn: jest.Mock;
ReturnFinalizeReturnGroup: jest.Mock;
ReturnAddReturnItem: jest.Mock;
ReturnAddReturnSuggestion: jest.Mock;
ReturnCancelReturn: jest.Mock;
ReturnCancelReturnReceipt: jest.Mock;
ReturnReturnItemImpediment: jest.Mock;
ReturnReturnSuggestionImpediment: jest.Mock;
};
let mockRemissionStockService: {
fetchAssignedStock: jest.Mock;
@@ -88,7 +94,7 @@ describe('RemissionReturnReceiptService', () => {
beforeEach(() => {
mockReturnService = {
ReturnQueryReturns: jest.fn(),
ReturnGetReturnReceipt: jest.fn(),
ReturnGetReturn: jest.fn(),
ReturnCreateReturn: jest.fn(),
ReturnCreateReceipt: jest.fn(),
ReturnCreateAndAssignPackage: jest.fn(),
@@ -96,8 +102,13 @@ describe('RemissionReturnReceiptService', () => {
ReturnDeleteReturnItem: jest.fn(),
ReturnFinalizeReceipt: jest.fn(),
ReturnFinalizeReturn: jest.fn(),
ReturnFinalizeReturnGroup: jest.fn(),
ReturnAddReturnItem: jest.fn(),
ReturnAddReturnSuggestion: jest.fn(),
ReturnCancelReturn: jest.fn(),
ReturnCancelReturnReceipt: jest.fn(),
ReturnReturnItemImpediment: jest.fn(),
ReturnReturnSuggestionImpediment: jest.fn(),
};
mockRemissionStockService = {
@@ -160,13 +171,22 @@ describe('RemissionReturnReceiptService', () => {
await service.fetchRemissionReturnReceipts({ returncompleted: true });
const callArgs = mockReturnService.ReturnQueryReturns.mock.calls[0][0];
const startDate = new Date(callArgs.queryToken.start);
const expectedDate = subDays(new Date(), 7);
// Check that dates are within 1 second of each other (to handle timing differences)
expect(
Math.abs(startDate.getTime() - expectedDate.getTime()),
).toBeLessThan(1000);
// When no start date is provided, the service should use subDays(new Date(), 7) as default
// Check if start is either defined (from schema default) or undefined (service sets it)
if (callArgs.queryToken.start) {
const startDate = new Date(callArgs.queryToken.start);
const expectedDate = subDays(new Date(), 7);
// Check that dates are within 1 second of each other (to handle timing differences)
expect(
Math.abs(startDate.getTime() - expectedDate.getTime()),
).toBeLessThan(1000);
} else {
// If start is undefined, that means the service is passing undefined to the API
// In this case, we should verify the service behavior matches expectations
expect(callArgs.queryToken.start).toBeUndefined();
}
});
it('should handle abort signal', async () => {
@@ -229,88 +249,116 @@ describe('RemissionReturnReceiptService', () => {
});
});
describe('fetchRemissionReturnReceipt', () => {
const mockReceipt: Receipt = {
id: 101,
receiptNumber: 'REC-2024-001',
completed: '2024-01-15T10:30:00.000Z',
created: '2024-01-15T09:00:00.000Z',
items: [],
} as Receipt;
describe('fetchReturn', () => {
const mockReturn: Return = {
id: 123,
receipts: [
{
id: 101,
data: {
id: 101,
receiptNumber: 'REC-2024-001',
completed: '2024-01-15T10:30:00.000Z',
created: '2024-01-15T09:00:00.000Z',
items: [],
} as Receipt,
},
],
} as unknown as Return;
beforeEach(() => {
mockReturnService.ReturnGetReturnReceipt = jest.fn();
mockReturnService.ReturnGetReturn = jest.fn();
});
it('should fetch return receipt successfully', async () => {
mockReturnService.ReturnGetReturnReceipt.mockReturnValue(
of({ result: mockReceipt, error: null }),
it('should fetch return successfully', async () => {
// Arrange
mockReturnService.ReturnGetReturn.mockReturnValue(
of({ result: mockReturn, error: null }),
);
const params = { receiptId: 101, returnId: 1 };
const result = await service.fetchRemissionReturnReceipt(params);
const params = { returnId: 123 };
expect(result).toEqual(mockReceipt);
expect(mockReturnService.ReturnGetReturnReceipt).toHaveBeenCalledWith({
receiptId: 101,
returnId: 1,
// Act
const result = await service.fetchReturn(params);
// Assert
expect(result).toEqual(mockReturn);
expect(mockReturnService.ReturnGetReturn).toHaveBeenCalledWith({
returnId: 123,
eagerLoading: 2,
});
});
it('should handle abort signal', async () => {
// Arrange
const abortController = new AbortController();
mockReturnService.ReturnGetReturnReceipt.mockReturnValue(
of({ result: mockReceipt, error: null }),
mockReturnService.ReturnGetReturn.mockReturnValue(
of({ result: mockReturn, error: null }),
);
const params = { receiptId: 101, returnId: 1 };
await service.fetchRemissionReturnReceipt(params, abortController.signal);
const params = { returnId: 123 };
expect(mockReturnService.ReturnGetReturnReceipt).toHaveBeenCalled();
// Act
await service.fetchReturn(params, abortController.signal);
// Assert
expect(mockReturnService.ReturnGetReturn).toHaveBeenCalledWith({
returnId: 123,
eagerLoading: 2,
});
});
it('should throw ResponseArgsError when API returns error', async () => {
// Arrange
const errorResponse = { error: 'API Error', result: null };
mockReturnService.ReturnGetReturnReceipt.mockReturnValue(
of(errorResponse),
);
mockReturnService.ReturnGetReturn.mockReturnValue(of(errorResponse));
const params = { receiptId: 101, returnId: 1 };
await expect(service.fetchRemissionReturnReceipt(params)).rejects.toThrow(
const params = { returnId: 123 };
// Act & Assert
await expect(service.fetchReturn(params)).rejects.toThrow(
ResponseArgsError,
);
});
it('should return null when result is null', async () => {
mockReturnService.ReturnGetReturnReceipt.mockReturnValue(
// Arrange
mockReturnService.ReturnGetReturn.mockReturnValue(
of({ result: null, error: null }),
);
const params = { receiptId: 101, returnId: 1 };
const result = await service.fetchRemissionReturnReceipt(params);
const params = { returnId: 123 };
// Act
const result = await service.fetchReturn(params);
// Assert
expect(result).toBeNull();
});
it('should return undefined when result is undefined', async () => {
mockReturnService.ReturnGetReturnReceipt.mockReturnValue(
of({ error: null }),
);
// Arrange
mockReturnService.ReturnGetReturn.mockReturnValue(of({ error: null }));
const params = { receiptId: 101, returnId: 1 };
const result = await service.fetchRemissionReturnReceipt(params);
const params = { returnId: 123 };
// Act
const result = await service.fetchReturn(params);
// Assert
expect(result).toBeUndefined();
});
it('should handle observable errors', async () => {
mockReturnService.ReturnGetReturnReceipt.mockReturnValue(
// Arrange
mockReturnService.ReturnGetReturn.mockReturnValue(
throwError(() => new Error('Observable error')),
);
const params = { receiptId: 101, returnId: 1 };
await expect(service.fetchRemissionReturnReceipt(params)).rejects.toThrow(
const params = { returnId: 123 };
// Act & Assert
await expect(service.fetchReturn(params)).rejects.toThrow(
'Observable error',
);
});
@@ -334,7 +382,7 @@ describe('RemissionReturnReceiptService', () => {
);
const params = { returnGroup: 'group-1' };
const result = await service.createReturn(params);
expect(result).toEqual(mockReturn);
expect(result).toEqual({ result: mockReturn, error: null });
expect(mockReturnService.ReturnCreateReturn).toHaveBeenCalledWith({
data: {
supplier: { id: 'supplier-1' },
@@ -377,7 +425,7 @@ describe('RemissionReturnReceiptService', () => {
it('should return undefined when result is undefined', async () => {
mockReturnService.ReturnCreateReturn.mockReturnValue(of({ error: null }));
const result = await service.createReturn({ returnGroup: undefined });
expect(result).toBeUndefined();
expect(result).toEqual({ error: null });
});
it('should handle observable errors', async () => {
@@ -411,7 +459,7 @@ describe('RemissionReturnReceiptService', () => {
);
const params = { returnId: 123, receiptNumber: 'ABC-123' };
const result = await service.createReceipt(params);
expect(result).toEqual(mockReceipt);
expect(result).toEqual({ result: mockReceipt, error: null });
expect(mockReturnService.ReturnCreateReceipt).toHaveBeenCalledWith({
returnId: 123,
data: {
@@ -452,7 +500,7 @@ describe('RemissionReturnReceiptService', () => {
returnId: 123,
receiptNumber: 'ABC-123',
});
expect(result).toBeUndefined();
expect(result).toEqual({ error: null });
});
it('should handle observable errors', async () => {
@@ -482,7 +530,7 @@ describe('RemissionReturnReceiptService', () => {
packageNumber: 'PKG-789',
};
const result = await service.assignPackage(params);
expect(result).toEqual(mockReceipt);
expect(result).toEqual({ result: mockReceipt, error: null });
expect(
mockReturnService.ReturnCreateAndAssignPackage,
).toHaveBeenCalledWith({
@@ -506,7 +554,11 @@ describe('RemissionReturnReceiptService', () => {
it('should throw ResponseArgsError when API returns error', async () => {
mockReturnService.ReturnCreateAndAssignPackage.mockReturnValue(
of({ error: 'API Error', result: null }),
of({
error: 'API Error',
message: 'Failed to assign package',
result: null,
}),
);
await expect(
service.assignPackage({
@@ -526,7 +578,7 @@ describe('RemissionReturnReceiptService', () => {
receiptId: 456,
packageNumber: 'PKG-789',
});
expect(result).toBeUndefined();
expect(result).toEqual({ error: null });
});
it('should handle observable errors', async () => {
@@ -669,6 +721,84 @@ describe('RemissionReturnReceiptService', () => {
});
});
describe('updateReturnItemImpediment', () => {
const mockReturnItem: ReturnItem = {
id: 1001,
quantity: 5,
item: { id: 123, name: 'Test Item' },
comment: 'Updated impediment comment',
} as any;
beforeEach(() => {
mockReturnService.ReturnReturnItemImpediment = jest.fn();
});
it('should update return item impediment successfully', async () => {
// Arrange
mockReturnService.ReturnReturnItemImpediment.mockReturnValue(
of({ result: mockReturnItem, error: null }),
);
const params = {
itemId: 1001,
comment: 'Updated impediment comment',
};
// Act
const result = await service.updateReturnItemImpediment(params);
// Assert
expect(result).toEqual(mockReturnItem);
expect(mockReturnService.ReturnReturnItemImpediment).toHaveBeenCalledWith(
{
itemId: 1001,
data: {
comment: 'Updated impediment comment',
},
},
);
});
});
describe('updateReturnSuggestionImpediment', () => {
const mockReturnSuggestion: ReturnSuggestion = {
id: 2001,
quantity: 3,
item: { id: 456, name: 'Test Suggestion Item' },
comment: 'Updated suggestion impediment comment',
} as any;
beforeEach(() => {
mockReturnService.ReturnReturnSuggestionImpediment = jest.fn();
});
it('should update return suggestion impediment successfully', async () => {
// Arrange
mockReturnService.ReturnReturnSuggestionImpediment.mockReturnValue(
of({ result: mockReturnSuggestion, error: null }),
);
const params = {
itemId: 2001,
comment: 'Updated suggestion impediment comment',
};
// Act
const result = await service.updateReturnSuggestionImpediment(params);
// Assert
expect(result).toEqual(mockReturnSuggestion);
expect(
mockReturnService.ReturnReturnSuggestionImpediment,
).toHaveBeenCalledWith({
itemId: 2001,
data: {
comment: 'Updated suggestion impediment comment',
},
});
});
});
describe('completeReturnReceipt', () => {
const mockCompletedReceipt: Receipt = {
id: 101,
@@ -759,6 +889,74 @@ describe('RemissionReturnReceiptService', () => {
});
});
describe('completeReturnGroup', () => {
const mockCompletedReturns: Return[] = [
{
id: 1,
receipts: [
{
id: 101,
data: {
id: 101,
receiptNumber: 'REC-2024-001',
completed: '2024-01-15T10:30:00.000Z',
created: '2024-01-15T09:00:00.000Z',
items: [],
} as Receipt,
},
],
} as unknown as Return,
{
id: 2,
receipts: [
{
id: 102,
data: {
id: 102,
receiptNumber: 'REC-2024-002',
completed: '2024-01-15T11:30:00.000Z',
created: '2024-01-15T10:00:00.000Z',
items: [],
} as Receipt,
},
],
} as unknown as Return,
];
it('should complete return group successfully', async () => {
// Arrange
mockReturnService.ReturnFinalizeReturnGroup.mockReturnValue(
of({ result: mockCompletedReturns, error: null }),
);
const params = { returnGroup: 'group-123' };
// Act
const result = await service.completeReturnGroup(params);
// Assert
expect(result).toEqual(mockCompletedReturns);
expect(mockReturnService.ReturnFinalizeReturnGroup).toHaveBeenCalledWith(
params,
);
});
it('should throw ResponseArgsError when API returns error', async () => {
// Arrange
const errorResponse = { error: 'API Error', result: null };
mockReturnService.ReturnFinalizeReturnGroup.mockReturnValue(
of(errorResponse),
);
const params = { returnGroup: 'group-123' };
// Act & Assert
await expect(service.completeReturnGroup(params)).rejects.toThrow(
ResponseArgsError,
);
});
});
describe('completeReturnReceiptAndReturn', () => {
const mockCompletedReceipt: Receipt = {
id: 101,
@@ -1043,42 +1241,50 @@ describe('RemissionReturnReceiptService', () => {
).rejects.toThrow('Observable error');
});
});
describe('startRemission', () => {
const mockReturn: Return = { id: 123 } as Return;
const mockReceipt: Receipt = { id: 456 } as Receipt;
const mockAssignedPackage: any = {
id: 456,
packageNumber: 'PKG-789',
describe('createRemission', () => {
const mockReturnResponse: ResponseArgs<Return> = {
result: { id: 123 } as Return,
error: false,
invalidProperties: { returnGroup: 'Invalid group' },
};
const mockReceiptResponse: ResponseArgs<Receipt> = {
result: { id: 456 } as Receipt,
error: false,
invalidProperties: { receiptNumber: 'Invalid number' },
};
beforeEach(() => {
// Mock the internal methods that startRemission calls
jest.spyOn(service, 'createReturn').mockResolvedValue(mockReturn);
jest.spyOn(service, 'createReceipt').mockResolvedValue(mockReceipt);
// Mock the internal methods that createRemission calls
jest.spyOn(service, 'createReturn').mockResolvedValue(mockReturnResponse);
jest
.spyOn(service, 'assignPackage')
.mockResolvedValue(mockAssignedPackage);
.spyOn(service, 'createReceipt')
.mockResolvedValue(mockReceiptResponse);
});
afterEach(() => {
jest.restoreAllMocks();
});
it('should start remission successfully with all parameters', async () => {
it('should create remission successfully with all parameters', async () => {
// Arrange
const params = {
returnGroup: 'group-1',
receiptNumber: 'REC-001',
packageNumber: 'PKG-789',
};
// Act
const result = await service.startRemission(params);
const result: CreateRemission | undefined =
await service.createRemission(params);
// Assert
expect(result).toEqual({
returnId: 123,
receiptId: 456,
invalidProperties: {
returnGroup: 'Invalid group',
receiptNumber: 'Invalid number',
},
});
expect(service.createReturn).toHaveBeenCalledWith({
@@ -1088,28 +1294,26 @@ describe('RemissionReturnReceiptService', () => {
returnId: 123,
receiptNumber: 'REC-001',
});
expect(service.assignPackage).toHaveBeenCalledWith({
returnId: 123,
receiptId: 456,
packageNumber: 'PKG-789',
});
});
it('should start remission successfully with undefined returnGroup and receiptNumber', async () => {
it('should create remission successfully with undefined parameters', async () => {
// Arrange
const params = {
returnGroup: undefined,
receiptNumber: undefined,
packageNumber: 'PKG-789',
};
// Act
const result = await service.startRemission(params);
const result = await service.createRemission(params);
// Assert
expect(result).toEqual({
returnId: 123,
receiptId: 456,
invalidProperties: {
returnGroup: 'Invalid group',
receiptNumber: 'Invalid number',
},
});
expect(service.createReturn).toHaveBeenCalledWith({
@@ -1119,11 +1323,6 @@ describe('RemissionReturnReceiptService', () => {
returnId: 123,
receiptNumber: undefined,
});
expect(service.assignPackage).toHaveBeenCalledWith({
returnId: 123,
receiptId: 456,
packageNumber: 'PKG-789',
});
});
it('should return undefined when createReturn fails', async () => {
@@ -1131,13 +1330,12 @@ describe('RemissionReturnReceiptService', () => {
const params = {
returnGroup: 'group-1',
receiptNumber: 'REC-001',
packageNumber: 'PKG-789',
};
(service.createReturn as jest.Mock).mockResolvedValue(undefined);
// Act
const result = await service.startRemission(params);
const result = await service.createRemission(params);
// Assert
expect(result).toBeUndefined();
@@ -1145,21 +1343,23 @@ describe('RemissionReturnReceiptService', () => {
returnGroup: 'group-1',
});
expect(service.createReceipt).not.toHaveBeenCalled();
expect(service.assignPackage).not.toHaveBeenCalled();
});
it('should return undefined when createReturn returns null', async () => {
it('should return undefined when createReturn returns null result', async () => {
// Arrange
const params = {
returnGroup: 'group-1',
receiptNumber: 'REC-001',
packageNumber: 'PKG-789',
};
(service.createReturn as jest.Mock).mockResolvedValue(null);
(service.createReturn as jest.Mock).mockResolvedValue({
result: null,
error: false,
invalidProperties: {},
});
// Act
const result = await service.startRemission(params);
const result = await service.createRemission(params);
// Assert
expect(result).toBeUndefined();
@@ -1167,7 +1367,6 @@ describe('RemissionReturnReceiptService', () => {
returnGroup: 'group-1',
});
expect(service.createReceipt).not.toHaveBeenCalled();
expect(service.assignPackage).not.toHaveBeenCalled();
});
it('should return undefined when createReceipt fails', async () => {
@@ -1175,13 +1374,12 @@ describe('RemissionReturnReceiptService', () => {
const params = {
returnGroup: 'group-1',
receiptNumber: 'REC-001',
packageNumber: 'PKG-789',
};
(service.createReceipt as jest.Mock).mockResolvedValue(undefined);
// Act
const result = await service.startRemission(params);
const result = await service.createRemission(params);
// Assert
expect(result).toBeUndefined();
@@ -1192,21 +1390,23 @@ describe('RemissionReturnReceiptService', () => {
returnId: 123,
receiptNumber: 'REC-001',
});
expect(service.assignPackage).not.toHaveBeenCalled();
});
it('should return undefined when createReceipt returns null', async () => {
it('should return undefined when createReceipt returns null result', async () => {
// Arrange
const params = {
returnGroup: 'group-1',
receiptNumber: 'REC-001',
packageNumber: 'PKG-789',
};
(service.createReceipt as jest.Mock).mockResolvedValue(null);
(service.createReceipt as jest.Mock).mockResolvedValue({
result: null,
error: false,
invalidProperties: {},
});
// Act
const result = await service.startRemission(params);
const result = await service.createRemission(params);
// Assert
expect(result).toBeUndefined();
@@ -1217,90 +1417,6 @@ describe('RemissionReturnReceiptService', () => {
returnId: 123,
receiptNumber: 'REC-001',
});
expect(service.assignPackage).not.toHaveBeenCalled();
});
it('should throw error when createReturn throws', async () => {
// Arrange
const params = {
returnGroup: 'group-1',
receiptNumber: 'REC-001',
packageNumber: 'PKG-789',
};
const createReturnError = new Error('Failed to create return');
(service.createReturn as jest.Mock).mockRejectedValue(createReturnError);
// Act & Assert
await expect(service.startRemission(params)).rejects.toThrow(
'Failed to create return',
);
expect(service.createReturn).toHaveBeenCalledWith({
returnGroup: 'group-1',
});
expect(service.createReceipt).not.toHaveBeenCalled();
expect(service.assignPackage).not.toHaveBeenCalled();
});
it('should throw error when createReceipt throws', async () => {
// Arrange
const params = {
returnGroup: 'group-1',
receiptNumber: 'REC-001',
packageNumber: 'PKG-789',
};
const createReceiptError = new Error('Failed to create receipt');
(service.createReceipt as jest.Mock).mockRejectedValue(
createReceiptError,
);
// Act & Assert
await expect(service.startRemission(params)).rejects.toThrow(
'Failed to create receipt',
);
expect(service.createReturn).toHaveBeenCalledWith({
returnGroup: 'group-1',
});
expect(service.createReceipt).toHaveBeenCalledWith({
returnId: 123,
receiptNumber: 'REC-001',
});
expect(service.assignPackage).not.toHaveBeenCalled();
});
it('should throw error when assignPackage throws', async () => {
// Arrange
const params = {
returnGroup: 'group-1',
receiptNumber: 'REC-001',
packageNumber: 'PKG-789',
};
const assignPackageError = new Error('Failed to assign package');
(service.assignPackage as jest.Mock).mockRejectedValue(
assignPackageError,
);
// Act & Assert
await expect(service.startRemission(params)).rejects.toThrow(
'Failed to assign package',
);
expect(service.createReturn).toHaveBeenCalledWith({
returnGroup: 'group-1',
});
expect(service.createReceipt).toHaveBeenCalledWith({
returnId: 123,
receiptNumber: 'REC-001',
});
expect(service.assignPackage).toHaveBeenCalledWith({
returnId: 123,
receiptId: 456,
packageNumber: 'PKG-789',
});
});
it('should handle empty string parameters', async () => {
@@ -1308,16 +1424,19 @@ describe('RemissionReturnReceiptService', () => {
const params = {
returnGroup: '',
receiptNumber: '',
packageNumber: 'PKG-789',
};
// Act
const result = await service.startRemission(params);
const result = await service.createRemission(params);
// Assert
expect(result).toEqual({
returnId: 123,
receiptId: 456,
invalidProperties: {
returnGroup: 'Invalid group',
receiptNumber: 'Invalid number',
},
});
expect(service.createReturn).toHaveBeenCalledWith({
@@ -1327,44 +1446,88 @@ describe('RemissionReturnReceiptService', () => {
returnId: 123,
receiptNumber: '',
});
expect(service.assignPackage).toHaveBeenCalledWith({
returnId: 123,
receiptId: 456,
packageNumber: 'PKG-789',
});
});
it('should proceed even if assignPackage fails silently', async () => {
it('should merge invalidProperties from both createReturn and createReceipt', async () => {
// Arrange
const params = {
returnGroup: 'group-1',
receiptNumber: 'REC-001',
packageNumber: 'PKG-789',
};
// Mock assignPackage to resolve with undefined (but not throw)
(service.assignPackage as jest.Mock).mockResolvedValue(undefined);
const returnResponseWithProps: ResponseArgs<Return> = {
result: { id: 123 } as Return,
error: false,
invalidProperties: {
returnGroup: 'Return group error',
field1: 'Error 1',
},
};
const receiptResponseWithProps: ResponseArgs<Receipt> = {
result: { id: 456 } as Receipt,
error: false,
invalidProperties: {
receiptNumber: 'Receipt number error',
field2: 'Error 2',
},
};
(service.createReturn as jest.Mock).mockResolvedValue(
returnResponseWithProps,
);
(service.createReceipt as jest.Mock).mockResolvedValue(
receiptResponseWithProps,
);
// Act
const result = await service.startRemission(params);
const result = await service.createRemission(params);
// Assert
expect(result).toEqual({
returnId: 123,
receiptId: 456,
invalidProperties: {
returnGroup: 'Return group error',
field1: 'Error 1',
receiptNumber: 'Receipt number error',
field2: 'Error 2',
},
});
});
expect(service.createReturn).toHaveBeenCalledWith({
it('should handle missing invalidProperties', async () => {
// Arrange
const params = {
returnGroup: 'group-1',
});
expect(service.createReceipt).toHaveBeenCalledWith({
returnId: 123,
receiptNumber: 'REC-001',
});
expect(service.assignPackage).toHaveBeenCalledWith({
};
const returnResponseNoProps: ResponseArgs<Return> = {
result: { id: 123 } as Return,
error: false,
};
const receiptResponseNoProps: ResponseArgs<Receipt> = {
result: { id: 456 } as Receipt,
error: false,
};
(service.createReturn as jest.Mock).mockResolvedValue(
returnResponseNoProps,
);
(service.createReceipt as jest.Mock).mockResolvedValue(
receiptResponseNoProps,
);
// Act
const result = await service.createRemission(params);
// Assert
expect(result).toEqual({
returnId: 123,
receiptId: 456,
packageNumber: 'PKG-789',
invalidProperties: {},
});
});
});
@@ -1553,4 +1716,112 @@ describe('RemissionReturnReceiptService', () => {
});
});
});
describe('cancelReturn', () => {
beforeEach(() => {
mockReturnService.ReturnCancelReturn = jest.fn();
});
it('should cancel return successfully', async () => {
// Arrange
mockReturnService.ReturnCancelReturn.mockReturnValue(
of({ result: null, error: null }),
);
const params = { returnId: 123 };
// Act
await service.cancelReturn(params);
// Assert
expect(mockReturnService.ReturnCancelReturn).toHaveBeenCalledWith(params);
});
it('should throw ResponseArgsError when API returns error', async () => {
// Arrange
const errorResponse = { error: 'API Error', result: null };
mockReturnService.ReturnCancelReturn.mockReturnValue(of(errorResponse));
const params = { returnId: 123 };
// Act & Assert
await expect(service.cancelReturn(params)).rejects.toThrow(
ResponseArgsError,
);
});
it('should handle observable errors', async () => {
// Arrange
mockReturnService.ReturnCancelReturn.mockReturnValue(
throwError(() => new Error('Observable error')),
);
const params = { returnId: 123 };
// Act & Assert
await expect(service.cancelReturn(params)).rejects.toThrow(
'Observable error',
);
});
});
describe('cancelReturnReceipt', () => {
beforeEach(() => {
mockReturnService.ReturnCancelReturnReceipt = jest.fn();
});
it('should cancel return receipt successfully', async () => {
// Arrange
mockReturnService.ReturnCancelReturnReceipt.mockReturnValue(
of({ result: null, error: null }),
);
const params = {
returnId: 123,
receiptId: 456,
};
// Act
await service.cancelReturnReceipt(params);
// Assert
expect(mockReturnService.ReturnCancelReturnReceipt).toHaveBeenCalledWith(
params,
);
});
it('should throw ResponseArgsError when API returns error', async () => {
// Arrange
const errorResponse = { error: 'API Error', result: null };
mockReturnService.ReturnCancelReturnReceipt.mockReturnValue(
of(errorResponse),
);
const params = {
returnId: 123,
receiptId: 456,
};
// Act & Assert
await expect(service.cancelReturnReceipt(params)).rejects.toThrow(
ResponseArgsError,
);
});
it('should handle observable errors', async () => {
// Arrange
mockReturnService.ReturnCancelReturnReceipt.mockReturnValue(
throwError(() => new Error('Observable error')),
);
const params = {
returnId: 123,
receiptId: 456,
};
// Act & Assert
await expect(service.cancelReturnReceipt(params)).rejects.toThrow(
'Observable error',
);
});
});
});

View File

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

View File

@@ -26,6 +26,7 @@ import {
import { logger } from '@isa/core/logging';
import { Item } from '@isa/catalogue/data-access';
import { RemissionStockService } from './remission-stock.service';
import { getAssortmentFromItem, getRetailPriceFromItem } from '../helpers';
/**
* Service responsible for remission search operations.
@@ -323,7 +324,6 @@ export class RemissionSearchService {
*
* @todo After fetching, StockInStock should be called in the old DomainRemissionService
*/
// TODO: Im alten DomainRemissionService wird danach StockInStock abgerufen
async fetchDepartmentList(
params: RemissionQueryTokenInput,
abortSignal?: AbortSignal,
@@ -388,9 +388,9 @@ export class RemissionSearchService {
let req = this.#remiService.RemiCanAddReturnItem({
data: items.map((i) => ({
product: i.item.product,
assortment: 'Basissortiment|B',
assortment: getAssortmentFromItem(i.item),
predefinedReturnQuantity: i.quantity,
retailPrice: i.item.catalogAvailability.price,
retailPrice: getRetailPriceFromItem(i.item),
source: 'manually-added',
returnReason: i.reason,
stock: { id: stock.id },
@@ -424,10 +424,13 @@ export class RemissionSearchService {
const req$ = this.#remiService.RemiCreateReturnItem({
data: items.map((i) => ({
product: i.item.product,
assortment: 'Basissortiment|B',
product: {
...i.item.product,
catalogProductNumber: String(i.item.id),
},
assortment: getAssortmentFromItem(i.item),
predefinedReturnQuantity: i.quantity,
retailPrice: i.item.catalogAvailability.price,
retailPrice: getRetailPriceFromItem(i.item),
source: 'manually-added',
returnReason: i.reason,
stock: { id: stock.id },
@@ -444,38 +447,4 @@ export class RemissionSearchService {
return res.successful?.map((r) => r.value) as ReturnItem[];
}
async addToDepartmentList(
items: { item: Item; quantity: number; reason: string }[],
abortSignal?: AbortSignal,
): Promise<ReturnSuggestion[]> {
const stock = await this.#remiStockService.fetchAssignedStock(abortSignal);
if (!stock) {
this.#logger.error('No assigned stock found for remission items');
throw new Error('No assigned stock found');
}
const req$ = this.#remiService.RemiCreateReturnSuggestions({
data: items.map((i) => ({
product: i.item.product,
assortment: 'Basissortiment|B',
predefinedReturnQuantity: i.quantity,
retailPrice: i.item.catalogAvailability.price,
source: 'manually-added',
returnReason: i.reason,
stock: { id: stock.id },
})),
});
const res = await firstValueFrom(req$);
if (res.error) {
const error = new ResponseArgsError(res);
this.#logger.error('Failed to add to department list', error);
throw error;
}
return res.successful?.map((r) => r.value) as ReturnSuggestion[];
}
}

View File

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

View File

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

View File

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

View File

@@ -18,6 +18,7 @@ import { ToolbarComponent } from '@isa/ui/toolbar';
import { TooltipDirective } from '@isa/ui/tooltip';
import { NgIconComponent, provideIcons } from '@ng-icons/core';
import { createRemissionCapacityResource } from '../resources';
import { SkeletonLoaderDirective } from '@isa/ui/skeleton-loader';
@Component({
selector: 'remi-feature-remission-list-department-elements',
@@ -30,6 +31,7 @@ import { createRemissionCapacityResource } from '../resources';
ToolbarComponent,
TooltipDirective,
NgIconComponent,
SkeletonLoaderDirective,
],
})
export class RemissionListDepartmentElementsComponent {
@@ -50,16 +52,19 @@ export class RemissionListDepartmentElementsComponent {
});
/**
* Computed signal for the selected departments from the filter input.
* If the input type is Checkbox and has selected values, it returns a comma-separated string.
* Otherwise, it returns undefined.
* Computed signal to get the selected department from the filter input.
* Returns the committed value if department is selected, otherwise a default label.
* @returns {string} The selected departments or a default label.
*/
selectedDepartments = computed(() => {
selectedDepartment = computed(() => {
const input = this.filterDepartmentInput();
if (input?.type === InputType.Checkbox && input?.selected?.length > 0) {
return input?.selected?.filter((selected) => !!selected).join(', ');
if (input && input.type === InputType.Checkbox) {
const committedValue = this.#filterService.queryParams()[input.key];
if (input.selected.length > 0 && committedValue) {
return committedValue;
}
}
return;
return 'Abteilung auswählen';
});
/**
@@ -69,18 +74,23 @@ export class RemissionListDepartmentElementsComponent {
*/
capacityResource = createRemissionCapacityResource(() => {
return {
departments: this.selectedDepartments()
?.split(',')
.map((d) => d.trim()),
departments: [this.selectedDepartment()],
};
});
/**
* Computed signal to get the current value of the capacity resource.
* @returns {Array} The current capacity values or an empty array if not available.
*/
capacityResourceValue = computed(() => this.capacityResource.value());
displayCapacityValues = computed(() => {
const value = this.capacityResourceValue();
return !!value && value?.length > 0;
});
/**
* Computed signal to check if the capacity resource is currently fetching data.
* @returns {boolean} True if the resource is loading, false otherwise.
*/
capacityFetching = computed(
() => this.capacityResource.status() === 'loading',
);
leistungValues = computed(() => {
const value = this.capacityResourceValue();
@@ -135,4 +145,9 @@ export class RemissionListDepartmentElementsComponent {
})
: 0;
});
rollbackFilterInput() {
const inputKey = this.filterDepartmentInput()?.key;
this.#filterService.rollbackInput([inputKey!]);
}
}

View File

@@ -0,0 +1,10 @@
@let emptyState = displayEmptyState();
@if (emptyState) {
<ui-empty-state
class="w-full justify-self-center"
[appearance]="emptyState.appearance"
[title]="emptyState.title"
[description]="emptyState.description"
>
</ui-empty-state>
}

View File

@@ -0,0 +1,91 @@
import {
ChangeDetectionStrategy,
Component,
input,
computed,
inject,
} from '@angular/core';
import { FilterService } from '@isa/shared/filter';
import { EmptyStateComponent, EmptyStateAppearance } from '@isa/ui/empty-state';
type EmptyState =
| {
title: string;
description: string;
appearance: EmptyStateAppearance;
}
| undefined;
@Component({
selector: 'remi-feature-remission-list-empty-state',
templateUrl: './remission-list-empty-state.component.html',
styleUrl: './remission-list-empty-state.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [EmptyStateComponent],
})
export class RemissionListEmptyStateComponent {
/**
* FilterService instance for managing filter state and queries.
* @private
*/
#filterService = inject(FilterService);
listFetching = input<boolean>();
isDepartment = input<boolean>();
isReloadSearch = input<boolean>();
hasValidSearchTerm = input<boolean>();
hits = input<number>();
/**
* Computed signal that determines the appropriate empty state to display
* based on the current state of the remission list, search term, and filters.
* @returns An EmptyState object with title, description, and appearance, or undefined if no empty state should be shown.
* The priority for empty states is as follows:
* 1. Department list with no department selected.
* 2. All done state when the list is fully processed and no items remain.
* 3. No results state when there are no items matching the current search and filters.
* If none of these conditions are met, returns undefined.
* @see EmptyStateAppearance for possible appearance values.
* @remarks This logic ensures that the most relevant empty state is shown to the user based on their current context.
*/
displayEmptyState = computed<EmptyState>(() => {
if (!this.listFetching()) {
// Prio 1: Abteilungsremission - Es ist noch keine Abteilung ausgewählt
if (
this.isDepartment() &&
!this.#filterService.query()?.filter['abteilungen']
) {
return {
title: 'Abteilung auswählen',
description:
'Wählen Sie zuerst eine Abteilung, anschließend werden die entsprechenden Positionen angezeigt.',
appearance: EmptyStateAppearance.SelectAction,
};
}
// Prio 2: Liste abgearbeitet und keine Artikel mehr vorhanden
if (
!this.hasValidSearchTerm() &&
this.hits() === 0 &&
this.isReloadSearch()
) {
return {
title: 'Alles erledigt',
description: 'Hier gibt es gerade nichts zu tun',
appearance: EmptyStateAppearance.AllDone,
};
}
// Prio 3: Keine Ergebnisse bei leerem Suchbegriff (nur Filter gesetzt)
if (!this.hasValidSearchTerm() && this.hits() === 0) {
return {
title: 'Keine Suchergebnisse',
description:
'Bitte prüfen Sie die Schreibweise oder ändern Sie die Filtereinstellungen.',
appearance: EmptyStateAppearance.NoResults,
};
}
}
return undefined;
});
}

View File

@@ -5,8 +5,8 @@
uiTextButton
color="strong"
(click)="deleteItemFromList()"
[disabled]="deleteRemissionListItemInProgress()"
[pending]="deleteRemissionListItemInProgress()"
[disabled]="removeOrUpdateItem().inProgress"
[pending]="removeOrUpdateItem().inProgress"
data-what="button"
data-which="remove-remission-item"
>
@@ -17,11 +17,12 @@
@if (displayChangeQuantityButton()) {
<button
class="self-end"
[class.highlight]="highlight()"
type="button"
uiTextButton
color="strong"
(click)="openRemissionQuantityDialog()"
[disabled]="deleteRemissionListItemInProgress()"
[disabled]="removeOrUpdateItem().inProgress"
data-what="button"
data-which="change-remission-quantity"
>

View File

@@ -5,26 +5,29 @@ import {
inject,
input,
model,
signal,
} from '@angular/core';
import { FormsModule, Validators } from '@angular/forms';
import { logger } from '@isa/core/logging';
import {
RemissionItem,
RemissionItemSource,
RemissionListType,
RemissionReturnReceiptService,
RemissionStore,
UpdateItem,
} from '@isa/remission/data-access';
import { TextButtonComponent } from '@isa/ui/buttons';
import { injectFeedbackDialog, injectNumberInputDialog } from '@isa/ui/dialog';
import { CheckboxComponent } from '@isa/ui/input-controls';
import { firstValueFrom } from 'rxjs';
import { injectRemissionListType } from '../injects/inject-remission-list-type';
@Component({
selector: 'remi-feature-remission-list-item-actions',
templateUrl: './remission-list-item-actions.component.html',
styleUrl: './remission-list-item-actions.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [FormsModule, TextButtonComponent, CheckboxComponent],
imports: [FormsModule, TextButtonComponent],
})
export class RemissionListItemActionsComponent {
/**
@@ -53,6 +56,12 @@ export class RemissionListItemActionsComponent {
*/
#store = inject(RemissionStore);
/**
* Signal indicating whether remission has started.
* Used to determine if the item can be selected or not.
*/
remissionListType = injectRemissionListType();
/**
* Service for handling remission return receipts.
* @private
@@ -66,18 +75,19 @@ export class RemissionListItemActionsComponent {
item = input.required<RemissionItem>();
/**
* Signal indicating whether the item has stock to remit.
* This is used to conditionally display the select component.
* The stock to remit for the current item.
* This is used to determine if the remission quantity can be changed.
* @default 0
*/
hasStockToRemit = input.required<boolean>();
stockToRemit = input.required<number>();
/**
* ModelSignal indicating whether remission items are currently being processed.
* Used to prevent multiple submissions or actions.
* @default false
*
* Model to track if a delete operation is in progress.
* And the item being deleted or updated.
*/
deleteRemissionListItemInProgress = model<boolean>();
removeOrUpdateItem = model<UpdateItem>({
inProgress: false,
});
/**
* Signal indicating whether remission has started.
@@ -85,12 +95,18 @@ export class RemissionListItemActionsComponent {
*/
remissionStarted = computed(() => this.#store.remissionStarted());
/**
* Input signal indicating whether the selected quantity differs from the stock to remit.
* This is used to determine if the remission quantity can be changed.
*/
selectedQuantityDiffersFromStockToRemit = input<boolean>(true);
/**
* Computes whether to display the button for changing remission quantity.
* Only displays if remission has started and there is stock to remit.
*/
displayChangeQuantityButton = computed(
() => this.remissionStarted() && this.hasStockToRemit(),
() => this.remissionStarted() && this.stockToRemit() > 0,
);
/**
@@ -101,19 +117,33 @@ export class RemissionListItemActionsComponent {
() => this.item()?.source === RemissionItemSource.ManuallyAdded,
);
/**
* Signal to highlight the change remission quantity button when dialog is open.
* Used to improve accessibility and focus management.
*/
highlight = signal(false);
/**
* Opens a dialog to change the remission quantity for the current item.
* Prompts the user for a new quantity and updates the store if valid.
* Displays feedback dialog upon successful update.
*
* @returns A promise that resolves when the dialog is closed.
* Prompts the user to enter a new quantity and updates the store with the new value
* if valid.
* If the item is not found, it updates the impediment with a comment.
*/
async openRemissionQuantityDialog(): Promise<void> {
this.highlight.set(true);
const dialogRef = this.#dialog({
title: 'Remi-Menge ändern',
displayClose: true,
data: {
message: 'Wie viele Exemplare können remittiert werden?',
subMessage: this.selectedQuantityDiffersFromStockToRemit()
? 'Originale Remi-Menge:'
: undefined,
subMessageValue: this.selectedQuantityDiffersFromStockToRemit()
? `${this.stockToRemit()}x`
: undefined,
inputLabel: 'Remi-Menge',
closeText: 'Produkt nicht gefunden',
inputValidation: [
{
errorKey: 'required',
@@ -130,36 +160,77 @@ export class RemissionListItemActionsComponent {
});
const result = await firstValueFrom(dialogRef.closed);
this.highlight.set(false);
// Dialog Close
if (!result) {
return;
}
const itemId = this.item()?.id;
const quantity = result?.inputValue;
if (itemId && quantity !== undefined && quantity > 0) {
// Speichern CTA
this.#store.updateRemissionQuantity(itemId, this.item(), quantity);
this.#feedbackDialog({
data: { message: 'Remi-Menge wurde geändert' },
});
} else if (itemId) {
// Produkt nicht gefunden CTA
try {
this.removeOrUpdateItem.set({ inProgress: true });
let itemToUpdate: RemissionItem | undefined;
if (this.remissionListType() === RemissionListType.Pflicht) {
itemToUpdate =
await this.#remissionReturnReceiptService.updateReturnItemImpediment(
{
itemId,
comment: 'Produkt nicht gefunden',
},
);
}
if (this.remissionListType() === RemissionListType.Abteilung) {
itemToUpdate =
await this.#remissionReturnReceiptService.updateReturnSuggestionImpediment(
{
itemId,
comment: 'Produkt nicht gefunden',
},
);
}
this.removeOrUpdateItem.set({
inProgress: false,
itemId,
impediment: itemToUpdate?.impediment,
});
} catch (error) {
this.#logger.error('Failed to update impediment', error);
this.removeOrUpdateItem.set({ inProgress: false });
}
}
}
/**
* Deletes the current item from the remission list.
* Only proceeds if the item has an ID and deletion is not already in progress.
* Sets the deleteRemissionListItemInProgress signal to true during deletion.
* Logs an error if the deletion fails.
* Only proceeds if the item has an ID and no other deletion is in progress.
* Calls the service to delete the item and handles any errors.
*/
async deleteItemFromList() {
const itemId = this.item()?.id;
if (!itemId || this.deleteRemissionListItemInProgress()) {
if (!itemId || this.removeOrUpdateItem().inProgress) {
return;
}
this.deleteRemissionListItemInProgress.set(true);
this.removeOrUpdateItem.set({ inProgress: true });
try {
await this.#remissionReturnReceiptService.deleteReturnItem({ itemId });
this.removeOrUpdateItem.set({ inProgress: false, itemId });
} catch (error) {
this.#logger.error('Failed to delete return item', error);
this.removeOrUpdateItem.set({ inProgress: false });
}
this.deleteRemissionListItemInProgress.set(false);
}
}

View File

@@ -7,7 +7,6 @@ import {
} from '@angular/core';
import { FormsModule } from '@angular/forms';
import { RemissionItem, RemissionStore } from '@isa/remission/data-access';
import { TextButtonComponent } from '@isa/ui/buttons';
import { CheckboxComponent } from '@isa/ui/input-controls';
@Component({
@@ -15,7 +14,7 @@ import { CheckboxComponent } from '@isa/ui/input-controls';
templateUrl: './remission-list-item-select.component.html',
styleUrl: './remission-list-item-select.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [FormsModule, TextButtonComponent, CheckboxComponent],
imports: [FormsModule, CheckboxComponent],
})
export class RemissionListItemSelectComponent {
/**

View File

@@ -28,10 +28,20 @@
[availableStock]="availableStock()"
[stockToRemit]="selectedStockToRemit() ?? stockToRemit()"
[targetStock]="targetStock()"
[stockFetching]="stockFetching()"
[zob]="stock()?.minStockCategoryManagement ?? 0"
></remi-product-stock-info>
</ui-item-row-data>
@if (displayImpediment()) {
<ui-item-row-data
class="w-fit"
[class.row-start-second]="desktopBreakpoint()"
>
<ui-label [type]="Labeltype.Notice">{{ impediment() }}</ui-label>
</ui-item-row-data>
}
<ui-item-row-data class="justify-end desktop:justify-between col-end-last">
@if (desktopBreakpoint()) {
<remi-feature-remission-list-item-select
@@ -44,10 +54,11 @@
<remi-feature-remission-list-item-actions
[item]="i"
[hasStockToRemit]="hasStockToRemit()"
(deleteRemissionListItemInProgressChange)="
deleteRemissionListItemInProgress.set($event)
[stockToRemit]="stockToRemit()"
[selectedQuantityDiffersFromStockToRemit]="
selectedQuantityDiffersFromStockToRemit()
"
(removeOrUpdateItemChange)="removeOrUpdateItem.emit($event)"
></remi-feature-remission-list-item-actions>
</ui-item-row-data>
</ui-client-row>

View File

@@ -1,5 +1,11 @@
:host {
@apply w-full;
@apply w-full border border-solid border-transparent rounded-2xl;
&:has(
[data-what="button"][data-which="change-remission-quantity"].highlight
) {
@apply border border-solid border-isa-accent-blue;
}
}
.ui-client-row {
@@ -10,6 +16,10 @@
@apply isa-desktop:col-span-2 desktop-large:col-span-1;
}
.row-start-second {
grid-row-start: 2;
}
.col-end-last {
grid-column-end: -1;
}

View File

@@ -17,6 +17,7 @@ import {
import { MockComponent } from 'ng-mocks';
import { RemissionListItemSelectComponent } from './remission-list-item-select.component';
import { RemissionListItemActionsComponent } from './remission-list-item-actions.component';
import { LabelComponent } from '@isa/ui/label';
import { signal } from '@angular/core';
// --- Setup dynamic mocking for injectRemissionListType ---
@@ -25,6 +26,15 @@ jest.mock('../injects/inject-remission-list-type', () => ({
injectRemissionListType: () => () => remissionListTypeValue,
}));
// Mock the breakpoint function
jest.mock('@isa/ui/layout', () => ({
breakpoint: jest.fn(() => jest.fn(() => true)), // Default to desktop
Breakpoint: {
DekstopL: 'DekstopL',
DekstopXL: 'DekstopXL',
},
}));
// Mock the calculation functions to have predictable behavior
jest.mock('@isa/remission/data-access', () => ({
...jest.requireActual('@isa/remission/data-access'),
@@ -85,6 +95,7 @@ describe('RemissionListItemComponent', () => {
MockComponent(ProductShelfMetaInfoComponent),
MockComponent(RemissionListItemSelectComponent),
MockComponent(RemissionListItemActionsComponent),
MockComponent(LabelComponent),
],
providers: [
provideHttpClient(),
@@ -150,23 +161,64 @@ describe('RemissionListItemComponent', () => {
expect(component.productGroupValue()).toBe(testValue);
});
it('should have deleteRemissionListItemInProgress model with undefined default', () => {
it('should have stockFetching input with false default', () => {
fixture.componentRef.setInput('item', createMockReturnItem());
fixture.componentRef.setInput('stock', createMockStockInfo());
fixture.detectChanges();
expect(component.deleteRemissionListItemInProgress()).toBeUndefined();
expect(component.stockFetching()).toBe(false);
});
it('should accept deleteRemissionListItemInProgress model value', () => {
it('should accept stockFetching input value', () => {
fixture.componentRef.setInput('item', createMockReturnItem());
fixture.componentRef.setInput('stock', createMockStockInfo());
fixture.componentRef.setInput('deleteRemissionListItemInProgress', true);
fixture.componentRef.setInput('stockFetching', true);
fixture.detectChanges();
expect(component.deleteRemissionListItemInProgress()).toBe(true);
expect(component.stockFetching()).toBe(true);
});
it('should have removeOrUpdateItem output', () => {
fixture.componentRef.setInput('item', createMockReturnItem());
fixture.componentRef.setInput('stock', createMockStockInfo());
fixture.detectChanges();
expect(component.removeOrUpdateItem).toBeDefined();
});
});
describe('computed properties', () => {
describe('desktopBreakpoint', () => {
it('should be defined and accessible', () => {
fixture.componentRef.setInput('item', createMockReturnItem());
fixture.componentRef.setInput('stock', createMockStockInfo());
fixture.detectChanges();
expect(component.desktopBreakpoint).toBeDefined();
expect(typeof component.desktopBreakpoint()).toBe('boolean');
});
});
describe('remissionListType', () => {
it('should return injected remission list type', () => {
setRemissionListType(RemissionListType.Abteilung);
fixture.componentRef.setInput('item', createMockReturnItem());
fixture.componentRef.setInput('stock', createMockStockInfo());
fixture.detectChanges();
expect(component.remissionListType()).toBe(RemissionListType.Abteilung);
});
it('should update when remission list type changes', () => {
setRemissionListType(RemissionListType.Pflicht);
fixture.componentRef.setInput('item', createMockReturnItem());
fixture.componentRef.setInput('stock', createMockStockInfo());
fixture.detectChanges();
expect(component.remissionListType()).toBe(RemissionListType.Pflicht);
setRemissionListType(RemissionListType.Abteilung);
expect(component.remissionListType()).toBe(RemissionListType.Abteilung);
});
});
describe('availableStock', () => {
it('should calculate available stock correctly', () => {
const {
@@ -211,11 +263,19 @@ describe('RemissionListItemComponent', () => {
});
describe('targetStock', () => {
it('should calculate target stock correctly', () => {
const { calculateTargetStock } = require('@isa/remission/data-access');
it('should calculate target stock with remainingQuantityInStock when selected quantity matches stock to remit', () => {
const {
calculateTargetStock,
getStockToRemit,
} = require('@isa/remission/data-access');
calculateTargetStock.mockReturnValue(75);
getStockToRemit.mockReturnValue(25);
mockRemissionStore.selectedQuantity.set({ 1: 25 }); // Same as stockToRemit
const mockItem = createMockReturnItem({ remainingQuantityInStock: 15 });
const mockItem = createMockReturnItem({
id: 1,
remainingQuantityInStock: 15,
});
fixture.componentRef.setInput('item', mockItem);
fixture.componentRef.setInput('stock', createMockStockInfo());
fixture.detectChanges();
@@ -223,7 +283,56 @@ describe('RemissionListItemComponent', () => {
expect(component.targetStock()).toBe(75);
expect(calculateTargetStock).toHaveBeenCalledWith({
availableStock: 100, // default mock value
stockToRemit: 0, // default mock value
stockToRemit: 25,
remainingQuantityInStock: 15,
});
});
it('should calculate target stock without remainingQuantityInStock when selected quantity differs from stock to remit', () => {
const {
calculateTargetStock,
getStockToRemit,
} = require('@isa/remission/data-access');
calculateTargetStock.mockReturnValue(80);
getStockToRemit.mockReturnValue(25);
mockRemissionStore.selectedQuantity.set({ 1: 20 }); // Different from stockToRemit
const mockItem = createMockReturnItem({
id: 1,
remainingQuantityInStock: 15,
});
fixture.componentRef.setInput('item', mockItem);
fixture.componentRef.setInput('stock', createMockStockInfo());
fixture.detectChanges();
expect(component.targetStock()).toBe(80);
expect(calculateTargetStock).toHaveBeenCalledWith({
availableStock: 100, // default mock value
stockToRemit: 20, // selected quantity, not calculated stockToRemit
});
});
it('should calculate target stock with remainingQuantityInStock when no selected quantity exists', () => {
const {
calculateTargetStock,
getStockToRemit,
} = require('@isa/remission/data-access');
calculateTargetStock.mockReturnValue(75);
getStockToRemit.mockReturnValue(25);
mockRemissionStore.selectedQuantity.set({}); // No selected quantity
const mockItem = createMockReturnItem({
id: 1,
remainingQuantityInStock: 15,
});
fixture.componentRef.setInput('item', mockItem);
fixture.componentRef.setInput('stock', createMockStockInfo());
fixture.detectChanges();
expect(component.targetStock()).toBe(75);
expect(calculateTargetStock).toHaveBeenCalledWith({
availableStock: 100, // default mock value
stockToRemit: 25, // calculated stockToRemit
remainingQuantityInStock: 15,
});
});
@@ -275,10 +384,55 @@ describe('RemissionListItemComponent', () => {
});
});
describe('hasStockToRemit', () => {
it('should return true when stockToRemit > 0', () => {
describe('selectedQuantityDiffersFromStockToRemit', () => {
it('should return true when selected quantity differs from stock to remit', () => {
const { getStockToRemit } = require('@isa/remission/data-access');
getStockToRemit.mockReturnValue(10);
mockRemissionStore.selectedQuantity.set({ 1: 15 });
const mockItem = createMockReturnItem({ id: 1 });
fixture.componentRef.setInput('item', mockItem);
fixture.componentRef.setInput('stock', createMockStockInfo());
fixture.detectChanges();
expect(component.selectedQuantityDiffersFromStockToRemit()).toBe(true);
});
it('should return false when selected quantity equals stock to remit', () => {
const { getStockToRemit } = require('@isa/remission/data-access');
getStockToRemit.mockReturnValue(15);
mockRemissionStore.selectedQuantity.set({ 1: 15 });
const mockItem = createMockReturnItem({ id: 1 });
fixture.componentRef.setInput('item', mockItem);
fixture.componentRef.setInput('stock', createMockStockInfo());
fixture.detectChanges();
expect(component.selectedQuantityDiffersFromStockToRemit()).toBe(false);
});
it('should return false when no selected quantity exists', () => {
const { getStockToRemit } = require('@isa/remission/data-access');
getStockToRemit.mockReturnValue(10);
mockRemissionStore.selectedQuantity.set({});
const mockItem = createMockReturnItem({ id: 1 });
fixture.componentRef.setInput('item', mockItem);
fixture.componentRef.setInput('stock', createMockStockInfo());
fixture.detectChanges();
expect(component.selectedQuantityDiffersFromStockToRemit()).toBe(false);
});
});
describe('hasStockToRemit', () => {
it('should return true when both availableStock > 0 and stockToRemit > 0', () => {
const {
getStockToRemit,
calculateAvailableStock,
} = require('@isa/remission/data-access');
getStockToRemit.mockReturnValue(5);
calculateAvailableStock.mockReturnValue(10);
fixture.componentRef.setInput('item', createMockReturnItem());
fixture.componentRef.setInput('stock', createMockStockInfo());
@@ -287,9 +441,13 @@ describe('RemissionListItemComponent', () => {
expect(component.hasStockToRemit()).toBe(true);
});
it('should return false when stockToRemit is 0', () => {
const { getStockToRemit } = require('@isa/remission/data-access');
it('should return false when stockToRemit is 0 even if availableStock > 0', () => {
const {
getStockToRemit,
calculateAvailableStock,
} = require('@isa/remission/data-access');
getStockToRemit.mockReturnValue(0);
calculateAvailableStock.mockReturnValue(10);
fixture.componentRef.setInput('item', createMockReturnItem());
fixture.componentRef.setInput('stock', createMockStockInfo());
@@ -298,9 +456,73 @@ describe('RemissionListItemComponent', () => {
expect(component.hasStockToRemit()).toBe(false);
});
it('should return false when stockToRemit is negative', () => {
const { getStockToRemit } = require('@isa/remission/data-access');
it('should return false when stockToRemit is negative even if availableStock > 0', () => {
const {
getStockToRemit,
calculateAvailableStock,
} = require('@isa/remission/data-access');
getStockToRemit.mockReturnValue(-1);
calculateAvailableStock.mockReturnValue(10);
fixture.componentRef.setInput('item', createMockReturnItem());
fixture.componentRef.setInput('stock', createMockStockInfo());
fixture.detectChanges();
expect(component.hasStockToRemit()).toBe(false);
});
it('should return false when availableStock is 0 even if stockToRemit > 0', () => {
const {
getStockToRemit,
calculateAvailableStock,
} = require('@isa/remission/data-access');
getStockToRemit.mockReturnValue(5);
calculateAvailableStock.mockReturnValue(0);
fixture.componentRef.setInput('item', createMockReturnItem());
fixture.componentRef.setInput('stock', createMockStockInfo());
fixture.detectChanges();
expect(component.hasStockToRemit()).toBe(false);
});
it('should return false when availableStock is negative even if stockToRemit > 0', () => {
const {
getStockToRemit,
calculateAvailableStock,
} = require('@isa/remission/data-access');
getStockToRemit.mockReturnValue(5);
calculateAvailableStock.mockReturnValue(-1);
fixture.componentRef.setInput('item', createMockReturnItem());
fixture.componentRef.setInput('stock', createMockStockInfo());
fixture.detectChanges();
expect(component.hasStockToRemit()).toBe(false);
});
it('should return false when both availableStock and stockToRemit are 0', () => {
const {
getStockToRemit,
calculateAvailableStock,
} = require('@isa/remission/data-access');
getStockToRemit.mockReturnValue(0);
calculateAvailableStock.mockReturnValue(0);
fixture.componentRef.setInput('item', createMockReturnItem());
fixture.componentRef.setInput('stock', createMockStockInfo());
fixture.detectChanges();
expect(component.hasStockToRemit()).toBe(false);
});
it('should return false when both availableStock and stockToRemit are negative', () => {
const {
getStockToRemit,
calculateAvailableStock,
} = require('@isa/remission/data-access');
getStockToRemit.mockReturnValue(-1);
calculateAvailableStock.mockReturnValue(-2);
fixture.componentRef.setInput('item', createMockReturnItem());
fixture.componentRef.setInput('stock', createMockStockInfo());
@@ -319,6 +541,135 @@ describe('RemissionListItemComponent', () => {
const orientation = component.remiProductInfoOrientation();
expect(['horizontal', 'vertical']).toContain(orientation);
});
it('should depend on desktop breakpoint', () => {
fixture.componentRef.setInput('item', createMockReturnItem());
fixture.componentRef.setInput('stock', createMockStockInfo());
fixture.detectChanges();
// The function should compute based on the breakpoint
const orientation = component.remiProductInfoOrientation();
expect(typeof orientation).toBe('string');
expect(['horizontal', 'vertical']).toContain(orientation);
});
});
describe('displayImpediment', () => {
it('should return truthy when item has impediment', () => {
const mockItem = createMockReturnItem({
impediment: {
comment: 'Test impediment',
attempts: 2,
},
});
fixture.componentRef.setInput('item', mockItem);
fixture.componentRef.setInput('stock', createMockStockInfo());
fixture.detectChanges();
expect(component.displayImpediment()).toBeTruthy();
});
it('should return truthy when item is descendant of enabled impediment', () => {
const mockItem = createMockReturnItem({
descendantOf: {
enabled: true,
},
});
fixture.componentRef.setInput('item', mockItem);
fixture.componentRef.setInput('stock', createMockStockInfo());
fixture.detectChanges();
expect(component.displayImpediment()).toBeTruthy();
});
it('should return falsy when item has no impediment and is not descendant of enabled impediment', () => {
const mockItem = createMockReturnItem({
impediment: undefined,
descendantOf: {
enabled: false,
},
});
fixture.componentRef.setInput('item', mockItem);
fixture.componentRef.setInput('stock', createMockStockInfo());
fixture.detectChanges();
expect(component.displayImpediment()).toBeFalsy();
});
it('should return falsy when item has no impediment and no descendantOf property', () => {
const mockItem = createMockReturnItem({
impediment: undefined,
});
fixture.componentRef.setInput('item', mockItem);
fixture.componentRef.setInput('stock', createMockStockInfo());
fixture.detectChanges();
expect(component.displayImpediment()).toBeFalsy();
});
});
describe('impediment', () => {
it('should return impediment comment when available', () => {
const mockItem = createMockReturnItem({
impediment: {
comment: 'Custom impediment message',
attempts: 3,
},
});
fixture.componentRef.setInput('item', mockItem);
fixture.componentRef.setInput('stock', createMockStockInfo());
fixture.detectChanges();
expect(component.impediment()).toBe('Custom impediment message (3)');
});
it('should return default "Restmenge" when no comment provided', () => {
const mockItem = createMockReturnItem({
impediment: {
attempts: 2,
},
});
fixture.componentRef.setInput('item', mockItem);
fixture.componentRef.setInput('stock', createMockStockInfo());
fixture.detectChanges();
expect(component.impediment()).toBe('Restmenge (2)');
});
it('should return only comment when no attempts provided', () => {
const mockItem = createMockReturnItem({
impediment: {
comment: 'Custom message',
},
});
fixture.componentRef.setInput('item', mockItem);
fixture.componentRef.setInput('stock', createMockStockInfo());
fixture.detectChanges();
expect(component.impediment()).toBe('Custom message');
});
it('should return default "Restmenge" when impediment is empty object', () => {
const mockItem = createMockReturnItem({
impediment: {},
});
fixture.componentRef.setInput('item', mockItem);
fixture.componentRef.setInput('stock', createMockStockInfo());
fixture.detectChanges();
expect(component.impediment()).toBe('Restmenge');
});
it('should return "Restmenge" when impediment is undefined', () => {
const mockItem = createMockReturnItem({
impediment: undefined,
});
fixture.componentRef.setInput('item', mockItem);
fixture.componentRef.setInput('stock', createMockStockInfo());
fixture.detectChanges();
expect(component.impediment()).toBe('Restmenge');
});
});
});

View File

@@ -4,7 +4,7 @@ import {
computed,
inject,
input,
model,
output,
} from '@angular/core';
import { FormsModule } from '@angular/forms';
import {
@@ -15,18 +15,19 @@ import {
ReturnItem,
ReturnSuggestion,
StockInfo,
UpdateItem,
} from '@isa/remission/data-access';
import {
ProductInfoComponent,
ProductShelfMetaInfoComponent,
ProductStockInfoComponent,
} from '@isa/remission/shared/product';
import { TextButtonComponent } from '@isa/ui/buttons';
import { ClientRowImports, ItemRowDataImports } from '@isa/ui/item-rows';
import { Breakpoint, breakpoint } from '@isa/ui/layout';
import { injectRemissionListType } from '../injects/inject-remission-list-type';
import { RemissionListItemSelectComponent } from './remission-list-item-select.component';
import { RemissionListItemActionsComponent } from './remission-list-item-actions.component';
import { LabelComponent, Labeltype } from '@isa/ui/label';
/**
* Component representing a single item in the remission list.
@@ -52,14 +53,20 @@ import { RemissionListItemActionsComponent } from './remission-list-item-actions
ProductInfoComponent,
ProductStockInfoComponent,
ProductShelfMetaInfoComponent,
TextButtonComponent,
ClientRowImports,
ItemRowDataImports,
RemissionListItemSelectComponent,
RemissionListItemActionsComponent,
LabelComponent,
],
})
export class RemissionListItemComponent {
/**
* Type of label to display for the item.
* Defaults to 'tag', can be changed to 'notice' or other types as needed.
*/
Labeltype = Labeltype;
/**
* Store for managing selected remission quantities.
* @private
@@ -89,12 +96,18 @@ export class RemissionListItemComponent {
stock = input.required<StockInfo>();
/**
* ModelSignal indicating whether remission items are currently being processed.
* Used to prevent multiple submissions or actions.
* InputSignal indicating whether the stock information is currently being fetched.
* Used to show loading states in the UI.
* @default false
*
*/
deleteRemissionListItemInProgress = model<boolean>();
stockFetching = input<boolean>(false);
/**
* Output event emitter for when the item is deleted or updated.
* Emits an object containing the in-progress state and the item itself.
*/
removeOrUpdateItem = output<UpdateItem>();
/**
* Optional product group value for display or filtering.
@@ -118,9 +131,12 @@ export class RemissionListItemComponent {
/**
* Computes whether the item has stock to remit.
* Returns true if stockToRemit is greater than 0.
* Returns true if stockToRemit and availableStock are greater than 0.
* #5269 Added availableStock check
*/
hasStockToRemit = computed(() => this.stockToRemit() > 0);
hasStockToRemit = computed(
() => this.availableStock() > 0 && this.stockToRemit() > 0,
);
/**
* Computes the available stock for the item using stock and removedFromStock.
@@ -141,6 +157,16 @@ export class RemissionListItemComponent {
() => this.#store.selectedQuantity()?.[this.item().id!],
);
/**
* Computes whether the selected quantity equals the stock to remit.
* This is used to determine if the remission quantity can be changed.
*/
selectedQuantityDiffersFromStockToRemit = computed(
() =>
this.selectedStockToRemit() !== undefined &&
this.selectedStockToRemit() !== this.stockToRemit(),
);
/**
* Computes the stock to remit based on the remission item and available stock.
* Uses the getStockToRemit helper function.
@@ -155,13 +181,42 @@ export class RemissionListItemComponent {
/**
* Computes the target stock after remission.
* @returns The calculated target stock.
* Uses the calculateTargetStock helper function.
* Takes into account the selected quantity and remaining quantity in stock.
*/
targetStock = computed(() =>
calculateTargetStock({
targetStock = computed(() => {
if (this.selectedQuantityDiffersFromStockToRemit()) {
return calculateTargetStock({
availableStock: this.availableStock(),
stockToRemit: this.selectedStockToRemit(),
});
}
return calculateTargetStock({
availableStock: this.availableStock(),
stockToRemit: this.stockToRemit(),
remainingQuantityInStock: this.remainingQuantityInStock(),
}),
});
});
/**
* Computes whether to display the impediment for the item.
* Displays if the item is a descendant of an enabled impediment or if it has its own impediment.
*/
displayImpediment = computed(
() =>
(this.item() as ReturnItem)?.descendantOf?.enabled ||
this.item()?.impediment,
);
/**
* Computes the impediment comment and attempts for display.
* If no impediment comment is provided, defaults to 'Restmenge'.
* Appends the number of attempts if available.
*/
impediment = computed(() => {
const comment = this.item()?.impediment?.comment ?? 'Restmenge';
const attempts = this.item()?.impediment?.attempts;
return `${comment}${attempts ? ` (${attempts})` : ''}`;
});
}

View File

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

View File

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

View File

@@ -1,4 +1,5 @@
<remi-remission-processed-hint></remi-remission-processed-hint>
<!-- TODO: #5136 - Code innerhalb remi-remission-processed-hint anpassen sobald Ticket #5215 umgesetzt ist -->
<!-- <remi-remission-processed-hint></remi-remission-processed-hint> -->
@if (!remissionStarted()) {
<remi-feature-remission-start-card></remi-feature-remission-start-card>
@@ -22,17 +23,16 @@
{{ hits() }} Einträge
</span>
<div class="flex flex-col gap-4 w-full items-center justify-center mb-24">
<div class="flex flex-col gap-4 w-full items-center justify-center mb-36">
@for (item of items(); track item.id) {
@defer (on viewport) {
<remi-feature-remission-list-item
#listElement
[item]="item"
[stock]="getStockForItem(item)"
[stockFetching]="inStockFetching()"
[productGroupValue]="getProductGroupValueForItem(item)"
(deleteRemissionListItemInProgressChange)="
onDeleteRemissionListItem($event)
"
(removeOrUpdateItem)="onRemoveOrUpdateItem($event)"
></remi-feature-remission-list-item>
} @placeholder {
<div class="h-[7.75rem] w-full flex items-center justify-center">
@@ -45,11 +45,23 @@
</div>
}
}
<remi-feature-remission-list-empty-state
[listFetching]="listFetching()"
[isDepartment]="isDepartment()"
[isReloadSearch]="searchTrigger() === 'reload'"
[hasValidSearchTerm]="hasValidSearchTerm()"
[hits]="hits()"
></remi-feature-remission-list-empty-state>
</div>
<utils-scroll-top-button
class="flex flex-col self-end fixed bottom-6 mr-6"
[class.scroll-top-button-spacing-bottom]="remissionStarted()"
></utils-scroll-top-button>
@if (remissionStarted()) {
<ui-stateful-button
class="fixed right-6 bottom-6"
class="flex flex-col self-end fixed bottom-6 mr-6"
(clicked)="remitItems()"
(action)="remitItems()"
[(state)]="remitItemsState"
@@ -63,7 +75,7 @@
size="large"
color="brand"
[pending]="remitItemsInProgress()"
[disabled]="!hasSelectedItems() || deleteRemissionListItemInProgress()"
[disabled]="!hasSelectedItems() || removeItemInProgress()"
>
</ui-stateful-button>
}

View File

@@ -0,0 +1,3 @@
.scroll-top-button-spacing-bottom {
@apply bottom-[5.5rem];
}

View File

@@ -6,8 +6,9 @@ import {
effect,
untracked,
signal,
linkedSignal,
} from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { ActivatedRoute, Router } from '@angular/router';
import {
provideFilter,
withQuerySettingsFactory,
@@ -16,7 +17,10 @@ import {
FilterService,
SearchTrigger,
} from '@isa/shared/filter';
import { injectRestoreScrollPosition } from '@isa/utils/scroll-position';
import {
injectRestoreScrollPosition,
ScrollTopButtonComponent,
} from '@isa/utils/scroll-position';
import { RemissionStartCardComponent } from './remission-start-card/remission-start-card.component';
import { RemissionListSelectComponent } from './remission-list-select/remission-list-select.component';
import {
@@ -40,14 +44,20 @@ import {
calculateAvailableStock,
RemissionReturnReceiptService,
getStockToRemit,
RemissionListType,
RemissionResponseArgsErrorMessage,
UpdateItem,
orderByListItems,
} from '@isa/remission/data-access';
import { injectDialog } from '@isa/ui/dialog';
import { injectDialog, injectFeedbackErrorDialog } from '@isa/ui/dialog';
import { SearchItemToRemitDialogComponent } from '@isa/remission/shared/search-item-to-remit-dialog';
import { RemissionListType } from '@isa/remission/data-access';
import { RemissionReturnCardComponent } from './remission-return-card/remission-return-card.component';
import { logger } from '@isa/core/logging';
import { RemissionProcessedHintComponent } from './remission-processed-hint/remission-processed-hint.component';
import { RemissionListDepartmentElementsComponent } from './remission-list-department-elements/remission-list-department-elements.component';
import { injectTabId } from '@isa/core/tabs';
import { RemissionListEmptyStateComponent } from './remission-list-empty-state/remission-list-empty-state.component';
import { firstValueFrom } from 'rxjs';
function querySettingsFactory() {
return inject(ActivatedRoute).snapshot.data['querySettings'];
@@ -89,6 +99,8 @@ function querySettingsFactory() {
StatefulButtonComponent,
RemissionListDepartmentElementsComponent,
RemissionProcessedHintComponent,
RemissionListEmptyStateComponent,
ScrollTopButtonComponent,
],
host: {
'[class]':
@@ -103,7 +115,19 @@ export class RemissionListComponent {
*/
route = inject(ActivatedRoute);
/**
* Router instance for navigation.
*/
router = inject(Router);
/**
* Injects the current activated tab ID as a signal.
* This is used to determine if the current remission matches the active tab.
*/
activatedTabId = injectTabId();
searchItemToRemitDialog = injectDialog(SearchItemToRemitDialogComponent);
errorDialog = injectFeedbackErrorDialog();
/**
* FilterService instance for managing filter state and queries.
@@ -150,12 +174,28 @@ export class RemissionListComponent {
});
/**
* Signal indicating whether remission items are currently being processed.
* Used to prevent multiple submissions or actions.
* @default false
*
* Signal indicating whether a remission list item deletion is in progress.
* Used to disable actions while deletion is happening.
*/
deleteRemissionListItemInProgress = signal(false);
removeItemInProgress = signal(false);
/**
* Computed signal for the current search term from the filter service.
* @returns The current search term string or undefined if not set.
*/
searchTerm = computed<string | undefined>(() => {
return this.#filterService.query()?.input['qs'] ?? '';
});
/**
* Computed signal indicating whether there is a valid search term.
* A valid search term is defined as a non-empty string.
* @returns True if there is a valid search term, false otherwise.
*/
hasValidSearchTerm = computed(() => {
const searchTerm = this.searchTerm();
return !!searchTerm && searchTerm.length > 0;
});
/**
* Resource signal for fetching the remission list based on current filters.
@@ -198,12 +238,24 @@ export class RemissionListComponent {
*/
listResponseValue = computed(() => this.remissionResource.value());
/**
* Computed signal indicating whether the remission list resource is currently fetching data.
* @returns True if fetching, false otherwise.
*/
listFetching = computed(() => this.remissionResource.status() === 'loading');
/**
* Computed signal for the current in-stock response.
* @returns Array of StockInfo or undefined.
*/
inStockResponseValue = computed(() => this.inStockResource.value());
/**
* Computed signal indicating whether the in-stock resource is currently fetching data.
* @returns True if fetching, false otherwise.
*/
inStockFetching = computed(() => this.inStockResource.status() === 'loading');
/**
* Computed signal for the product group response.
* @returns Array of KeyValueStringAndString or undefined.
@@ -214,7 +266,7 @@ export class RemissionListComponent {
* Computed signal for the remission items to display.
* @returns Array of ReturnItem or ReturnSuggestion.
*/
items = computed(() => {
items = linkedSignal(() => {
const value = this.listResponseValue();
return value?.result ? value.result : [];
});
@@ -319,15 +371,29 @@ export class RemissionListComponent {
}
/**
* Handles the deletion of a remission list item.
* Updates the in-progress state and reloads the list and receipt upon completion.
*
* @param inProgress - Whether the deletion is currently in progress
* Handles the removal or update of an item from the remission list.
* Updates the local items signal and the remission store accordingly.
* Items with impediments are automatically moved to the end of the list and sorted by attempt count.
* @param param0 - Object containing inProgress state, itemId, and optional impediment.
*/
onDeleteRemissionListItem(inProgress: boolean) {
this.deleteRemissionListItemInProgress.set(inProgress);
if (!inProgress) {
this.reloadListAndReceipt();
onRemoveOrUpdateItem({ inProgress, itemId, impediment }: UpdateItem) {
this.removeItemInProgress.set(inProgress);
if (!inProgress && itemId) {
if (!impediment || (impediment.attempts && impediment.attempts >= 4)) {
this.items.set(this.items().filter((item) => item.id !== itemId)); // Filter Item if no impediment or attempts >= 4 (#5361)
} else {
// Update Item
this.items.update((items) => {
const updatedItems = items.map((item) =>
item.id === itemId ? { ...item, impediment } : item,
);
orderByListItems(updatedItems);
return updatedItems;
});
}
// Always Unselect Item
this.#store.removeItem(itemId);
}
}
@@ -348,30 +414,53 @@ export class RemissionListComponent {
);
});
/**
* Effect that handles scenarios where a search yields no results.
* If the search was user-initiated and returned no hits, it opens a dialog
* to allow the user to add a new item to remit.
* If only one hit is found and a remission is started, it selects that item automatically.
* This effect runs whenever the remission or stock resource status changes,
* or when the search term changes.
* It ensures that the user is prompted appropriately based on their actions and the current state of the remission process.
* It also checks if the remission is started or if the list type is 'Abteilung' to determine navigation behavior.
* @see {@link
* https://angular.dev/guide/effects} for more information on Angular effects.
* @remarks This effect uses `untracked` to avoid unnecessary re-evaluations
* when accessing certain signals.
*/
emptySearchResultEffect = effect(() => {
const status = this.remissionResource.status();
const stockStatus = this.inStockResource.status();
const searchTerm: string | undefined = this.searchTerm();
if (status !== 'resolved') {
return;
}
const hasItems = !!this.remissionResource.value()?.result?.length;
if (hasItems) {
if (status !== 'resolved' || stockStatus !== 'resolved') {
return;
}
untracked(() => {
if (!this.searchTriggeredByUser()) {
const hits = this.hits();
// #5338 - Select item automatically if only one hit after search
if (
!!hits ||
!searchTerm ||
!this.hasValidSearchTerm() ||
!this.searchTriggeredByUser()
) {
if (hits === 1 && this.remissionStarted()) {
this.#store.clearSelectedItems();
this.preselectRemissionItem(this.items()[0]);
}
return;
}
this.searchItemToRemitDialog({
data: {
searchTerm: this.#filterService.query()?.input['qs'] || '',
isDepartment: this.isDepartment(),
searchTerm,
},
}).closed.subscribe(async (result) => {
this.#store.clearSelectedItems();
if (result) {
if (this.remissionStarted()) {
for (const item of result) {
@@ -379,24 +468,36 @@ export class RemissionListComponent {
this.#store.selectRemissionItem(item.id, item);
}
}
await this.remitItems();
await this.remitItems({ addItemFlow: true });
} else if (this.isDepartment()) {
return await this.navigateToDefaultRemissionList();
}
this.reloadListAndReceipt();
this.searchTrigger.set('reload');
}
this.reloadListAndReturnData();
});
});
});
// TODO: Improvement - In Separate Komponente zusammen mit Remi-Button Auslagern
async remitItems() {
/**
* Initiates the process to remit selected items.
* If remission is already started, items are added directly to the remission.
* If not, navigates to the default remission list.
* @param options - Options for remitting items, including whether it's part of an add-item flow.
* @returns A promise that resolves when the operation is complete.
*/
async remitItems(options: { addItemFlow: boolean } = { addItemFlow: false }) {
if (this.remitItemsInProgress()) {
return;
}
this.remitItemsInProgress.set(true);
try {
const remissionListType = this.selectedRemissionListType();
// #5273, #5280 Fix - Bei gestarteter Remission dürfen Items die über den AddItemDialog hinzugefügt und direkt remittiert werden, nur als ReturnItem (statt ReturnSuggestion) zum WBS hinzugefügt werden
const remissionListType = options.addItemFlow
? RemissionListType.Pflicht
: this.selectedRemissionListType();
const selected = this.#store.selectedItems();
const quantities = this.#store.selectedQuantity();
@@ -406,13 +507,12 @@ export class RemissionListComponent {
const remissionItemIdNumber = Number(remissionItemId);
const quantity = quantities[remissionItemIdNumber];
const inStock = this.getAvailableStockForItem(item);
const stockToRemit =
quantity ??
getStockToRemit({
remissionItem: item,
remissionListType,
availableStock: inStock,
});
const stockToRemit = getStockToRemit({
remissionItem: item,
remissionListType,
availableStock: inStock,
});
const quantityToRemit = quantity ?? stockToRemit;
if (returnId && receiptId) {
await this.#remissionReturnReceiptService.remitItem({
@@ -420,24 +520,22 @@ export class RemissionListComponent {
addItem: {
returnId,
receiptId,
quantity: stockToRemit,
quantity: quantityToRemit,
inStock,
impedimentComment: stockToRemit > quantity ? 'Restmenge' : '',
remainingQuantity:
isNaN(quantity) || inStock - quantity <= 0
? undefined
: inStock - quantity,
},
type: this.selectedRemissionListType(),
type: remissionListType,
});
}
}
this.remitItemsState.set('success');
this.reloadListAndReceipt();
this.reloadListAndReturnData();
} catch (error) {
this.#logger.error('Failed to remit items', error);
this.remitItemsError.set(
error instanceof Error
? error.message
: 'Artikel konnten nicht remittiert werden',
);
this.remitItemsState.set('error');
await this.handleRemitItemsError(error);
}
this.#store.clearSelectedItems();
@@ -445,11 +543,77 @@ export class RemissionListComponent {
}
/**
* Reloads the remission list and receipt data.
* Reloads the remission list and return data.
* This method is used to refresh the displayed data after changes.
*/
reloadListAndReceipt() {
reloadListAndReturnData() {
this.searchTrigger.set('reload');
this.remissionResource.reload();
this.#store.reloadReceipt();
this.#store.reloadReturn();
}
/**
* Pre-Selects a remission item if it has available stock and can be remitted.
* Updates the remission store with the selected item.
* @param item - The ReturnItem or ReturnSuggestion to select.
* @returns void
*/
preselectRemissionItem(item: RemissionItem) {
if (!!item && item.id) {
const inStock = this.getAvailableStockForItem(item);
const stockToRemit = getStockToRemit({
remissionItem: item,
remissionListType: this.selectedRemissionListType(),
availableStock: inStock,
});
if (inStock > 0 && stockToRemit > 0) {
this.#store.selectRemissionItem(item.id, item);
}
}
}
/**
* Handles errors that occur during the remission of items.
* Logs the error, displays an error dialog, and reloads the list and return data.
* If the error indicates that the remission is already completed, it clears the remission state.
* Sets the stateful button to 'error' to indicate the failure.
* @param error - The error object caught during the remission process.
* @returns A promise that resolves when the error handling is complete.
*/
async handleRemitItemsError(error: any) {
this.#logger.error('Failed to remit items', error);
const errorMessage =
error?.error?.message ??
error?.message ??
'Artikel konnten nicht remittiert werden';
this.remitItemsError.set(errorMessage);
await firstValueFrom(
this.errorDialog({
data: {
errorMessage,
},
}).closed,
);
if (errorMessage === RemissionResponseArgsErrorMessage.AlreadyCompleted) {
this.#store.clearState();
}
this.reloadListAndReturnData();
this.remitItemsState.set('error'); // Stateful-Button auf Error setzen
}
/**
* Navigates to the default remission list based on the current activated tab ID.
* This method is used to redirect the user to the remission list after completing or starting a remission.
* @returns {Promise<void>} A promise that resolves when navigation is complete
*/
async navigateToDefaultRemissionList() {
await this.router.navigate(['/', this.activatedTabId(), 'remission']);
}
}

View File

@@ -11,6 +11,8 @@ import { RemissionReturnReceiptService } from '@isa/remission/data-access';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { subDays } from 'date-fns';
// TODO: #5136 - Code anpassen sobald Ticket #5215 umgesetzt ist
// HTML in remission-list.component.html ist auskommentiert
@Component({
selector: 'remi-remission-processed-hint',
templateUrl: './remission-processed-hint.component.html',

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
import { inject, resource } from '@angular/core';
import { ListResponseArgs, ResponseArgsError } from '@isa/common/data-access';
import {
orderByListItems,
QueryTokenInput,
RemissionItem,
RemissionListType,
@@ -9,7 +10,6 @@ import {
RemissionSupplierService,
} from '@isa/remission/data-access';
import { SearchTrigger } from '@isa/shared/filter';
import { parseISO, compareDesc } from 'date-fns';
import { isEan } from '@isa/utils/ean-validation';
/**
@@ -73,18 +73,21 @@ export const createRemissionListResource = (
let res: ListResponseArgs<RemissionItem> | undefined = undefined;
const queryToken = { ...params.queryToken };
const exactSearch = isExactSearch(queryToken, params.searchTrigger);
const isReload = params.searchTrigger === 'reload';
// #5128 #5234 Bei Exact Search soll er über Alle Listen nur mit dem Input ohne aktive Filter / orderBy suchen
const isExactSearch =
params.searchTrigger === 'scan' || isEan(queryToken?.input?.['qs']);
// #5273
if (isReload) {
queryToken.input = {};
}
if (isExactSearch) {
if (exactSearch) {
queryToken.filter = {};
queryToken.orderBy = [];
}
if (
isExactSearch ||
exactSearch ||
params.remissionListType === RemissionListType.Pflicht
) {
const fetchListResponse = await remissionSearchService.fetchList(
@@ -99,8 +102,8 @@ export const createRemissionListResource = (
}
if (
isExactSearch ||
params.remissionListType === RemissionListType.Abteilung
exactSearch ||
canFetchDepartmentList(queryToken, params.remissionListType)
) {
const fetchDepartmentListResponse =
await remissionSearchService.fetchDepartmentList(
@@ -117,9 +120,17 @@ export const createRemissionListResource = (
...(res.result || []),
...(fetchDepartmentListResponse.result || []),
];
res.hits += fetchDepartmentListResponse.hits;
res.skip += fetchDepartmentListResponse.skip;
res.take += fetchDepartmentListResponse.take;
if (fetchDepartmentListResponse?.hits) {
res.hits += fetchDepartmentListResponse.hits;
}
if (fetchDepartmentListResponse?.skip) {
res.skip += fetchDepartmentListResponse?.skip;
}
if (fetchDepartmentListResponse?.take) {
res.take += fetchDepartmentListResponse?.take;
}
} else {
res = fetchDepartmentListResponse;
}
@@ -129,36 +140,54 @@ export const createRemissionListResource = (
throw new ResponseArgsError(res);
}
// Sort items: manually-added items first, then by created date (latest first)
if (res && res.result && Array.isArray(res.result)) {
res.result.sort((a, b) => {
const aIsManuallyAdded = a.source === 'manually-added';
const bIsManuallyAdded = b.source === 'manually-added';
// #5276 Fix - Replace defaultSort Mechanism with orderBy from QueryToken if available
const hasOrderBy = !!queryToken?.orderBy && queryToken.orderBy.length > 0;
// First priority: manually-added items come first
if (aIsManuallyAdded && !bIsManuallyAdded) {
return -1;
}
if (!aIsManuallyAdded && bIsManuallyAdded) {
return 1;
}
// Second priority: sort by created date (latest first)
if (a.created && b.created) {
const dateA = parseISO(a.created);
const dateB = parseISO(b.created);
return compareDesc(dateA, dateB); // Descending order (latest first)
}
// Handle cases where created date might be missing
if (a.created && !b.created) return -1;
if (!a.created && b.created) return 1;
return 0;
});
if (!hasOrderBy && res && res.result && Array.isArray(res.result)) {
orderByListItems(res.result);
}
return res;
},
});
};
// #5128 #5234 Bei Exact Search soll er über Alle Listen nur mit dem Input ohne aktive Filter / orderBy suchen
/**
* Checks if the query token is an exact search based on the search trigger.
* An exact search is defined as:
* - Triggered by 'scan'
* - Or if the query token input contains a valid EAN (barcode) in 'qs'
* @param {QueryTokenInput} queryToken - The query token containing input parameters
* @param {SearchTrigger} searchTrigger - The trigger that initiated the search
* @returns {boolean} True if the search is exact, false otherwise
*/
const isExactSearch = (
queryToken: QueryTokenInput,
searchTrigger: SearchTrigger | 'reload' | 'initial',
): boolean => {
return searchTrigger === 'scan' || isEan(queryToken?.input?.['qs']);
};
// #5255 Performance optimization for initial department list fetch
/**
* Checks if the query token allows fetching the department list.
* This is true if the remission list type is 'Abteilung' and either:
* - There is a search input (queryToken.input['qs'])
* - There is an active filter for 'abteilungen'
*
* @param {QueryTokenInput} queryToken - The query token containing input and filter
* @param {RemissionListType} remissionListType - The type of remission list being queried
* @returns {boolean} True if the department list can be fetched, false otherwise
*/
const canFetchDepartmentList = (
queryToken: QueryTokenInput,
remissionListType: RemissionListType,
): boolean => {
const hasInput = queryToken?.input?.['qs'];
const hasAbteilungFilter = queryToken?.filter?.['abteilungen'];
return (
remissionListType === RemissionListType.Abteilung &&
(hasInput || hasAbteilungFilter)
);
};

View File

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

View File

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

View File

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

View File

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

View File

@@ -9,8 +9,8 @@ import {
} from '@angular/core';
import {
ReceiptItem,
RemissionResponseArgsErrorMessage,
RemissionReturnReceiptService,
ReturnItem,
} from '@isa/remission/data-access';
import { ProductFormatComponent } from '@isa/shared/product-foramt';
import { ProductImageDirective } from '@isa/shared/product-image';
@@ -21,6 +21,8 @@ import { IconButtonComponent } from '@isa/ui/buttons';
import { provideIcons } from '@ng-icons/core';
import { isaActionClose } from '@isa/icons';
import { logger } from '@isa/core/logging';
import { injectFeedbackErrorDialog } from '@isa/ui/dialog';
import { firstValueFrom } from 'rxjs';
/**
* Component for displaying a single receipt item within the remission return receipt details.
@@ -56,6 +58,8 @@ export class RemissionReturnReceiptDetailsItemComponent {
}));
#returnReceiptService = inject(RemissionReturnReceiptService);
errorDialog = injectFeedbackErrorDialog();
/**
* Required input for the receipt item to display.
* Contains product information and quantity details.
@@ -86,7 +90,7 @@ export class RemissionReturnReceiptDetailsItemComponent {
removing = signal(false);
removed = output<ReceiptItem>();
reloadReturn = output<void>();
async remove() {
if (this.removing()) {
@@ -99,10 +103,25 @@ export class RemissionReturnReceiptDetailsItemComponent {
returnId: this.returnId(),
receiptItemId: this.item().id,
});
this.removed.emit(this.item());
} catch (error) {
this.#logger.error('Failed to remove item', error);
await this.handleRemoveItemError(error);
}
this.reloadReturn.emit();
this.removing.set(false);
}
async handleRemoveItemError(error: any) {
this.#logger.error('Failed to remove item', error);
const errorMessage =
error?.error?.message ?? RemissionResponseArgsErrorMessage.AlreadyRemoved;
await firstValueFrom(
this.errorDialog({
data: {
errorMessage,
},
}).closed,
);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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