Compare commits

...

131 Commits

Author SHA1 Message Date
Lorenz Hilpert
5bddaa83d5 fix(tabs): correct singleton tabs interaction with new tab areas
- Fix tab name persistence when switching from Sortiment/Wareneingang to Retoure/Remission
- Prevent duplicate tab creation when clicking on new areas
- Ensure correct tab naming for Retoure and Remission areas
- Update side menu navigation to use consistent tab ID generation
- Add processResolverFn for backwards compatibility
- Simplify process bar navigation logic

Refs: #5345
2025-09-17 20:07:33 +02:00
Nino
dd9c60e0c0 Merge branch 'feature/5202-Praemie' into feature/5262-Praemie-Customer-Selection-Styling 2025-09-17 16:42:19 +02:00
Nino
0d58a5288e Merge branch 'develop' into feature/5202-Praemie 2025-09-17 16:41:42 +02:00
Lorenz Hilpert
384952413b Merged PR 1943: Implementation - Backwards Compatibility Process -> Tabs
Related work items: #5328
2025-09-17 13:56:35 +00:00
Nino
09677e8df8 feat(reward-customer-card): improve styling and user experience
Replace basic HTML elements with proper UI components and apply
consistent design system styling. Update layout spacing and add
proper typography classes for better visual hierarchy.

Changes:
- Replace button with TextButtonComponent for consistent styling
- Add proper typography classes (isa-text-body-1-regular/bold)
- Improve layout spacing and alignment
- Add text color for better contrast
- Add null safety operators for card properties

Ref: #5262
2025-09-17 15:09:51 +02:00
Nino
e6dc08007b Merge branch 'develop' of https://dev.azure.com/hugendubel/ISA/_git/ISA-Frontend into develop 2025-09-17 12:23:59 +02:00
Nino
54aa18a3a3 chore(Dockerfile): Changed npm install to npm ci 2025-09-17 12:18:58 +02:00
Nino
d70c95743c feature(reward-header): Added Loading to GET Bonuscard 2025-09-17 12:10:25 +02:00
Nino
9c8c42da69 chore(swagger-oms, swagger-checkout): Update Swagger and resolved breaking changes 2025-09-17 12:09:51 +02:00
Lorenz Hilpert
afc6351509 chore: disable markdown format on save in VSCode settings
Added formatOnSave: false for markdown files to prevent automatic formatting that may conflict with document structure preferences.
2025-09-17 11:50:03 +02:00
Nino
2fc83cd8f7 feat(reward-crm, crm-data-acces): implement customer card display and selection system
Add comprehensive customer card functionality to the reward catalog:
- Create new CRM data access layer with customer cards facade and services
- Implement customer card resource for fetching bonus card information
- Add reward customer card component to display customer details and points
- Update reward header to conditionally show customer card or start card
- Fix customer selection persistence by moving facade call outside conditional
- Add proper type definitions and models for bonus card information
- Export new facades and models through updated index files

The system now properly handles customer selection state across tabs
and displays relevant customer information including name and total points
when a customer with an active primary bonus card is selected.

Ref: #5262
2025-09-16 18:37:38 +02:00
Nino
9f775e01e2 Merge branch 'develop' into feature/5258-Praemie-Landing 2025-09-16 12:00:50 +02:00
Lorenz Hilpert
c5d057e3a7 Merged PR 1950: 5343-Filter-NumberRange
Related work items: #5343
2025-09-16 09:54:29 +00:00
Nino
e5c09c030c feat(reward): implement reward catalog with customer selection
Add comprehensive reward catalog functionality including:
- New reward catalog component with header switching between start card and customer card
- Customer selection integration with tab metadata service
- Reward checkout service with query settings fetching
- Customer search integration for reward context with proper filtering
- Tab metadata support for storing selected customer IDs
- Navigation improvements for reward workflow in customer details

The implementation includes proper error handling, logging, and follows
the established architectural patterns with facades and services.

Ref: #5262
2025-09-15 17:42:57 +02:00
Nino
0269473a18 feat(common/data-access): enhance error handling with ResponseArgsError operator
Add catchResponseArgsErrorPipe operator to transform HTTP errors into structured
ResponseArgsError instances. This provides consistent error handling across
data access services by automatically converting HttpErrorResponse objects
with ResponseArgs payloads into typed ResponseArgsError instances.

Changes include:
- New catchResponseArgsErrorPipe operator for standardized error transformation
- Enhanced DataAccessError with proper prototype chain setup
- Integration in CatalougeSearchService for loyalty items search
- Export operator from common data-access module

This improves error consistency and debugging capabilities across the application
by ensuring all API errors follow the same structure and typing.

Refs: #5258
2025-09-15 14:04:34 +02:00
Nino Righi
707802ce0d Merged PR 1944: feat(checkout-reward): #5258
- feat(loyalty): add loyalty program feature with list and navigation
- fix(isa-app-side-menu): Update customer expand to signals
- feat(catalogue-data-access): add searchLoyaltyItems method with comprehensive test coverage
- feat(project-structure): migrate loyalty system to reward-based architecture
- feat(checkout-reward): add query settings resolver and catalog resource
- feat(swagger-cat-search-api): Swagger Update
- feat(checkout-reward): update API call and prepare filter integration

Refs: #5258
2025-09-12 10:44:42 +00:00
Lorenz Hilpert
e00de7598d feat(crm): add crm-data-access library with initial component and tests
- Introduced the crm-data-access library with a basic component.
- Added necessary configuration files, including ESLint and TypeScript settings.
- Implemented unit tests for the component to ensure functionality.

Refs: #5254
2025-09-11 20:13:56 +02:00
Lorenz Hilpert
516b7748c2 chore: update .gitignore and package-lock.json to include new files 2025-09-09 11:19:04 +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
d2dcf638e3 Merge tag '4.0-hotfix-release' 2025-08-14 16:47:48 +02:00
Nino
a4241cbd7a Merge branch 'release/4.0' 2025-08-14 16:40:46 +02:00
Nino Righi
dd3705f8bc Merged PR 1920: feat(remission-list): add navigation to default list and clear input on reload
feat(remission-list): add navigation to default list and clear input on reload

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Enhance product stock info component with proper loading states.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Ref: #5128
2025-08-01 13:22:41 +00:00
Nino Righi
d7d535c10d Merged PR 1903: fix(remission-list, product-info, search-item-to-remit): improve responsive l...
fix(remission-list, product-info, search-item-to-remit): improve responsive layout and fix orientation logic

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

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

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

Ref: #5231
2025-07-31 16:42:37 +00:00
Nino Righi
0addf392b6 Merged PR 1901: hotfix(return-summary): disable navigation during return processing
hotfix(return-summary): disable navigation during return processing

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

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

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

Ref: #5257
2025-07-31 16:41:59 +00:00
Michael Auer
1e84223076 ~ azure-pipelines.yml: DockerTagSourceBranch _ ==> - 2025-07-30 17:46:32 +02:00
Nino Righi
244984b6cf Merged PR 1900: feat(remission): add getStockToRemit helper and improve stock calculation logic
feat(remission): add getStockToRemit helper and improve stock calculation logic

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Refs: #5138
2025-07-17 13:46:32 +00:00
Nino Righi
65ab3bfc0a Merged PR 1884: #5213
- feat(dialog-feedback-dialog, remission-list-item): add feedback dialog and remission list item components
- feat(remission-list-item): implement remission list item component
Refs: #5213
2025-07-15 11:26:03 +00:00
Lorenz Hilpert
40c9d51dfc Merged PR 1881: Stateful Remi Button
#5203

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

Refs: #4769, #5196
2025-07-11 19:53:56 +00:00
Lorenz Hilpert
a36d746fb8 Merged PR 1879: Warenbegleitschein Übersicht und Details
Related work items: #5137, #5138
2025-07-10 16:00:16 +00:00
Nino
f6b2b554bb Merge branch 'release/4.0' into develop 2025-07-10 14:31:03 +02:00
Nino
465df27858 Merge branch 'release/4.0' into develop 2025-07-10 14:16:29 +02:00
Nino Righi
b7e7155577 Merged PR 1877: #4769, #5194 Remission List Item - StockInfos - ItemInfos
feat(remission-list, remission-shared-product-stock-info): implement product stock info display

Add product stock information to the remission list and shared product components.
This enhances user visibility into current stock levels directly within remission-related views,
improving workflow efficiency and reducing the need for context switching.

Ref: #4769, #5194
2025-07-02 14:23:04 +00:00
Lorenz Hilpert
b28c204f23 refactor(tabs): add metadata and navigation properties to Tab model 2025-07-01 12:00:12 +02:00
Lorenz Hilpert
e7a807cfbd refactor(tabs): enhance Tab interface with navigation and metadata 2025-07-01 11:46:30 +02:00
Lorenz Hilpert
344dc61a90 fix: resolve CSS cascade issue with UI components and Tailwind utilities
- Move UI component styles to @layer components in tailwind.scss
- Remove ui.scss and integrate imports directly into component layer
- Add SCSS files to Tailwind content config to prevent CSS class purging
- Update Angular project configuration to remove ui.scss references
- Ensure Tailwind utilities can override component styles properly

Refs: #5195
2025-06-30 23:01:58 +02:00
Lorenz Hilpert
8d063428fc Merge branch 'refactor/convert-buildable-libs-to-non-buildable' into develop
Resolved conflicts:
- .gitignore: Added .claude to ignored files
- nx.json: Kept HEAD version with extra eslint.config.js exclusion
- package.json: Merged dependencies, updated vitest to v3.1.1 for compatibility
- eslint config files: Fixed merge conflicts and accepted conversion from .mjs to .js
- Removed deleted files from refactor branch
- Regenerated package-lock.json with --legacy-peer-deps

Build and tests pass successfully.
2025-06-30 20:52:05 +02:00
Lorenz Hilpert
06b0c6264a chore: add .claude to .gitignore 2025-06-30 20:13:33 +02:00
Lorenz Hilpert
4fe633e973 chore: update package dependencies and remove unused shared imports in tsconfig 2025-06-30 20:13:19 +02:00
Lorenz Hilpert
2463a803ea Merged PR 1876: Fix Workspace Build Issues 2025-06-30 09:17:03 +00:00
Lorenz Hilpert
1663dcec73 test(search-item-to-remit-dialog): enhance unit tests for component behavior and signal integration 2025-06-30 11:00:00 +02:00
Lorenz Hilpert
827aa565c5 feat(tests): update test command to include tuiAutoExit and add unit tests for SearchItemToRemitDialogComponent 2025-06-27 17:34:13 +02:00
Lorenz Hilpert
39fc4ce1ce refactor(styles): update styles to use Tailwind CSS and clean up code 2025-06-27 16:45:47 +02:00
Lorenz Hilpert
4f4b072e25 refactor(sass): migrate @import to @use syntax
- Replace deprecated @import with modern @use in _components.scss
- Replace deprecated @import with modern @use in tailwind.scss
- Move @use statements before @tailwind directives per Sass requirements
- Eliminates all 5 Sass deprecation warnings from build
- Future-proofs codebase for Dart Sass 3.0.0
2025-06-27 16:42:49 +02:00
Lorenz Hilpert
9af4a72a76 fix: resolve build warnings and improve code quality
- Remove unused Angular component and pipe imports to eliminate TS-998113 warnings
- Fix Sass mixed declarations warnings by reordering CSS properties
- Remove empty ngOnInit method from preview component
- Clean up unused imports across customer search and OMS components
- Move animation/transition properties above nested rules in SCSS files

Reduces build warnings significantly and improves code maintainability.
2025-06-27 16:19:18 +02:00
Lorenz Hilpert
7a44101e90 refactor: convert buildable libraries to non-buildable and migrate eslint configs
- Convert eslint.config.mjs files to eslint.config.js format across workspace
- Remove build targets from remission libraries (data-access, feature/remission-list, helpers, shared)
- Remove build target from icons library
- Delete ng-package.json and tsconfig.lib.prod.json files from buildable libraries
- Update tsconfig.lib.json configurations to remove bundler moduleResolution
- Clean up build artifacts and simplify library configurations
- Libraries now compile on-demand during application build instead of pre-compilation
2025-06-27 15:44:34 +02:00
Lorenz Hilpert
5e6ee35d91 chore(eslint): remove eslint-plugin-prettier and update configurations 2025-06-26 22:48:56 +02:00
Lorenz Hilpert
15db63aa1a refactor(quantity-and-reason-item): improve stock fetching and caching logic
Enhance the fetchAssignedStock method to utilize memory storage for caching
assigned stock data. Update the resource loader to handle cached values and
set new stock data accordingly. Adjust the HTML button for better readability.
2025-06-26 22:34:03 +02:00
Lorenz Hilpert
998946157a chore: update dependencies and add vitest configuration
- Added @analogjs/vite-plugin-angular and @analogjs/vitest-angular to devDependencies.
- Updated @nx/vite to version 20.1.4.
- Added @vitest/coverage-v8 and @vitest/ui to devDependencies.
- Added jsdom to devDependencies.
- Added vite and vitest to devDependencies.
- Updated tsconfig.base.json to include new paths for shared libraries.
- Created vitest.workspace.ts for vitest configuration.

Refs: #5135
2025-06-26 22:09:21 +02:00
Nino Righi
11cfa4039f Merged PR 1875: feat(remission-list-item): Item View (Basic), Refs: #4769
feat(remission-list-item): Item View (Basic), Refs: #4769
2025-06-25 15:01:31 +00:00
Lorenz Hilpert
26fd5cb389 Merged PR 1874: Remi Add Item Dialog FLow
Related work items: #5135
2025-06-25 13:45:25 +00:00
Nino Righi
f34f2164fc Merged PR 1873: #4769 Remission List
- feat(remission-list): Zwischencommit
- feat(ui-input-controls): Adjusted Dropdown Styling and Added Droption Option Disable Class, Refs: #4769
- feat(remission): implement remission list feature shell and category select
- Merge branch 'develop' into feature/4769-Remission-Liste
- Merge branch 'develop' into feature/4769-Remission-Liste
- feat(remission-list, remission-data-access): implement new remission data access layer and update remission list integration
2025-06-25 13:38:22 +00:00
Lorenz Hilpert
a68f5b5347 chore(dependencies): add optional dependency for @esbuild/linux-x64 2025-06-25 11:50:45 +02:00
Nino Righi
d53540b8db Merged PR 1867: #4769 Remi 3.0 - Remission – Scannen und Suchen
- feat(remission-list): Zwischencommit
- feat(ui-input-controls): Adjusted Dropdown Styling and Added Droption Option Disable Class, Refs: #4769
- feat(remission): implement remission list feature shell and category select
- Merge branch 'develop' into feature/4769-Remission-Liste
2025-06-23 15:23:54 +00:00
Lorenz Hilpert
4cf0ce820e Merged PR 1866: Anlage Komponenten und Directives + Unit Tests und Stories
Related work items: #5175
2025-06-18 13:58:00 +00:00
Nino
b21ebac53f feat(remission-list): Init Routing to Remission List 2025-06-16 17:24:35 +02:00
Lorenz Hilpert
5a68adc87c chore: update editorconfig and add .prettierrc for code formatting 2025-06-16 17:06:34 +02:00
Nino
befdc9fa4d feat(core-tabs): Changes due to Renaming from Process to Tab and Unit Test Fixes 2025-06-16 16:37:48 +02:00
Nino
e41dbc2870 feat(core-tabs): Move Core-Process to Core-Tabs 2025-06-16 15:05:30 +02:00
Nino
083f75a395 feat(remission): Init Remission Feature/Shared/Helpers and Data-Access Libs
Refs: #4768, #4769, #4770, #4771
2025-06-16 13:51:43 +02:00
Lorenz Hilpert
7c8aef9a48 chore: update npm install command to include legacy-peer-deps flag 2025-06-16 12:06:10 +02:00
Lorenz Hilpert
ee841eba49 chore: update @nx/angular dependency to version ^20.1.4 2025-06-16 12:02:45 +02:00
Lorenz Hilpert
0560f18de3 Merge branch 'migration-angular-20' into develop 2025-06-16 11:55:58 +02:00
Lorenz Hilpert
d8c2ca9bdc Migration Angular v19 -> v20 2025-06-16 11:54:47 +02:00
Lorenz Hilpert
54664123fb chore: update dependencies to latest versions
- Upgraded @nx packages to version 21.2.0
- Updated Storybook packages to version 9.0.5 and core-server to 8.6.11
- Upgraded eslint-config-prettier to version 10.1.5
- Updated jest-preset-angular to version 14.6.0
- Upgraded nx to version 21.2.0
- Updated storybook test-runner to version 0.22.0
2025-06-13 15:54:23 +02:00
1437 changed files with 83356 additions and 53163 deletions

View File

@@ -2,6 +2,3 @@ last 1 Chrome version
last 1 Firefox version
last 2 Edge major versions
last 2 iOS major versions
safari > 11
Firefox ESR
not IE 11 # Angular supports IE 11 only as an opt-in. To opt-in, remove the 'not' prefix on this line.

View File

@@ -7,6 +7,7 @@ indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
end_of_line = crlf
[*.md]
max_line_length = off

View File

@@ -1,21 +1,415 @@
# Mentor Instructions
## Introduction
You are Mentor, an AI assistant focused on ensuring code quality, strict adherence to best practices, and development efficiency. **Your core function is to enforce the coding standards and guidelines established in this workspace.** Your goal is to help me produce professional, maintainable, and high-performing code.
## Tone and Personality
Maintain a professional, objective, and direct tone consistently:
- **Guideline Enforcement & Error Correction:** When code deviates from guidelines or contains errors, provide precise, technical feedback. Clearly state the issue, cite the relevant guideline or principle, and explain the required correction for optimal, maintainable results.
- **Technical Consultation:** In discussions about architecture, best practices, or complex coding inquiries, remain formal and analytical. Provide clear, well-reasoned explanations and recommendations grounded in industry standards and the project's specific guidelines.
## Behavioral Guidelines
- **Actionable Feedback:** Prioritize constructive, actionable feedback aimed at improving code quality, maintainability, and adherence to standards. Avoid rewriting code; focus on explaining the necessary changes and their rationale based on guidelines.
- **Strict Guideline Adherence:** Base _all_ feedback, suggestions, and explanations rigorously on the guidelines documented within this workspace. Cite specific rules and principles consistently.
- **Demand Clarity:** If a query or code snippet lacks sufficient detail for a thorough, professional analysis, request clarification.
- **Professional Framing:** Frame all feedback objectively, focusing on the technical aspects and the importance of meeting project standards for long-term success.
- **Context-Specific Expertise:** Provide specific, context-aware advice tailored to the code or problem, always within the framework of our established guidelines.
- **Enforce Standards:** Actively enforce project preferences for Type safety, Clean Code principles, and thorough documentation, as mandated by the workspace guidelines.
## ISA Frontend AI Assistant Working Rules
Concise, project-specific guidance so an AI agent can be productive quickly. Focus on THESE patterns; avoid generic boilerplate.
### 1. Monorepo & Tooling
- Nx workspace (Angular 20 + Libraries under `libs/**`, main app `apps/isa-app`).
- Scripts (see `package.json`):
- Dev serve: `npm start` (=> `nx serve isa-app --ssl`).
- Library tests (exclude app): `npm test` (Jest + emerging Vitest). CI uses `npm run ci`.
- Build dev: `npm run build`; prod: `npm run build-prod`.
- Storybook: `npm run storybook`.
- Swagger codegen: `npm run generate:swagger` then `npm run fix:files:swagger`.
- Default branch in Nx: `develop` (`nx.json: defaultBase`). Use affected commands when adding libs.
- Node >=22, TS 5.8, ESLint flat config (`eslint.config.js`).
### 1.a Project Tree (Detailed Overview)
```
.
├─ apps/
│ └─ isa-app/ # Main Angular app (Jest). Legacy non-standalone root component pattern.
│ ├─ project.json # Build/serve/test targets
│ ├─ src/
│ │ ├─ main.ts / index.html # Angular bootstrap
│ │ ├─ app/main.component.ts # Root component (standalone:false)
│ │ ├─ environments/ # Environment files (prod replace)
│ │ ├─ assets/ # Static assets
│ │ └─ config/ # Runtime config JSON (read via Config service)
│ └─ .storybook/ # App Storybook config
├─ libs/ # All reusable code (grouped by domain / concern)
│ ├─ core/ # Cross-cutting infrastructure
│ │ ├─ logging/ # Logging service + providers + sinks
│ │ │ ├─ src/lib/logging.service.ts
│ │ │ ├─ src/lib/logging.providers.ts
│ │ │ └─ README.md # Full API & patterns
│ │ ├─ config/ # `Config` service (Zod validated lookup)
│ │ └─ storage/ # User-scoped storage + signal store feature (`withStorage`)
│ │ ├─ src/lib/signal-store-feature.ts
│ │ └─ src/lib/storage.ts
│ │
│ ├─ shared/ # Shared UI/services not domain specific
│ │ └─ scanner/ # Scandit integration (tokens, service, components, platform gating)
│ │ ├─ src/lib/scanner.service.ts
│ │ └─ src/lib/render-if-scanner-is-ready.directive.ts
│ │
│ ├─ remission/ # Remission domain features (newer pattern; Vitest)
│ │ ├─ feature/
│ │ │ ├─ remission-return-receipt-details/
│ │ │ │ ├─ vite.config.mts # Signals + Vitest example
│ │ │ │ └─ src/lib/resources/ # Resource factories (signals async pattern)
│ │ │ └─ remission-return-receipt-list/
│ │ └─ shared/ # Dialogs / shared remission UI pieces
│ │
│ ├─ common/ # Cross-domain utilities (decorators, print, data-access)
│ ├─ utils/ # Narrow utility libs (ean-validation, z-safe-parse, etc.)
│ ├─ ui/ # Generic UI components (presentational)
│ ├─ icons/ # Icon sets / wrappers
│ ├─ catalogue/ # Domain area (legacy Jest)
│ ├─ customer/ # Domain area (legacy Jest)
│ └─ oms/ # Domain area (legacy Jest)
├─ generated/swagger/ # Generated API clients (regen via scripts; do not hand edit)
├─ tools/ # Helper scripts (e.g. swagger fix script)
├─ testresults/ # JUnit XML (jest-junit). CI artifact pickup.
├─ coverage/ # Per-project coverage outputs
├─ tailwind-plugins/ # Custom Tailwind plugin modules used by `tailwind.config.js`
├─ vitest.workspace.ts # Glob enabling multi-lib Vitest detection
├─ nx.json / package.json # Workspace + scripts + defaultBase=develop
└─ eslint.config.js # Flat ESLint root config
```
Guidelines: create new code in the closest domain folder; expose public API via each lib `src/index.ts`; follow existing naming (`feature-name.type.ts`). Keep generated swagger untouched—extend via wrapper libs if needed.
### 1.b Import Path Aliases
Use existing TS path aliases (see `tsconfig.base.json`) instead of long relative paths:
Core / Cross-cutting:
- `@isa/core/logging`, `@isa/core/config`, `@isa/core/storage`, `@isa/core/tabs`, `@isa/core/notifications`
Domain & Features:
- Catalogue: `@isa/catalogue/data-access`
- Customer: `@isa/customer/data-access`
- OMS features: `@isa/oms/feature/return-details`, `.../return-process`, `.../return-review`, `.../return-search`, `.../return-summary`
- OMS shared: `@isa/oms/shared/product-info`, `@isa/oms/shared/task-list`
- Remission: `@isa/remission/data-access`, feature libs (`@isa/remission/feature/remission-return-receipt-details`, `...-list`) and shared (`@isa/remission/shared/remission-start-dialog`, `.../search-item-to-remit-dialog`, `.../return-receipt-actions`, `.../product`)
Shared / UI:
- Shared libs: `@isa/shared/scanner`, `@isa/shared/filter`, `@isa/shared/product-image`, `@isa/shared/product-router-link`, `@isa/shared/product-format`
- UI components: `@isa/ui/buttons`, `@isa/ui/dialog`, `@isa/ui/input-controls`, `@isa/ui/layout`, `@isa/ui/menu`, `@isa/ui/toolbar`, etc. (one alias per folder under `libs/ui/*`)
- Icons: `@isa/icons`
Utilities:
- `@isa/utils/ean-validation`, `@isa/utils/z-safe-parse`, `@isa/utils/scroll-position`
Generated Swagger Clients:
- `@generated/swagger/isa-api`, `@generated/swagger/oms-api`, `@generated/swagger/inventory-api`, etc. (one per subfolder). Never edit generated sources—wrap in a domain lib if extension needed.
App-local (only inside `apps/isa-app` context):
- Namespaced folders: `@adapter/*`, `@domain/*`, `@hub/*`, `@modal/*`, `@page/*`, `@shared/*` (and nested: `@shared/components/*`, `@shared/services/*`, etc.), `@ui/*`, `@utils/*`, `@swagger/*`.
Patterns:
- Always add new reusable code as a library then expose via an `@isa/...` alias; do not add new generic code under app-local aliases if it may be reused later.
- When introducing a new library ensure its `src/index.ts` re-exports only stable public surface; internal helpers stay un-exported.
- For new generated API groups, extend via thin wrappers in a domain `data-access` lib rather than patching generated code.
### 2. Testing Strategy
- Legacy tests: Jest (`@nx/jest:jest`). New feature libs (e.g. remission feature) use Vitest + Vite plugin (`vite.config.mts`).
- When adding a new library today prefer Vitest unless consistency with existing Jest-only area is required.
- Do NOT mix frameworks inside one lib. Check presence of `vite.config.*` to know it is Vitest-enabled.
- App (`isa-app`) still uses Jest.
### 3. Architecture & Cross-Cutting Services
- Core libraries underpin features: `@isa/core/logging`, `@isa/core/config`, `@isa/core/storage`.
- Feature domains grouped (e.g. `libs/remission/**`, `libs/shared/**`, `libs/common/**`). Keep domain-specific code there; UI-only pieces in `ui/` or `shared/`.
- Prefer standalone components but some legacy components set `standalone: false` (see `MainComponent`). Maintain existing pattern unless doing a focused migration.
### 4. Logging (Critical Pattern)
- Central logging via `@isa/core/logging` (files: `logging.service.ts`, `logging.providers.ts`).
- Configure once in app config using provider builders: `provideLogging(withLogLevel(...), withSink(ConsoleLogSink), withContext({...}))`.
- Use factory `logger(() => ({ dynamicContext }))` (see README) rather than injecting `LoggingService` directly unless extending framework code.
- Context hierarchy: global -> component (`provideLoggerContext`) -> instance (factory param) -> message (callback arg). Always pass context as lazy function `() => ({ ... })` for perf.
- Respect log level threshold; do not perform expensive serialization before calling (let sinks handle it or gate behind dev checks).
### 5. Configuration Access
- Use `Config` service (`@isa/core/config/src/lib/config.ts`). Fetch values with Zod schema: `config.get('licence.scandit', z.string())` (see `SCANDIT_LICENSE` token). Avoid deprecated untyped access.
### 6. Storage & State Persistence
- Storage abstraction: `injectStorage(SomeProvider)` wraps a `StorageProvider` (local/session/indexedDB/custom user storage) and prefixes keys with current authenticated user `sub` (OAuth `sub` fallback 'anonymous').
- When adding persisted signal stores, use `withStorage(storageKey, ProviderType)` feature (`signal-store-feature.ts`) to auto debounce-save (1s) + restore on init. Only pass plain serializable state.
### 7. Signals & State
- Internal state often via Angular signals & NgRx Signals (`@ngrx/signals`). Avoid manual subscriptions—prefer computed/signals and `rxMethod` for side effects.
- When persisting, ensure objects are JSON-safe; validation via Zod if deserializing external data.
#### 7.a NgRx Signals Deep Dive
Core building blocks we use:
- `signalStore(...)` + features: `withState`, `withComputed`, `withMethods`, `withHooks`, `withStorage` (custom feature in `core/storage`).
- `rxMethod` (from `@ngrx/signals/rxjs-interop`) to bridge imperative async flows (HTTP calls, debounce, switchMap) into store-driven mutations.
- `getState`, `patchState` for immutable, shallow merges; avoid manually mutating nested objects—spread + patch.
Patterns:
1. Store Shape: Keep initial state small & serializable (no class instances, functions, DOM nodes). Derive heavy or view-specific projections with `withComputed`.
2. Side Effects: Wrap fetch/update flows inside `rxMethod` pipes; ensure cancellation semantics (`switchMap`) to drop stale requests.
3. Persistence: Apply `withStorage(key, Provider)` last so hooks run after other features; persisted state must be plain JSON (no Dates—convert to ISO strings). Debounce already handled (1s) in `withStorage`—do NOT add another debounce upstream unless burst traffic is extreme.
4. Error Handling: Keep an `error` field in state for presentation; log via `logger()` at Warn/Error levels but do not store full Error object (serialize minimal fields: `message`, maybe `code`).
5. Loading Flags: Prefer a boolean `loading` OR a discriminated union `status: 'idle'|'loading'|'success'|'error'` for richer UI; avoid multiple booleans that can drift.
6. Computed Selectors: Name as `XComputed` or just semantic (e.g. `filteredItems`) using `computed(() => ...)` inside `withComputed`; never cause side-effects in a computed.
7. Resource Factory Pattern: For remote data needed in multiple components, create a factory function returning an object with `value`, `isLoading`, `error` signals plus a `reload()` method; see remission `resources/` directory.
Store Lifecycle Hooks:
- Use `withHooks({ onInit() { ... }, onDestroy() { ... } })` for restoration, websockets, or timers. Pair cleanups explicitly.
Persistence Feature (`withStorage`):
- Implementation: Debounced `storeState` rxMethod listens to any state change, saves hashed userscoped key (see `hash.utils.ts`). On init it calls `restoreState()`.
- Extending: If you need to blacklist transient fields from persistence, add a method wrapping `getState` and remove keys before `storage.set` (extend feature locally rather than editing shared code unless broadly needed).
Typical Store Template:
```ts
// feature-x.store.ts
import {
signalStore,
withState,
withComputed,
withMethods,
withHooks,
} from '@ngrx/signals';
import { rxMethod } from '@ngrx/signals/rxjs-interop';
import { debounceTime, switchMap, tap, catchError, of } from 'rxjs';
import { withStorage } from '@isa/core/storage';
import { logger } from '@isa/core/logging';
interface FeatureXState {
items: ReadonlyArray<Item>;
query: string;
loading: boolean;
error?: string;
}
const initialState: FeatureXState = { items: [], query: '', loading: false };
export const FeatureXStore = signalStore(
withState(initialState),
withProps((store, logger = logger(() => ({ store: 'FeatureX' }))) => ({
_logger: logger,
})),
withComputed(({ items, query }) => ({
filtered: computed(() => items().filter((i) => i.name.includes(query()))),
hasError: computed(() => !!query() && !items().length),
})),
withMethods((store) => ({
setQuery: (q: string) => patchState(store, { query: q }),
// rxMethod side effect to load items
loadItems: rxMethod<string | void>(
pipe(
debounceTime(150),
tap(() => patchState(store, { loading: true, error: undefined })),
switchMap(() =>
fetchItems(store.query()).pipe(
tap((items) => patchState(store, { items, loading: false })),
catchError((err) => {
store._logger.error('Load failed', err as Error, () => ({
query: store.query(),
}));
patchState(store, {
loading: false,
error: (err as Error).message,
});
return of([]);
}),
),
),
),
),
})),
withHooks((store) => ({
onInit() {
store.loadItems();
},
})),
withStorage('feature-x', LocalStorageProvider),
);
```
Testing Signal Stores (Vitest or Jest):
- Use `runInInjectionContext(TestBed.inject(Injector), () => FeatureXStore)` or instantiate via exported factory if provided.
- For async rxMethod flows, flush microtasks (`await vi.runAllTimersAsync()` if timers used) or rely on returned observable completion when you subscribe inside the test harness.
- Snapshot only primitive slices (avoid full object snapshots with volatile ordering).
Migration Tips:
- Converting legacy NgRx reducers: Start by lifting static initial state + selectors into `withState` + `withComputed`; replace effects with `rxMethod` maintaining cancellation semantics (`switchMap` mirrors effect flattening strategy).
- Keep action names only if externally observed (analytics, logging). Otherwise remove ceremony—call store methods directly.
Anti-Patterns to Avoid:
- Writing to signals inside a computed or inside another signal setter (causes cascading updates).
- Storing large unnormalized arrays and then repeatedly filtering/sorting in multiple components—centralize that in computed selectors.
- Persisting secrets or PII directly; hash keys already user-scoped but content still plain—sanitize if needed.
- Returning raw subscriptions from store methods; expose signals or idempotent methods only.
#### 7.b Prefer Signals over Observables (Practical Rules)
Default to signals for all in-memory UI & derived state; keep Observables only at I/O edges.
Use Observables for:
- HTTP / WebSocket / SignalR streams at the boundary.
- Timer / interval / external event sources.
- Interop with legacy NgRx store pieces not yet migrated.
Immediately convert inbound Observables to signals:
```ts
// Legacy service returning Observable<Item[]>
items$ = http.get<Item[]>(url);
// New pattern
const items = toSignal(http.get<Item[]>(url), { initialValue: [] });
```
Expose signals from stores & services:
```ts
// BAD (forces template async pipe + subscription mgmt)
getItems(): Observable<Item[]> { return this.http.get(...); }
// GOOD
items = toSignal(this.http.get<Item[]>(url), { initialValue: [] });
```
Bridge when needed:
```ts
// Signal -> Observable (rare):
const queryChanges$ = fromSignal(query, { requireSync: true });
// Observable -> Signal (preferred):
const data = toSignal(data$, { initialValue: undefined });
```
Side-effects: never subscribe manually—wrap in `rxMethod` (cancels stale work via `switchMap`).
```ts
loadData: rxMethod<void>(
pipe(
switchMap(() =>
this.api.fetch().pipe(tap((r) => patchState(store, { data: r }))),
),
),
);
```
Template usage: reference signals directly (`{{ item.name }}`) or in control flow; no `| async` needed.
Replacing combineLatest / map chains:
```ts
// Before (Observable)
vm$ = combineLatest([a$, b$]).pipe(map(([a, b]) => buildVm(a, b)));
// After (Signals)
const vm = computed(() => buildVm(a(), b()));
```
Debounce / throttle user input:
Keep raw form value as a signal; create an rxMethod for debounced fetch instead of debouncing inside a computed.
```ts
search = signal('');
runSearch: rxMethod<string>(
pipe(
debounceTime(300),
switchMap((term) =>
this.api
.search(term)
.pipe(tap((results) => patchState(store, { results }))),
),
),
);
effect(() => {
runSearch(this.search());
});
```
Avoid converting a signal back to an Observable just to use a single RxJS operator; prefer inline signal `computed` or small helper.
Migration heuristic:
1. Identify component `foo$` fields used only in template -> convert to signal via `toSignal`.
2. Collapse chains of `combineLatest` + `map` into `computed`.
3. Replace imperative `subscribe` side-effects with `rxMethod` + `patchState`.
4. Add persistence last via `withStorage` if state must survive reload.
Performance tip: heavy derived computations (sorting large arrays) belong in a memoized `computed`; if expensive & infrequently needed, gate behind another signal flag.
### 8. Scanner Integration (Scandit)
- Barcode scanning encapsulated in `@isa/shared/scanner` (`scanner.service.ts`). Use provided injection tokens for license & defaults (override via DI if needed). Service auto-configures once; `ready` signal triggers `configure()` lazily.
- Always catch and log errors with proper context; platform gating throws `PlatformNotSupportedError` which is downgraded to warn.
### 9. Styling
- Tailwind with custom semantic tokens (`tailwind.config.js`). Prefer design tokens like `text-isa-neutral-700`, spacing utilities with custom `px-*` scales rather than adhoc raw values.
- Global overlays rely on CDK classes; retain `@angular/cdk/overlay-prebuilt.css` in style arrays when creating new entrypoints or Storybook stories.
### 10. Library Conventions
- File naming: kebab-case; feature first then type (e.g. `return-receipt-list.component.ts`).
- Provide public API via each lib `src/index.ts`. Export only stable symbols; keep internal utilities in subfolders not re-exported.
- Add `project.json` with `test` & `lint` targets; for new Vitest libs include `vite.config.mts` and adjust `tsconfig.spec.json` references to vitest types.
### 11. Adding / Modifying Tests
- For Jest libs: standard `*.spec.ts` with `TestBed`. Spectator may appear in legacy code—do not introduce Spectator in new tests; use Angular Testing Utilities.
- For Vitest libs: ensure `vite.config.mts` includes `setupFiles`. Use `describe/it` from `vitest` and Angular TestBed (see remission resource spec for pattern of using `runInInjectionContext`).
- Prefer resource-style factories returning signals for async state (pattern in `createSupplierResource`).
### 12. Performance & Safety
- Logging: rely on lazy context function; avoid `JSON.stringify()` unless behind a dev guard.
- Storage: hashing keys (see `hash.utils.ts`) ensures stable key space; do not bypass if you need consistent per-user scoping.
- Scanner overlay: always clean up overlay + event listeners (follow existing `open` implementation for pattern).
### 13. CI / Coverage / Artifacts
- JUnit XML placed in `testresults/` (Jest configured with `jest-junit`). Keep filename stability for pipeline consumption; do not rename those outputs.
- Coverage output under `coverage/libs/...`; respect Nx caching—avoid side effects outside project roots.
### 14. When Unsure
- Search existing domain folder for analogous implementation (e.g. new feature under remission: inspect sibling feature libs for structure).
- Preserve existing DI token patterns instead of introducing new global singletons.
### 15. Quick Examples
```ts
// New feature logger usage
const log = logger(() => ({ feature: 'ReturnReceipt', action: 'init' }));
log.info('Mount');
// Persisting a signal store slice
export const FeatureStore = signalStore(
withState(initState),
withStorage('return:filters', LocalStorageProvider),
);
// Fetch config value safely
const apiBase = inject(Config).get('api.baseUrl', z.string().url());
```
---
Let me know if any area (e.g. auth flow, NgRx usage, Swagger generation details) needs deeper coverage and I can extend this file.

View File

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

189
.github/prompts/plan.prompt.md vendored Normal file
View File

@@ -0,0 +1,189 @@
---
mode: agent
tools: ['edit', 'search', 'usages', 'vscodeAPI', 'problems', 'changes', 'fetch', 'githubRepo', 'Nx Mcp Server', 'context7']
description: Plan Mode - Research and create a detailed implementation plan before making any changes.
model: Gemini 2.5 Pro (copilot)
---
# Plan Mode
You are now operating in **Plan Mode** - a research and planning phase that ensures thorough analysis before implementation. Plan mode is **ALWAYS ACTIVE** when using this prompt. You must follow these strict guidelines for every request:
## Phase 1: Research & Analysis (MANDATORY)
### ALLOWED Operations:
- ✅ Read files using Read, Glob, Grep tools
- ✅ Search documentation and codebases
- ✅ Analyze existing patterns and structures
- ✅ Use WebFetch for documentation research
- ✅ List and explore project structure
- ✅ Use Nx/Angular/Context7 MCP tools for workspace analysis
- ✅ Review dependencies and configurations
### FORBIDDEN Operations:
-**NEVER** create, edit, or modify any files
-**NEVER** run commands that change system state
-**NEVER** make commits or push changes
-**NEVER** install packages or modify configurations
-**NEVER** run build/test commands during planning
## Phase 2: Plan Presentation (REQUIRED FORMAT)
After thorough research, present your plan using this exact structure:
```markdown
## 📋 Implementation Plan
### 🎯 Objective
[Clear statement of what will be accomplished]
### 🔍 Research Summary
- **Current State**: [What exists now]
- **Requirements**: [What needs to be built/changed]
- **Constraints**: [Limitations and considerations]
### 📁 Files to be Modified/Created
1. **File**: `path/to/file.ts`
- **Action**: Create/Modify/Delete
- **Purpose**: [Why this file needs changes]
- **Key Changes**: [Specific modifications planned]
2. **File**: `path/to/another-file.ts`
- **Action**: Create/Modify/Delete
- **Purpose**: [Why this file needs changes]
- **Key Changes**: [Specific modifications planned]
### 🏗️ Implementation Steps
1. **Step 1**: [Detailed description]
- Files affected: `file1.ts`, `file2.ts`
- Rationale: [Why this step is necessary]
2. **Step 2**: [Detailed description]
- Files affected: `file3.ts`
- Rationale: [Why this step is necessary]
3. **Step N**: [Continue numbering...]
### ⚠️ Risks & Considerations
- **Risk 1**: [Potential issue and mitigation]
- **Risk 2**: [Potential issue and mitigation]
### 🧪 Testing Strategy
- [How the changes will be tested]
- [Specific test files or approaches]
### 📚 Architecture Decisions
- **Pattern Used**: [Which architectural pattern will be followed]
- **Libraries/Dependencies**: [What will be used and why]
- **Integration Points**: [How this fits with existing code]
### ✅ Success Criteria
- [ ] Criterion 1
- [ ] Criterion 2
- [ ] All tests pass
- [ ] No lint errors
```
## Phase 3: Await Approval
After presenting the plan:
1. **STOP** all implementation activities
2. **WAIT** for explicit user approval
3. **DO NOT** proceed with any file changes
4. **RESPOND** to questions or plan modifications
5. **EXIT PLAN MODE** only when user explicitly says "execute", "implement", "go ahead", "approved", or similar approval language
## Phase 4: Implementation (After Exiting Plan Mode)
Once the user explicitly approves and you exit plan mode:
1. **PLAN MODE IS NOW DISABLED** - you can proceed with normal implementation
2. Use TodoWrite to create implementation todos
3. Follow the plan step-by-step
4. Update todos as you progress
5. Run tests and lint checks as specified
6. Provide progress updates
## Key Behavioral Rules
### Research Thoroughly
- Spend significant time understanding the codebase
- Look for existing patterns to follow
- Identify all dependencies and integration points
- Consider edge cases and error scenarios
### Be Comprehensive
- Plans should be detailed enough for another developer to implement
- Include all necessary file changes
- Consider testing, documentation, and deployment
- Address potential conflicts or breaking changes
### Show Your Work
- Explain reasoning behind architectural decisions
- Reference existing code patterns when applicable
- Cite documentation or best practices
- Provide alternatives when multiple approaches exist
### Safety First
- Never make changes during planning phase
- Always wait for explicit approval
- Flag potentially risky changes
- Suggest incremental implementation when complex
## Example Interactions
### Good Plan Mode Behavior:
```
User: "Add a dark mode toggle to the settings page"
Assistant: I'll research the current theming system and create a comprehensive plan for implementing dark mode.
[Extensive research using Read, Grep, Glob tools]
## 📋 Implementation Plan
[Follows complete format above]
Ready to proceed? Please approve this plan before I begin implementation.
```
### What NOT to do:
```
User: "Add a dark mode toggle"
Assistant: I'll add that right away!
[Immediately starts editing files - WRONG!]
```
# <<<<<<< HEAD
## Integration with Existing Copilot Instructions
This plan mode respects all existing project patterns:
- Follows Angular + Nx workspace conventions
- Uses existing import path aliases
- Respects testing strategy (Jest/Vitest)
- Follows NgRx Signals patterns
- Adheres to logging and configuration patterns
- Maintains library conventions and file naming
> > > > > > > develop
> > > > > > > Remember: **RESEARCH FIRST, PLAN THOROUGHLY, WAIT FOR APPROVAL, THEN IMPLEMENT**

View File

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

145
.gitignore vendored
View File

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

View File

@@ -1,7 +1,7 @@
{
"*.ts": "npx eslint --fix --config eslint.config.mjs",
"*.tsx": "npx eslint --fix --config eslint.config.mjs",
"*.js": "npx eslint --fix --config eslint.config.mjs",
"*.jsx": "npx eslint --fix --config eslint.config.mjs",
"*.html": "npx eslint --fix --config eslint.config.mjs"
}
{
"*.ts": "npx eslint --fix --config eslint.config.js",
"*.tsx": "npx eslint --fix --config eslint.config.js",
"*.js": "npx eslint --fix --config eslint.config.js",
"*.jsx": "npx eslint --fix --config eslint.config.js",
"*.html": "npx eslint --fix --config eslint.config.js"
}

37
.prettierrc Normal file
View File

@@ -0,0 +1,37 @@
{
"singleQuote": true,
"semi": true,
"trailingComma": "all",
"tabWidth": 2,
"bracketSpacing": true,
"printWidth": 80,
"endOfLine": "auto",
"arrowParens": "always",
"quoteProps": "consistent",
"overrides": [
{
"files": "*.html",
"options": {
"parser": "html"
}
},
{
"files": "*.component.html",
"options": {
"parser": "angular"
}
},
{
"files": "*.scss",
"options": {
"singleQuote": false
}
},
{
"files": "*.json",
"options": {
"printWidth": 80
}
}
]
}

View File

@@ -5,6 +5,7 @@
"angular.ng-template",
"nrwl.angular-console",
"dbaeumer.vscode-eslint",
"firsttris.vscode-jest-runner"
"firsttris.vscode-jest-runner",
"editorconfig.editorconfig"
]
}

192
.vscode/settings.json vendored
View File

@@ -1,92 +1,100 @@
{
"editor.accessibilitySupport": "off",
"typescript.tsdk": "node_modules/typescript/lib",
"exportall.config.exclude": [".test.", ".spec.", ".stories."],
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
"eslint.validate": [
"json"
],
"[html]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[typescript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[typescriptreact]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"exportall.config.folderListener": [
"/libs/oms/data-access/src/lib/models",
"/libs/oms/data-access/src/lib/schemas",
"/libs/catalogue/data-access/src/lib/models",
"/libs/common/data-access/src/lib/models",
"/libs/common/data-access/src/lib/error",
"/libs/oms/data-access/src/lib/errors/return-process"
],
"github.copilot.chat.commitMessageGeneration.instructions": [
{
"file": ".github/commit-instructions.md"
}
],
"github.copilot.chat.codeGeneration.instructions": [
{
"file": ".vscode/llms/angular.txt"
},
{
"file": "docs/tech-stack.md"
},
{
"file": "docs/guidelines/code-style.md"
},
{
"file": "docs/guidelines/project-structure.md"
},
{
"file": "docs/guidelines/state-management.md"
},
{
"file": "docs/guidelines/testing.md"
}
],
"github.copilot.chat.testGeneration.instructions": [
{
"file": ".github/testing-instructions.md"
},
{
"file": "docs/tech-stack.md"
},
{
"file": "docs/guidelines/code-style.md"
},
{
"file": "docs/guidelines/testing.md"
}
],
"github.copilot.chat.reviewSelection.instructions": [
{
"file": ".github/copilot-instructions.md"
},
{
"file": ".github/review-instructions.md"
},
{
"file": "docs/tech-stack.md"
},
{
"file": "docs/guidelines/code-style.md"
},
{
"file": "docs/guidelines/project-structure.md"
},
{
"file": "docs/guidelines/state-management.md"
},
{
"file": "docs/guidelines/testing.md"
}
],
"nxConsole.generateAiAgentRules": true,
"chat.mcp.enabled": true,
"chat.mcp.discovery.enabled": true
}
{
"editor.accessibilitySupport": "off",
"typescript.tsdk": "node_modules/typescript/lib",
"exportall.config.exclude": [".test.", ".spec.", ".stories."],
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
"eslint.validate": [
"json"
],
"[html]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[typescript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[typescriptreact]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[markdown]": {
"editor.formatOnSave": false
},
"exportall.config.folderListener": [
"/libs/oms/data-access/src/lib/models",
"/libs/oms/data-access/src/lib/schemas",
"/libs/catalogue/data-access/src/lib/models",
"/libs/common/data-access/src/lib/models",
"/libs/common/data-access/src/lib/error",
"/libs/oms/data-access/src/lib/errors/return-process"
],
"github.copilot.chat.commitMessageGeneration.instructions": [
{
"file": ".github/commit-instructions.md"
}
],
"github.copilot.chat.codeGeneration.instructions": [
{
"file": ".vscode/llms/angular.txt"
},
{
"file": "docs/tech-stack.md"
},
{
"file": "docs/guidelines/code-style.md"
},
{
"file": "docs/guidelines/project-structure.md"
},
{
"file": "docs/guidelines/state-management.md"
},
{
"file": "docs/guidelines/testing.md"
}
],
"github.copilot.chat.testGeneration.instructions": [
{
"file": ".github/testing-instructions.md"
},
{
"file": "docs/tech-stack.md"
},
{
"file": "docs/guidelines/code-style.md"
},
{
"file": "docs/guidelines/testing.md"
}
],
"github.copilot.chat.reviewSelection.instructions": [
{
"file": ".github/copilot-instructions.md"
},
{
"file": ".github/review-instructions.md"
},
{
"file": "docs/tech-stack.md"
},
{
"file": "docs/guidelines/code-style.md"
},
{
"file": "docs/guidelines/project-structure.md"
},
{
"file": "docs/guidelines/state-management.md"
},
{
"file": "docs/guidelines/testing.md"
}
],
"nxConsole.generateAiAgentRules": true,
"chat.mcp.discovery.enabled": {
"claude-desktop": true,
"windsurf": true,
"cursor-global": true,
"cursor-workspace": true
},
"chat.mcp.access": "all"
}

148
CLAUDE.md Normal file
View File

@@ -0,0 +1,148 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
This is an Angular monorepo managed by Nx. The main application is `isa-app`, which appears to be an inventory and returns management system for retail/e-commerce.
## Architecture
### Monorepo Structure
- **apps/isa-app**: Main Angular application
- **libs/**: Reusable libraries organized by domain and type
- **core/**: Core utilities (config, logging, storage, tabs)
- **common/**: Shared utilities (data-access, decorators, print)
- **ui/**: UI component libraries (buttons, dialogs, inputs, etc.)
- **shared/**: Shared domain components (filter, scanner, product components)
- **oms/**: Order Management System features and utilities
- **remission/**: Remission/returns management features
- **catalogue/**: Product catalogue functionality
- **utils/**: General utilities (validation, scroll position, parsing)
- **icons/**: Icon library
- **generated/swagger/**: Auto-generated API client code from OpenAPI specs
### Key Architectural Patterns
- **Standalone Components**: Project uses Angular standalone components
- **Feature Libraries**: Domain features organized as separate libraries (e.g., `oms-feature-return-search`)
- **Data Access Layer**: Separate data-access libraries for each domain (e.g., `oms-data-access`, `remission-data-access`)
- **Shared UI Components**: Reusable UI components in `libs/ui/`
- **Generated API Clients**: Swagger/OpenAPI clients auto-generated in `generated/swagger/`
## Common Development Commands
### Build Commands
```bash
# Build the main application (development)
npx nx build isa-app --configuration=development
# Build for production
npx nx build isa-app --configuration=production
# Serve the application with SSL
npx nx serve isa-app --ssl
```
### Testing Commands
```bash
# Run tests for a specific library (always use --skip-cache)
npx nx run <project-name>:test --skip-cache
# Example: npx nx run remission-data-access:test --skip-cache
# Run tests for all libraries except the main app
npx nx run-many -t test --exclude isa-app --skip-cache
# Run a single test file
npx nx run <project-name>:test --testFile=<path-to-test-file> --skip-cache
# Run tests with coverage
npx nx run <project-name>:test --code-coverage --skip-cache
# Run tests in watch mode
npx nx run <project-name>:test --watch
```
### Linting Commands
```bash
# Lint a specific project
npx nx lint <project-name>
# Example: npx nx lint remission-data-access
# Run linting for all projects
npx nx run-many -t lint
```
### Other Useful Commands
```bash
# Generate Swagger API clients
npm run generate:swagger
# Start Storybook
npx nx run isa-app:storybook
# Format code with Prettier
npm run prettier
# List all projects in the workspace
npx nx list
# Show project dependencies graph
npx nx graph
# Run affected tests (based on git changes)
npx nx affected:test
```
## Testing Framework
### Current Setup
- **Jest**: Primary test runner for existing libraries
- **Vitest**: Being adopted for new libraries (migration in progress)
- **Testing Utilities**:
- **Angular Testing Utilities** (TestBed, ComponentFixture): Use for new tests
- **Spectator**: Legacy testing utility for existing tests
- **ng-mocks**: For advanced mocking scenarios
### Test File Requirements
- Test files must end with `.spec.ts`
- Use AAA pattern (Arrange-Act-Assert)
- Include E2E testing attributes (`data-what`, `data-which`) in HTML templates
- Mock external dependencies and child components
## State Management
- **NgRx**: Store, Effects, Entity, Component Store, Signals
- **RxJS**: For reactive programming patterns
## Styling
- **Tailwind CSS**: Primary styling framework with custom configuration
- **SCSS**: For component-specific styles
- **Custom Tailwind plugins**: For buttons, inputs, menus, typography
## API Integration
- **Generated Swagger Clients**: Auto-generated TypeScript clients from OpenAPI specs
- **Available APIs**: availability, cat-search, checkout, crm, eis, inventory, isa, oms, print, wws
## Build Configuration
- **Angular 20.1.2**: Latest Angular version
- **TypeScript 5.8.3**: For type safety
- **Node.js >= 22.0.0**: Required Node version
- **npm >= 10.0.0**: Required npm version
## Important Conventions
- **Component Prefix**: Each library has its own prefix (e.g., `remi` for remission, `oms` for OMS)
- **Standalone Components**: All new components should be standalone
- **Path Aliases**: Use TypeScript path aliases defined in `tsconfig.base.json` (e.g., `@isa/core/config`)
- **Project Names**: Can be found in each library's `project.json` file
## Development Workflow Tips
- Always use `npx nx run` pattern for executing tasks
- Include `--skip-cache` flag when running tests to ensure fresh results
- Use Nx's affected commands to optimize CI/CD pipelines
- Project graph visualization helps understand dependencies: `npx nx graph`
## Development Notes
- Use start target to start the application. Only one project can be started: isa-app
- Make sure to have a look at @docs/guidelines/testing.md before writing tests
- Make sure to add e2e attributes to the html. Those are important for my colleagues writen e2e tests
- Guide for the e2e testing attributes can be found in the testing.md
- When reviewing code follow the instructions @.github/review-instructions.md

View File

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

View File

@@ -2,8 +2,11 @@ import type { StorybookConfig } from '@storybook/angular';
const config: StorybookConfig = {
stories: ['../stories/**/*.@(mdx|stories.@(js|jsx|ts|tsx))'],
addons: ['@storybook/addon-essentials', '@storybook/addon-interactions'],
staticDirs: ['../src/assets'],
addons: [
'@storybook/addon-essentials',
'@storybook/addon-interactions',
'@storybook/addon-docs',
],
previewHead: (head) => `
${head}
<link href="/assets/fonts/fonts.css" rel="stylesheet" />

View File

@@ -1,55 +1,55 @@
import nx from '@nx/eslint-plugin';
import baseConfig from '../../eslint.config.mjs';
export default [
...baseConfig,
...nx.configs['flat/angular'],
...nx.configs['flat/angular-template'],
{
files: ['**/*.ts'],
rules: {
'@angular-eslint/directive-selector': [
'error',
{
type: 'attribute',
prefix: 'app',
style: 'camelCase',
},
],
'@angular-eslint/component-selector': [
'error',
{
type: 'element',
prefix: 'app',
style: 'kebab-case',
},
],
},
},
{
files: ['**/*.ts'],
rules: {
'@typescript-eslint/no-unused-expressions': 'warn',
'prefer-const': 'warn',
'@angular-eslint/contextual-lifecycle': 'warn',
'@typescript-eslint/no-explicit-any': 'warn',
'@angular-eslint/no-empty-lifecycle-method': 'warn',
'@typescript-eslint/no-inferrable-types': 'warn',
'@angular-eslint/component-selector': 'warn',
'@angular-eslint/prefer-standalone': 'warn',
'@typescript-eslint/no-inferrable-types': 'warn',
'no-empty-function': 'warn',
'@typescript-eslint/no-empty-function': 'warn',
'@typescript-eslint/no-unused-vars': 'warn',
'@angular-eslint/directive-selector': 'warn',
},
},
{
files: ['**/*.html'],
// Override or add rules here
rules: {
'@angular-eslint/template/elements-content': 'warn',
'@angular-eslint/template/no-autofocus': 'warn',
},
},
];
const nx = require('@nx/eslint-plugin');
const baseConfig = require('../../eslint.config.js');
module.exports = [
...baseConfig,
...nx.configs['flat/angular'],
...nx.configs['flat/angular-template'],
{
files: ['**/*.ts'],
rules: {
'@angular-eslint/directive-selector': [
'error',
{
type: 'attribute',
prefix: 'app',
style: 'camelCase',
},
],
'@angular-eslint/component-selector': [
'error',
{
type: 'element',
prefix: 'app',
style: 'kebab-case',
},
],
},
},
{
files: ['**/*.ts'],
rules: {
'@typescript-eslint/no-unused-expressions': 'warn',
'prefer-const': 'warn',
'@angular-eslint/contextual-lifecycle': 'warn',
'@typescript-eslint/no-explicit-any': 'warn',
'@angular-eslint/no-empty-lifecycle-method': 'warn',
'@typescript-eslint/no-inferrable-types': 'warn',
'@angular-eslint/component-selector': 'warn',
'@angular-eslint/prefer-standalone': 'warn',
'@typescript-eslint/no-inferrable-types': 'warn',
'no-empty-function': 'warn',
'@typescript-eslint/no-empty-function': 'warn',
'@typescript-eslint/no-unused-vars': 'warn',
'@angular-eslint/directive-selector': 'warn',
},
},
{
files: ['**/*.html'],
// Override or add rules here
rules: {
'@angular-eslint/template/elements-content': 'warn',
'@angular-eslint/template/no-autofocus': 'warn',
},
},
];

View File

@@ -38,7 +38,7 @@
],
"styles": [
"@angular/cdk/overlay-prebuilt.css",
"apps/isa-app/src/ui.scss",
"apps/isa-app/src/tailwind.scss",
"apps/isa-app/src/styles.scss"
],
"scripts": []
@@ -84,7 +84,8 @@
"buildTarget": "isa-app:build:development"
}
},
"defaultConfiguration": "development"
"defaultConfiguration": "development",
"continuous": true
},
"extract-i18n": {
"executor": "@angular-devkit/build-angular:extract-i18n",
@@ -117,7 +118,19 @@
"configDir": "apps/isa-app/.storybook",
"browserTarget": "isa-app:build",
"compodoc": false,
"styles": ["apps/isa-app/src/ui.scss", "apps/isa-app/src/styles.scss"]
"open": false,
"assets": [
{
"glob": "**/*",
"input": "apps/isa-app/src/assets",
"output": "/assets"
}
],
"styles": [
"@angular/cdk/overlay-prebuilt.css",
"apps/isa-app/src/tailwind.scss",
"apps/isa-app/src/styles.scss"
]
},
"configurations": {
"ci": {
@@ -132,33 +145,18 @@
"outputDir": "dist/storybook/isa-app",
"configDir": "apps/isa-app/.storybook",
"browserTarget": "isa-app:build",
"compodoc": false
"compodoc": false,
"styles": [
"@angular/cdk/overlay-prebuilt.css",
"apps/isa-app/src/tailwind.scss",
"apps/isa-app/src/styles.scss"
]
},
"configurations": {
"ci": {
"quiet": true
}
}
},
"test-storybook": {
"executor": "nx:run-commands",
"options": {
"command": "test-storybook -c apps/isa-app/.storybook --url=http://localhost:4400"
}
},
"static-storybook": {
"executor": "@nx/web:file-server",
"dependsOn": ["build-storybook"],
"options": {
"buildTarget": "isa-app:build-storybook",
"staticFilePath": "dist/storybook/isa-app",
"spa": true
},
"configurations": {
"ci": {
"buildTarget": "isa-app:build-storybook:ci"
}
}
}
}
}

View File

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

View File

@@ -1,4 +1,4 @@
import { DOCUMENT } from '@angular/common';
import {
Component,
effect,
@@ -10,6 +10,7 @@ import {
Renderer2,
signal,
untracked,
DOCUMENT
} from '@angular/core';
import { Title } from '@angular/platform-browser';
import { SwUpdate } from '@angular/service-worker';

View File

@@ -1,233 +1,253 @@
import {
HTTP_INTERCEPTORS,
provideHttpClient,
withInterceptorsFromDi,
} from '@angular/common/http';
import {
ErrorHandler,
Injector,
LOCALE_ID,
NgModule,
inject,
provideAppInitializer,
} from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { PlatformModule } from '@angular/cdk/platform';
import { Config } from '@core/config';
import { AuthModule, AuthService, LoginStrategy } from '@core/auth';
import { CoreCommandModule } from '@core/command';
import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component';
import { CoreApplicationModule } from '@core/application';
import { AppStoreModule } from './app-store.module';
import { ServiceWorkerModule } from '@angular/service-worker';
import { environment } from '../environments/environment';
import { AppSwaggerModule } from './app-swagger.module';
import { AppDomainModule } from './app-domain.module';
import { UiModalModule } from '@ui/modal';
import {
NotificationsHubModule,
NOTIFICATIONS_HUB_OPTIONS,
} from '@hub/notifications';
import { SignalRHubOptions } from '@core/signalr';
import { CoreBreadcrumbModule } from '@core/breadcrumb';
import { UiCommonModule } from '@ui/common';
import { registerLocaleData } from '@angular/common';
import localeDe from '@angular/common/locales/de';
import localeDeExtra from '@angular/common/locales/extra/de';
import { HttpErrorInterceptor } from './interceptors';
import { CoreLoggerModule, LOG_PROVIDER } from '@core/logger';
import { IsaLogProvider } from './providers';
import { IsaErrorHandler } from './providers/isa.error-handler';
import {
ScanAdapterModule,
ScanAdapterService,
ScanditScanAdapterModule,
} from '@adapter/scan';
import { RootStateService } from './store/root-state.service';
import * as Commands from './commands';
import { PreviewComponent } from './preview';
import { NativeContainerService } from '@external/native-container';
import { ShellModule } from '@shared/shell';
import { MainComponent } from './main.component';
import { IconModule } from '@shared/components/icon';
import { NgIconsModule } from '@ng-icons/core';
import {
matClose,
matWifi,
matWifiOff,
} from '@ng-icons/material-icons/baseline';
import { NetworkStatusService } from './services/network-status.service';
import { firstValueFrom } from 'rxjs';
import { provideMatomo } from 'ngx-matomo-client';
import { withRouter, withRouteData } from 'ngx-matomo-client';
import {
provideLogging,
withLogLevel,
LogLevel,
withSink,
ConsoleLogSink,
} from '@isa/core/logging';
registerLocaleData(localeDe, localeDeExtra);
registerLocaleData(localeDe, 'de', localeDeExtra);
export function _appInitializerFactory(config: Config, injector: Injector) {
return async () => {
const statusElement = document.querySelector('#init-status');
const laoderElement = document.querySelector('#init-loader');
try {
let online = false;
const networkStatus = injector.get(NetworkStatusService);
while (!online) {
online = await firstValueFrom(networkStatus.online$);
if (!online) {
statusElement.innerHTML =
'<b>Warte auf Netzwerkverbindung (WLAN)</b><br><br>Bitte prüfen Sie die Netzwerkverbindung (WLAN).<br>Sobald eine Netzwerkverbindung besteht, wird die App automatisch neu geladen.';
await new Promise((resolve) => setTimeout(resolve, 250));
}
}
statusElement.innerHTML = 'Konfigurationen werden geladen...';
statusElement.innerHTML = 'Scanner wird initialisiert...';
const scanAdapter = injector.get(ScanAdapterService);
await scanAdapter.init();
statusElement.innerHTML = 'Authentifizierung wird geprüft...';
const auth = injector.get(AuthService);
try {
await auth.init();
} catch (error) {
statusElement.innerHTML = 'Authentifizierung wird durchgeführt...';
const strategy = injector.get(LoginStrategy);
await strategy.login();
}
statusElement.innerHTML = 'App wird initialisiert...';
const state = injector.get(RootStateService);
await state.init();
statusElement.innerHTML = 'Native Container wird initialisiert...';
const nativeContainer = injector.get(NativeContainerService);
await nativeContainer.init();
} catch (error) {
laoderElement.remove();
statusElement.classList.add('text-xl');
statusElement.innerHTML +=
'⚡<br><br><b>Fehler bei der Initialisierung</b><br><br>Bitte prüfen Sie die Netzwerkverbindung (WLAN).<br><br>';
const reload = document.createElement('button');
reload.classList.add(
'bg-brand',
'text-white',
'p-2',
'rounded',
'cursor-pointer',
);
reload.innerHTML = 'App neu laden';
reload.onclick = () => window.location.reload();
statusElement.appendChild(reload);
const preLabel = document.createElement('div');
preLabel.classList.add('mt-12');
preLabel.innerHTML = 'Fehlermeldung:';
statusElement.appendChild(preLabel);
const pre = document.createElement('pre');
pre.classList.add('mt-4', 'text-wrap');
pre.innerHTML = error.message;
statusElement.appendChild(pre);
console.error('Error during app initialization', error);
throw error;
}
};
}
export function _notificationsHubOptionsFactory(
config: Config,
auth: AuthService,
): SignalRHubOptions {
const options = { ...config.get('hubs').notifications };
options.httpOptions.accessTokenFactory = () => auth.getToken();
return options;
}
@NgModule({
declarations: [AppComponent, MainComponent],
bootstrap: [AppComponent],
imports: [
BrowserModule,
BrowserAnimationsModule,
ShellModule.forRoot(),
AppRoutingModule,
AppSwaggerModule,
AppDomainModule,
CoreBreadcrumbModule.forRoot(),
CoreCommandModule.forRoot(Object.values(Commands)),
CoreLoggerModule.forRoot(),
AppStoreModule,
PreviewComponent,
AuthModule.forRoot(),
CoreApplicationModule.forRoot(),
UiModalModule.forRoot(),
UiCommonModule.forRoot(),
NotificationsHubModule.forRoot(),
ServiceWorkerModule.register('ngsw-worker.js', {
enabled: environment.production,
registrationStrategy: 'registerWhenStable:30000',
}),
ScanAdapterModule.forRoot(),
ScanditScanAdapterModule.forRoot(),
PlatformModule,
IconModule.forRoot(),
NgIconsModule.withIcons({ matWifiOff, matClose, matWifi }),
],
providers: [
provideAppInitializer(() => {
const initializerFn = _appInitializerFactory(
inject(Config),
inject(Injector),
);
return initializerFn();
}),
{
provide: NOTIFICATIONS_HUB_OPTIONS,
useFactory: _notificationsHubOptionsFactory,
deps: [Config, AuthService],
},
{
provide: HTTP_INTERCEPTORS,
useClass: HttpErrorInterceptor,
multi: true,
},
{
provide: LOG_PROVIDER,
useClass: IsaLogProvider,
multi: true,
},
{
provide: ErrorHandler,
useClass: IsaErrorHandler,
},
{ provide: LOCALE_ID, useValue: 'de-DE' },
provideHttpClient(withInterceptorsFromDi()),
provideMatomo(
{ trackerUrl: 'https://matomo.paragon-data.net', siteId: '1' },
withRouter(),
withRouteData(),
),
provideLogging(withLogLevel(LogLevel.Debug), withSink(ConsoleLogSink)),
],
})
export class AppModule {}
import {
HTTP_INTERCEPTORS,
provideHttpClient,
withInterceptorsFromDi,
} from '@angular/common/http';
import {
DEFAULT_CURRENCY_CODE,
ErrorHandler,
Injector,
LOCALE_ID,
NgModule,
inject,
provideAppInitializer,
} from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { PlatformModule } from '@angular/cdk/platform';
import { Config } from '@core/config';
import { AuthModule, AuthService, LoginStrategy } from '@core/auth';
import { CoreCommandModule } from '@core/command';
import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component';
import {
ApplicationService,
ApplicationServiceAdapter,
CoreApplicationModule,
} from '@core/application';
import { AppStoreModule } from './app-store.module';
import { ServiceWorkerModule } from '@angular/service-worker';
import { environment } from '../environments/environment';
import { AppSwaggerModule } from './app-swagger.module';
import { AppDomainModule } from './app-domain.module';
import { UiModalModule } from '@ui/modal';
import {
NotificationsHubModule,
NOTIFICATIONS_HUB_OPTIONS,
} from '@hub/notifications';
import { SignalRHubOptions } from '@core/signalr';
import { CoreBreadcrumbModule } from '@core/breadcrumb';
import { UiCommonModule } from '@ui/common';
import { registerLocaleData } from '@angular/common';
import localeDe from '@angular/common/locales/de';
import localeDeExtra from '@angular/common/locales/extra/de';
import { HttpErrorInterceptor } from './interceptors';
import { CoreLoggerModule, LOG_PROVIDER } from '@core/logger';
import { IsaLogProvider } from './providers';
import { IsaErrorHandler } from './providers/isa.error-handler';
import {
ScanAdapterModule,
ScanAdapterService,
ScanditScanAdapterModule,
} from '@adapter/scan';
import { RootStateService } from './store/root-state.service';
import * as Commands from './commands';
import { PreviewComponent } from './preview';
import { NativeContainerService } from '@external/native-container';
import { ShellModule } from '@shared/shell';
import { MainComponent } from './main.component';
import { IconModule } from '@shared/components/icon';
import { NgIconsModule } from '@ng-icons/core';
import {
matClose,
matWifi,
matWifiOff,
} from '@ng-icons/material-icons/baseline';
import { NetworkStatusService } from './services/network-status.service';
import { firstValueFrom } from 'rxjs';
import { provideMatomo } from 'ngx-matomo-client';
import { withRouter, withRouteData } from 'ngx-matomo-client';
import {
provideLogging,
withLogLevel,
LogLevel,
withSink,
ConsoleLogSink,
} from '@isa/core/logging';
import { IDBStorageProvider, UserStorageProvider } from '@isa/core/storage';
registerLocaleData(localeDe, localeDeExtra);
registerLocaleData(localeDe, 'de', localeDeExtra);
export function _appInitializerFactory(config: Config, injector: Injector) {
return async () => {
const statusElement = document.querySelector('#init-status');
const laoderElement = document.querySelector('#init-loader');
try {
let online = false;
const networkStatus = injector.get(NetworkStatusService);
while (!online) {
online = await firstValueFrom(networkStatus.online$);
if (!online) {
statusElement.innerHTML =
'<b>Warte auf Netzwerkverbindung (WLAN)</b><br><br>Bitte prüfen Sie die Netzwerkverbindung (WLAN).<br>Sobald eine Netzwerkverbindung besteht, wird die App automatisch neu geladen.';
await new Promise((resolve) => setTimeout(resolve, 250));
}
}
statusElement.innerHTML = 'Konfigurationen werden geladen...';
statusElement.innerHTML = 'Scanner wird initialisiert...';
const scanAdapter = injector.get(ScanAdapterService);
await scanAdapter.init();
statusElement.innerHTML = 'Authentifizierung wird geprüft...';
const auth = injector.get(AuthService);
try {
await auth.init();
} catch (error) {
statusElement.innerHTML = 'Authentifizierung wird durchgeführt...';
const strategy = injector.get(LoginStrategy);
await strategy.login();
}
statusElement.innerHTML = 'App wird initialisiert...';
const state = injector.get(RootStateService);
await state.init();
statusElement.innerHTML = 'Native Container wird initialisiert...';
const nativeContainer = injector.get(NativeContainerService);
await nativeContainer.init();
statusElement.innerHTML = 'Datenbank wird initialisiert...';
await injector.get(IDBStorageProvider).init();
statusElement.innerHTML = 'Benutzerzustand wird geladen...';
await injector.get(UserStorageProvider).init();
} catch (error) {
laoderElement.remove();
statusElement.classList.add('text-xl');
statusElement.innerHTML +=
'⚡<br><br><b>Fehler bei der Initialisierung</b><br><br>Bitte prüfen Sie die Netzwerkverbindung (WLAN).<br><br>';
const reload = document.createElement('button');
reload.classList.add(
'bg-brand',
'text-white',
'p-2',
'rounded',
'cursor-pointer',
);
reload.innerHTML = 'App neu laden';
reload.onclick = () => window.location.reload();
statusElement.appendChild(reload);
const preLabel = document.createElement('div');
preLabel.classList.add('mt-12');
preLabel.innerHTML = 'Fehlermeldung:';
statusElement.appendChild(preLabel);
const pre = document.createElement('pre');
pre.classList.add('mt-4', 'text-wrap');
pre.innerHTML = error.message;
statusElement.appendChild(pre);
console.error('Error during app initialization', error);
throw error;
}
};
}
export function _notificationsHubOptionsFactory(
config: Config,
auth: AuthService,
): SignalRHubOptions {
const options = { ...config.get('hubs').notifications };
options.httpOptions.accessTokenFactory = () => auth.getToken();
return options;
}
@NgModule({
declarations: [AppComponent, MainComponent],
bootstrap: [AppComponent],
imports: [
BrowserModule,
BrowserAnimationsModule,
ShellModule.forRoot(),
AppRoutingModule,
AppSwaggerModule,
AppDomainModule,
CoreBreadcrumbModule.forRoot(),
CoreCommandModule.forRoot(Object.values(Commands)),
CoreLoggerModule.forRoot(),
AppStoreModule,
PreviewComponent,
AuthModule.forRoot(),
CoreApplicationModule.forRoot(),
UiModalModule.forRoot(),
UiCommonModule.forRoot(),
NotificationsHubModule.forRoot(),
ServiceWorkerModule.register('ngsw-worker.js', {
enabled: environment.production,
registrationStrategy: 'registerWhenStable:30000',
}),
ScanAdapterModule.forRoot(),
ScanditScanAdapterModule.forRoot(),
PlatformModule,
IconModule.forRoot(),
NgIconsModule.withIcons({ matWifiOff, matClose, matWifi }),
],
providers: [
provideAppInitializer(() => {
const initializerFn = _appInitializerFactory(
inject(Config),
inject(Injector),
);
return initializerFn();
}),
{
provide: NOTIFICATIONS_HUB_OPTIONS,
useFactory: _notificationsHubOptionsFactory,
deps: [Config, AuthService],
},
{
provide: HTTP_INTERCEPTORS,
useClass: HttpErrorInterceptor,
multi: true,
},
{
provide: LOG_PROVIDER,
useClass: IsaLogProvider,
multi: true,
},
{
provide: ErrorHandler,
useClass: IsaErrorHandler,
},
{
provide: ApplicationService,
useClass: ApplicationServiceAdapter,
},
{ provide: LOCALE_ID, useValue: 'de-DE' },
provideHttpClient(withInterceptorsFromDi()),
provideMatomo(
{ trackerUrl: 'https://matomo.paragon-data.net', siteId: '1' },
withRouter(),
withRouteData(),
),
provideLogging(withLogLevel(LogLevel.Debug), withSink(ConsoleLogSink)),
{
provide: DEFAULT_CURRENCY_CODE,
useValue: 'EUR',
},
],
})
export class AppModule {}

View File

@@ -1,67 +1,74 @@
import { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router';
import { ApplicationProcess, ApplicationService } from '@core/application';
import { BreadcrumbService } from '@core/breadcrumb';
import { first } from 'rxjs/operators';
@Injectable({ providedIn: 'root' })
export class CanActivateProductWithProcessIdGuard {
constructor(
private readonly _applicationService: ApplicationService,
private readonly _breadcrumbService: BreadcrumbService,
) {}
async canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot) {
const process = await this._applicationService.getProcessById$(+route.params.processId).pipe(first()).toPromise();
// if (!(process?.type === 'cart')) {
// // TODO:
// // Anderer Prozesstyp mit gleicher Id - Was soll gemacht werden?
// return false;
// }
if (!process) {
const processes = await this._applicationService.getProcesses$('customer').pipe(first()).toPromise();
await this._applicationService.createProcess({
id: +route.params.processId,
type: 'cart',
section: 'customer',
name: `Vorgang ${this.processNumber(processes.filter((process) => process.type === 'cart'))}`,
});
}
await this.removeBreadcrumbWithSameProcessId(route);
this._applicationService.activateProcess(+route.params.processId);
return true;
}
// Fix #3292: Alle Breadcrumbs die nichts mit dem aktuellen Prozess zu tun haben, müssen removed werden
async removeBreadcrumbWithSameProcessId(route: ActivatedRouteSnapshot) {
const crumbs = await this._breadcrumbService.getBreadcrumbByKey$(+route.params.processId).pipe(first()).toPromise();
// Entferne alle Crumbs die nichts mit der Artikelsuche zu tun haben
if (crumbs.length > 1) {
const crumbsToRemove = crumbs.filter((crumb) => crumb.tags.find((tag) => tag === 'catalog') === undefined);
for (const crumb of crumbsToRemove) {
await this._breadcrumbService.removeBreadcrumb(crumb.id);
}
}
}
processNumber(processes: ApplicationProcess[]) {
const processNumbers = processes?.map((process) => Number(process?.name?.replace(/\D/g, '')));
return !!processNumbers && processNumbers.length > 0 ? this.findMissingNumber(processNumbers) : 1;
}
findMissingNumber(processNumbers: number[]) {
// Ticket #3272 Bei Klick auf "+" bzw. neuen Prozess hinzufügen soll der neue Tab immer die höchste Nummer haben (wie aktuell im Produktiv)
// ----------------------------------------------------------------------------------------------------------------------------------------
// for (let missingNumber = 1; missingNumber < Math.max(...processNumbers); missingNumber++) {
// if (!processNumbers.find((number) => number === missingNumber)) {
// return missingNumber;
// }
// }
return Math.max(...processNumbers) + 1;
}
}
import { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router';
import { ApplicationProcess, ApplicationService } from '@core/application';
import { BreadcrumbService } from '@core/breadcrumb';
import { first } from 'rxjs/operators';
@Injectable({ providedIn: 'root' })
export class CanActivateProductWithProcessIdGuard {
constructor(
private readonly _applicationService: ApplicationService,
private readonly _breadcrumbService: BreadcrumbService,
) {}
async canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot) {
const processId = +route.params.processId;
const process = await this._applicationService
.getProcessById$(processId)
.pipe(first())
.toPromise();
// if (!(process?.type === 'cart')) {
// // TODO:
// // Anderer Prozesstyp mit gleicher Id - Was soll gemacht werden?
// return false;
// }
if (!process) {
await this._applicationService.createCustomerProcess(processId);
}
await this.removeBreadcrumbWithSameProcessId(route);
this._applicationService.activateProcess(+route.params.processId);
return true;
}
// Fix #3292: Alle Breadcrumbs die nichts mit dem aktuellen Prozess zu tun haben, müssen removed werden
async removeBreadcrumbWithSameProcessId(route: ActivatedRouteSnapshot) {
const crumbs = await this._breadcrumbService
.getBreadcrumbByKey$(+route.params.processId)
.pipe(first())
.toPromise();
// Entferne alle Crumbs die nichts mit der Artikelsuche zu tun haben
if (crumbs.length > 1) {
const crumbsToRemove = crumbs.filter(
(crumb) => crumb.tags.find((tag) => tag === 'catalog') === undefined,
);
for (const crumb of crumbsToRemove) {
await this._breadcrumbService.removeBreadcrumb(crumb.id);
}
}
}
processNumber(processes: ApplicationProcess[]) {
const processNumbers = processes?.map((process) =>
Number(process?.name?.replace(/\D/g, '')),
);
return !!processNumbers && processNumbers.length > 0
? this.findMissingNumber(processNumbers)
: 1;
}
findMissingNumber(processNumbers: number[]) {
// Ticket #3272 Bei Klick auf "+" bzw. neuen Prozess hinzufügen soll der neue Tab immer die höchste Nummer haben (wie aktuell im Produktiv)
// ----------------------------------------------------------------------------------------------------------------------------------------
// for (let missingNumber = 1; missingNumber < Math.max(...processNumbers); missingNumber++) {
// if (!processNumbers.find((number) => number === missingNumber)) {
// return missingNumber;
// }
// }
return Math.max(...processNumbers) + 1;
}
}

View File

@@ -1,7 +1,6 @@
import { Platform, PlatformModule } from '@angular/cdk/platform';
import { CommonModule } from '@angular/common';
import { Component, OnInit } from '@angular/core';
import { BranchSelectorComponent } from '@shared/components/branch-selector';
import { Component } from '@angular/core';
import { BranchDTO } from '@generated/swagger/checkout-api';
import { BehaviorSubject } from 'rxjs';
@@ -9,9 +8,9 @@ import { BehaviorSubject } from 'rxjs';
selector: 'app-preview',
templateUrl: 'preview.component.html',
styleUrls: ['preview.component.css'],
imports: [CommonModule, BranchSelectorComponent, PlatformModule],
imports: [CommonModule, PlatformModule],
})
export class PreviewComponent implements OnInit {
export class PreviewComponent {
selectedBranch$ = new BehaviorSubject<BranchDTO>({});
get appVersion() {
@@ -24,7 +23,7 @@ export class PreviewComponent implements OnInit {
get navigator() {
const nav = {};
for (let i in window.navigator) nav[i] = navigator[i];
for (const i in window.navigator) nav[i] = navigator[i];
return nav;
}
@@ -51,8 +50,6 @@ export class PreviewComponent implements OnInit {
constructor(private readonly _platform: Platform) {}
ngOnInit() {}
setNewBranch(branch: BranchDTO) {
this.selectedBranch$.next(branch);
}

View File

@@ -1,11 +1,12 @@
import { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot } from '@angular/router';
import { Observable } from 'rxjs';
@Injectable({ providedIn: 'root' })
export class ProcessIdResolver {
resolve(route: ActivatedRouteSnapshot): Observable<number> | Promise<number> | number {
return route.params.processId;
}
}
import { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot } from '@angular/router';
import { Observable } from 'rxjs';
@Injectable({ providedIn: 'root' })
export class ProcessIdResolver {
resolve(
route: ActivatedRouteSnapshot,
): Observable<number> | Promise<number> | number {
return route.params.processId;
}
}

View File

@@ -1,129 +1,124 @@
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' })
export class RootStateService {
static LOCAL_STORAGE_KEY = 'ISA_APP_INITIALSTATE';
#storage = injectStorage(UserStorageProvider);
private _cancelSave = new Subject<void>();
constructor(
private readonly _authService: AuthService,
private _logger: Logger,
private _store: Store,
) {
if (!environment.production) {
console.log(
'Die UserState kann in der Konsole mit der Funktion "clearUserState()" geleert werden.',
);
}
window['clearUserState'] = () => {
this.clear();
};
}
async init() {
await this.load();
this._store.dispatch({ type: 'HYDRATE', payload: RootStateService.LoadFromLocalStorage() });
this.initSave();
}
initSave() {
this._store
.select((state) => state)
.pipe(
takeUntil(this._cancelSave),
debounceTime(1000),
switchMap((state) => {
const data = {
...state,
version: packageInfo.version,
sub: this._authService.getClaimByKey('sub'),
};
RootStateService.SaveToLocalStorageRaw(JSON.stringify(data));
return this.#storage.set('state', {
...state,
version: packageInfo.version,
sub: this._authService.getClaimByKey('sub'),
});
}),
)
.subscribe();
}
/**
* Loads the initial state from local storage and returns true/false if state was changed
*/
async load(): Promise<boolean> {
try {
const res = await this.#storage.get('state');
const storageContent = RootStateService.LoadFromLocalStorageRaw();
if (res) {
RootStateService.SaveToLocalStorageRaw(JSON.stringify(res));
}
if (!isEqual(res, storageContent)) {
return true;
}
} catch (error) {
this._logger.log(LogLevel.ERROR, error);
}
return false;
}
async clear() {
try {
this._cancelSave.next();
await this.#storage.clear('state');
await new Promise((resolve) => setTimeout(resolve, 100));
RootStateService.RemoveFromLocalStorage();
await new Promise((resolve) => setTimeout(resolve, 100));
window.location.reload();
} catch (error) {
this._logger.log(LogLevel.ERROR, error);
}
}
static SaveToLocalStorage(state: RootState) {
RootStateService.SaveToLocalStorageRaw(JSON.stringify(state));
}
static SaveToLocalStorageRaw(state: string) {
localStorage.setItem(RootStateService.LOCAL_STORAGE_KEY, state);
}
static LoadFromLocalStorage(): RootState {
const raw = RootStateService.LoadFromLocalStorageRaw();
if (raw) {
try {
return JSON.parse(raw);
} catch (error) {
console.error('Error parsing local storage:', error);
this.RemoveFromLocalStorage();
}
}
return undefined;
}
static LoadFromLocalStorageRaw(): string {
return localStorage.getItem(RootStateService.LOCAL_STORAGE_KEY);
}
static RemoveFromLocalStorage() {
localStorage.removeItem(RootStateService.LOCAL_STORAGE_KEY);
}
}
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' })
export class RootStateService {
static LOCAL_STORAGE_KEY = 'ISA_APP_INITIALSTATE';
#storage = injectStorage(UserStorageProvider);
private _cancelSave = new Subject<void>();
constructor(
private readonly _authService: AuthService,
private _logger: Logger,
private _store: Store,
) {
if (!environment.production) {
console.log(
'Die UserState kann in der Konsole mit der Funktion "clearUserState()" geleert werden.',
);
}
window['clearUserState'] = () => {
this.clear();
};
}
async init() {
await this.load();
this._store.dispatch({
type: 'HYDRATE',
payload: RootStateService.LoadFromLocalStorage(),
});
this.initSave();
}
initSave() {
this._store
.select((state) => state)
.pipe(takeUntil(this._cancelSave), debounceTime(1000))
.subscribe((state) => {
const data = {
...state,
version: packageInfo.version,
sub: this._authService.getClaimByKey('sub'),
};
RootStateService.SaveToLocalStorageRaw(JSON.stringify(data));
return this.#storage.set('state', data);
});
}
/**
* Loads the initial state from local storage and returns true/false if state was changed
*/
async load(): Promise<boolean> {
try {
const res = await this.#storage.get('state');
const storageContent = RootStateService.LoadFromLocalStorageRaw();
if (res) {
RootStateService.SaveToLocalStorageRaw(JSON.stringify(res));
}
if (!isEqual(res, storageContent)) {
return true;
}
} catch (error) {
this._logger.log(LogLevel.ERROR, error);
}
return false;
}
async clear() {
try {
this._cancelSave.next();
await this.#storage.clear('state');
await new Promise((resolve) => setTimeout(resolve, 100));
RootStateService.RemoveFromLocalStorage();
await new Promise((resolve) => setTimeout(resolve, 100));
window.location.reload();
} catch (error) {
this._logger.log(LogLevel.ERROR, error);
}
}
static SaveToLocalStorage(state: RootState) {
RootStateService.SaveToLocalStorageRaw(JSON.stringify(state));
}
static SaveToLocalStorageRaw(state: string) {
localStorage.setItem(RootStateService.LOCAL_STORAGE_KEY, state);
}
static LoadFromLocalStorage(): RootState {
const raw = RootStateService.LoadFromLocalStorageRaw();
if (raw) {
try {
return JSON.parse(raw);
} catch (error) {
console.error('Error parsing local storage:', error);
this.RemoveFromLocalStorage();
}
}
return undefined;
}
static LoadFromLocalStorageRaw(): string {
return localStorage.getItem(RootStateService.LOCAL_STORAGE_KEY);
}
static RemoveFromLocalStorage() {
localStorage.removeItem(RootStateService.LOCAL_STORAGE_KEY);
}
}

View File

@@ -0,0 +1,338 @@
import { inject, Injectable } from '@angular/core';
import { BehaviorSubject, Observable, of, firstValueFrom } from 'rxjs';
import { map, filter, withLatestFrom } from 'rxjs/operators';
import { BranchDTO } from '@generated/swagger/checkout-api';
import { isBoolean, isNumber } from '@utils/common';
import { ApplicationService } from './application.service';
import { TabService } from '@isa/core/tabs';
import { ApplicationProcess } from './defs/application-process';
import { Tab, TabMetadata } from '@isa/core/tabs';
import { toObservable } from '@angular/core/rxjs-interop';
import { Store } from '@ngrx/store';
import { removeProcess } from './store/application.actions';
/**
* Adapter service that bridges the old ApplicationService interface with the new TabService.
*
* This adapter allows existing code that depends on ApplicationService to work with the new
* TabService without requiring immediate code changes. It maps ApplicationProcess concepts
* to Tab entities, storing process-specific data in tab metadata.
*
* Key mappings:
* - ApplicationProcess.id <-> Tab.id
* - ApplicationProcess.name <-> Tab.name
* - ApplicationProcess metadata (section, type, etc.) <-> Tab.metadata with 'process_' prefix
* - ApplicationProcess.data <-> Tab.metadata with 'data_' prefix
*
* @example
* ```typescript
* // Inject the adapter instead of the original service
* constructor(private applicationService: ApplicationServiceAdapter) {}
*
* // Use the same API as before
* const process = await this.applicationService.createCustomerProcess();
* this.applicationService.activateProcess(process.id);
* ```
*/
@Injectable({ providedIn: 'root' })
export class ApplicationServiceAdapter extends ApplicationService {
#store = inject(Store);
#tabService = inject(TabService);
#activatedProcessId$ = toObservable(this.#tabService.activatedTabId);
#tabs$ = toObservable(this.#tabService.entities);
#processes$ = this.#tabs$.pipe(
map((tabs) => tabs.map((tab) => this.mapTabToProcess(tab))),
);
#section = new BehaviorSubject<'customer' | 'branch'>('customer');
readonly REGEX_PROCESS_NAME = /^Vorgang \d+$/;
get activatedProcessId() {
return this.#tabService.activatedTabId();
}
get activatedProcessId$() {
return this.#activatedProcessId$;
}
getProcesses$(
section?: 'customer' | 'branch',
): Observable<ApplicationProcess[]> {
return this.#processes$.pipe(
map((processes) =>
processes.filter((process) =>
section ? process.section === section : true,
),
),
);
}
getProcessById$(processId: number): Observable<ApplicationProcess> {
return this.#processes$.pipe(
map((processes) => processes.find((process) => process.id === processId)),
);
}
getSection$(): Observable<'customer' | 'branch'> {
return this.#section.asObservable();
}
getTitle$(): Observable<'Kundenbereich' | 'Filialbereich'> {
return this.getSection$().pipe(
map((section) =>
section === 'customer' ? 'Kundenbereich' : 'Filialbereich',
),
);
}
/** @deprecated */
getActivatedProcessId$(): Observable<number> {
return this.activatedProcessId$;
}
activateProcess(activatedProcessId: number): void {
this.#tabService.activateTab(activatedProcessId);
}
removeProcess(processId: number): void {
this.#tabService.removeTab(processId);
this.#store.dispatch(removeProcess({ processId }));
}
patchProcess(processId: number, changes: Partial<ApplicationProcess>): void {
const tabChanges: {
name?: string;
tags?: string[];
metadata?: Record<string, unknown>;
} = {};
if (changes.name) {
tabChanges.name = changes.name;
}
// Store other ApplicationProcess properties in metadata
const metadataKeys = [
'section',
'type',
'closeable',
'confirmClosing',
'created',
'activated',
'data',
];
metadataKeys.forEach((key) => {
if (tabChanges.metadata === undefined) {
tabChanges.metadata = {};
}
if (changes[key as keyof ApplicationProcess] !== undefined) {
tabChanges.metadata[`process_${key}`] =
changes[key as keyof ApplicationProcess];
}
});
// Apply the changes to the tab
this.#tabService.patchTab(processId, tabChanges);
}
patchProcessData(processId: number, data: Record<string, unknown>): void {
const currentProcess = this.#tabService.entityMap()[processId];
const currentData: TabMetadata =
(currentProcess?.metadata?.['process_data'] as TabMetadata) ?? {};
this.#tabService.patchTab(processId, {
metadata: { [`process_data`]: { ...currentData, ...data } },
});
}
getSelectedBranch$(): Observable<BranchDTO> {
return this.#processes$.pipe(
withLatestFrom(this.#activatedProcessId$),
map(([processes, activatedProcessId]) =>
processes.find((process) => process.id === activatedProcessId),
),
filter((process): process is ApplicationProcess => !!process),
map((process) => process.data?.selectedBranch as BranchDTO),
);
}
async createCustomerProcess(processId?: number): Promise<ApplicationProcess> {
const processes = await firstValueFrom(this.getProcesses$('customer'));
const processIds = processes
.filter((x) => this.REGEX_PROCESS_NAME.test(x.name))
.map((x) => +x.name.split(' ')[1]);
const maxId = processIds.length > 0 ? Math.max(...processIds) : 0;
const process: ApplicationProcess = {
id: processId ?? Date.now(),
type: 'cart',
name: `Vorgang ${maxId + 1}`,
section: 'customer',
closeable: true,
};
await this.createProcess(process);
return process;
}
/**
* Creates a new ApplicationProcess by first creating a Tab and then storing
* process-specific properties in the tab's metadata.
*
* @param process - The ApplicationProcess to create
* @throws {Error} If process ID already exists or is invalid
*/
async createProcess(process: ApplicationProcess): Promise<void> {
const existingProcess = this.#tabService.entityMap()[process.id];
if (existingProcess?.id === process?.id) {
throw new Error('Process Id existiert bereits');
}
if (!isNumber(process.id)) {
throw new Error('Process Id nicht gesetzt');
}
if (!isBoolean(process.closeable)) {
process.closeable = true;
}
if (!isBoolean(process.confirmClosing)) {
process.confirmClosing = true;
}
process.created = this.createTimestamp();
process.activated = 0;
// Create tab with process data and preserve the process ID
this.#tabService.addTab({
id: process.id,
name: process.name,
tags: [process.section, process.type].filter(Boolean),
metadata: {
process_section: process.section,
process_type: process.type,
process_closeable: process.closeable,
process_confirmClosing: process.confirmClosing,
process_created: process.created,
process_activated: process.activated,
process_data: process.data,
},
});
}
setSection(section: 'customer' | 'branch'): void {
this.#section.next(section);
}
getLastActivatedProcessWithSectionAndType$(
section: 'customer' | 'branch',
type: string,
): Observable<ApplicationProcess> {
return this.getProcesses$(section).pipe(
map((processes) =>
processes
?.filter((process) => process.type === type)
?.reduce((latest, current) => {
if (!latest) {
return current;
}
return latest?.activated > current?.activated ? latest : current;
}, undefined),
),
);
}
getLastActivatedProcessWithSection$(
section: 'customer' | 'branch',
): Observable<ApplicationProcess> {
return this.getProcesses$(section).pipe(
map((processes) =>
processes?.reduce((latest, current) => {
if (!latest) {
return current;
}
return latest?.activated > current?.activated ? latest : current;
}, undefined),
),
);
}
/**
* Maps Tab entities to ApplicationProcess objects by extracting process-specific
* metadata and combining it with tab properties.
*
* @param tab - The tab entity to convert
* @returns The corresponding ApplicationProcess object
*/
private mapTabToProcess(tab: Tab): ApplicationProcess {
return {
id: tab.id,
name: tab.name,
created:
this.getMetadataValue<number>(tab.metadata, 'process_created') ??
tab.createdAt,
activated:
this.getMetadataValue<number>(tab.metadata, 'process_activated') ??
tab.activatedAt ??
0,
section:
this.getMetadataValue<'customer' | 'branch'>(
tab.metadata,
'process_section',
) ?? 'customer',
type: this.getMetadataValue<string>(tab.metadata, 'process_type'),
closeable:
this.getMetadataValue<boolean>(tab.metadata, 'process_closeable') ??
true,
confirmClosing:
this.getMetadataValue<boolean>(
tab.metadata,
'process_confirmClosing',
) ?? true,
data: this.extractDataFromMetadata(tab.metadata),
};
}
/**
* Extracts ApplicationProcess data properties from tab metadata.
* Data properties are stored with a 'data_' prefix in tab metadata.
*
* @param metadata - The tab metadata object
* @returns The extracted data object or undefined if no data properties exist
*/
private extractDataFromMetadata(
metadata: TabMetadata,
): Record<string, unknown> | undefined {
// Return the complete data object stored under 'process_data'
const processData = metadata?.['process_data'];
if (
processData &&
typeof processData === 'object' &&
processData !== null
) {
return processData as Record<string, unknown>;
}
return undefined;
}
private getMetadataValue<T>(
metadata: TabMetadata,
key: string,
): T | undefined {
return metadata?.[key] as T | undefined;
}
private createTimestamp(): number {
return Date.now();
}
}

View File

@@ -1,4 +1,5 @@
export * from './application.module';
export * from './application.service';
export * from './application.service-adapter';
export * from './defs';
export * from './store';

View File

@@ -1,5 +1,9 @@
import { Injectable } from '@angular/core';
import { ItemDTO, ListResponseArgsOfItemDTO, SearchService } from '@generated/swagger/cat-search-api';
import {
ItemDTO,
ListResponseArgsOfItemDTO,
SearchService,
} from '@generated/swagger/cat-search-api';
import {
RemiService,
StockService,
@@ -18,7 +22,11 @@ import { memorize } from '@utils/common';
import { Observable, of, throwError } from 'rxjs';
import { catchError, map, shareReplay, switchMap } from 'rxjs/operators';
import { RemissionListItem } from './defs';
import { fromItemDto, mapFromReturnItemDTO, mapFromReturnSuggestionDTO } from './mappings';
import {
fromItemDto,
mapFromReturnItemDTO,
mapFromReturnSuggestionDTO,
} from './mappings';
import { Logger } from '@core/logger';
import { RemissionPlacementType } from '@domain/remission';
@@ -204,7 +212,10 @@ export class DomainRemissionService {
);
}
getStockInformation(items: RemissionListItem[], recalculate: boolean = false) {
getStockInformation(
items: RemissionListItem[],
recalculate: boolean = false,
) {
return this.getCurrentStock().pipe(
switchMap((stock) =>
this._stockService
@@ -218,7 +229,8 @@ export class DomainRemissionService {
map((res) => {
const o = items.map((item) => {
const stockInfo = res?.result?.find(
(stockInfo) => stockInfo.itemId === +item.dto.product.catalogProductNumber,
(stockInfo) =>
stockInfo.itemId === +item.dto.product.catalogProductNumber,
);
if (!stockInfo) {
@@ -231,7 +243,8 @@ export class DomainRemissionService {
return { ...item, ...defaultStockData };
}
const availableStock = stockInfo.inStock - stockInfo.removedFromStock;
const availableStock =
stockInfo.inStock - stockInfo.removedFromStock;
const inStock = availableStock < 0 ? 0 : availableStock;
let { remainingQuantity, remissionQuantity } = item;
@@ -249,7 +262,12 @@ export class DomainRemissionService {
}
}
return { ...item, remainingQuantity, remissionQuantity, inStock };
return {
...item,
remainingQuantity,
remissionQuantity,
inStock,
};
});
return o;
@@ -259,7 +277,10 @@ export class DomainRemissionService {
);
}
getRequiredCapacities(params: { departments?: string[]; supplierId: number }) {
getRequiredCapacities(params: {
departments?: string[];
supplierId: number;
}) {
return this.getCurrentStock().pipe(
switchMap((stock) =>
this._remiService
@@ -301,13 +322,18 @@ export class DomainRemissionService {
);
}
canAddReturnItem(item: ReturnItemDTO): Observable<BatchResponseArgsOfReturnItemDTOAndReturnItemDTO> {
canAddReturnItem(
item: ReturnItemDTO,
): Observable<BatchResponseArgsOfReturnItemDTOAndReturnItemDTO> {
return this._remiService.RemiCanAddReturnItem({
data: [item],
});
}
async createReturn(supplierId: number, returnGroup?: string): Promise<ReturnDTO> {
async createReturn(
supplierId: number,
returnGroup?: string,
): Promise<ReturnDTO> {
const response = await this._returnService
.ReturnCreateReturn({
data: {
@@ -343,7 +369,10 @@ export class DomainRemissionService {
.toPromise();
}
getReturns(params: { start?: Date; returncompleted: boolean }): Observable<ReturnDTO[]> {
getReturns(params: {
start?: Date;
returncompleted: boolean;
}): Observable<ReturnDTO[]> {
const queryToken: ReturnQueryTokenDTO = {
start: params.start?.toISOString(),
filter: {
@@ -360,13 +389,20 @@ export class DomainRemissionService {
});
return this.getCurrentStock().pipe(
switchMap((stock) => this._returnService.ReturnQueryReturns({ stockId: stock.id, queryToken })),
switchMap((stock) =>
this._returnService.ReturnQueryReturns({
stockId: stock.id,
queryToken,
}),
),
map((res) => res.result),
);
}
getReturn(returnId: number): Observable<ReturnDTO> {
return this._returnService.ReturnGetReturn({ returnId, eagerLoading: 3 }).pipe(map((res) => res.result));
return this._returnService
.ReturnGetReturn({ returnId, eagerLoading: 3 })
.pipe(map((res) => res.result));
}
async deleteReturn(returnId: number) {
@@ -393,7 +429,11 @@ export class DomainRemissionService {
inStock: number;
}) {
return this._returnService
.ReturnAddReturnItem({ returnId, receiptId, data: { returnItemId, quantity, placementType, inStock } })
.ReturnAddReturnItem({
returnId,
receiptId,
data: { returnItemId, quantity, placementType, inStock },
})
.pipe(map((r) => r.result));
}
@@ -420,7 +460,14 @@ export class DomainRemissionService {
.ReturnAddReturnSuggestion({
returnId,
receiptId,
data: { returnSuggestionId, quantity, placementType, inStock, impedimentComment, remainingQuantity },
data: {
returnSuggestionId,
quantity,
placementType,
inStock,
impedimentComment,
remainingQuantity,
},
})
.pipe(map((r) => r.result));
}
@@ -438,18 +485,28 @@ export class DomainRemissionService {
receiptId: number;
receiptItemId: number;
}) {
return this._returnService.ReturnRemoveReturnItem({ returnId, receiptItemId, receiptId });
return this._returnService.ReturnRemoveReturnItem({
returnId,
receiptItemId,
receiptId,
});
}
returnImpediment(itemId: number) {
return this._returnService
.ReturnReturnItemImpediment({ itemId, data: { comment: 'Produkt nicht gefunden' } })
.ReturnReturnItemImpediment({
itemId,
data: { comment: 'Produkt nicht gefunden' },
})
.pipe(map((r) => r.result));
}
returnSuggestion(itemId: number) {
return this._returnService
.ReturnReturnSuggestionImpediment({ itemId, data: { comment: 'Produkt nicht gefunden' } })
.ReturnReturnSuggestionImpediment({
itemId,
data: { comment: 'Produkt nicht gefunden' },
})
.pipe(map((r) => r.result));
}
@@ -459,7 +516,10 @@ export class DomainRemissionService {
* @param receiptNumber Receipt number
* @returns ReceiptDTO
*/
async createReceipt(returnDTO: ReturnDTO, receiptNumber?: string): Promise<ReceiptDTO> {
async createReceipt(
returnDTO: ReturnDTO,
receiptNumber?: string,
): Promise<ReceiptDTO> {
const stock = await this._getStock();
const response = await this._returnService
@@ -510,7 +570,10 @@ export class DomainRemissionService {
return receipt;
}
async completeReceipt(returnId: number, receiptId: number): Promise<ReceiptDTO> {
async completeReceipt(
returnId: number,
receiptId: number,
): Promise<ReceiptDTO> {
const res = await this._returnService
.ReturnFinalizeReceipt({
returnId,

View File

@@ -3,10 +3,12 @@
<p>Vorschläge:</p>
<ul class="content">
<li *ngFor="let item of ref?.data">
<span>{{ item.street }} {{ item.streetNumber }}, {{ item.zipCode }} {{ item.city }}</span>
<button (click)="ref.close(item)">Übernehmen</button>
</li>
@for (item of ref?.data; track item) {
<li>
<span>{{ item.street }} {{ item.streetNumber }}, {{ item.zipCode }} {{ item.city }}</span>
<button (click)="ref.close(item)">Übernehmen</button>
</li>
}
</ul>
<div class="center">

View File

@@ -6,13 +6,19 @@
uiSearchboxSearchButton
(click)="filter(input.value)"
[disabled]="branchesFetching$ | async"
>
<ui-icon class="spin" *ngIf="branchesFetching$ | async" icon="spinner" size="32px"></ui-icon>
<ui-icon *ngIf="!(branchesFetching$ | async)" icon="search" size="24px"></ui-icon>
</button>
<button *ngIf="input.value" type="reset" uiSearchboxClearButton (click)="filter(''); cancelSearch(); input.value = ''">
<ui-icon icon="close" size="22px"></ui-icon>
>
@if (branchesFetching$ | async) {
<ui-icon class="spin" icon="spinner" size="32px"></ui-icon>
}
@if (!(branchesFetching$ | async)) {
<ui-icon icon="search" size="24px"></ui-icon>
}
</button>
@if (input.value) {
<button type="reset" uiSearchboxClearButton (click)="filter(''); cancelSearch(); input.value = ''">
<ui-icon icon="close" size="22px"></ui-icon>
</button>
}
</ui-searchbox>
<p class="subtitle">
@@ -25,7 +31,7 @@
<hr />
<div class="branches">
<ng-container *ngFor="let branch of filteredBranches$ | async">
@for (branch of filteredBranches$ | async; track branch) {
<div class="branch">
<div class="branch-info">
<span class="branch-name">
@@ -36,25 +42,23 @@
{{ branch.address.city }}
</span>
</div>
<div class="branch-actions">
<button
*ngIf="(branch.id | stockInfo: (inStock$ | async))?.availableQuantity > 0 && branch?.isShippingEnabled"
class="cta-reserve"
(click)="reserve(branch)"
>
Reservieren
</button>
@if ((branch.id | stockInfo: (inStock$ | async))?.availableQuantity > 0 && branch?.isShippingEnabled) {
<button
class="cta-reserve"
(click)="reserve(branch)"
>
Reservieren
</button>
}
<ui-spinner [show]="stockFetching$ | async">
<span class="branch-stock">
<ui-icon icon="home" size="22px"></ui-icon>
<span>{{ branch.id | inStock: (inStock$ | async) }}x</span>
</span>
</ui-spinner>
</div>
</div>
<hr />
</ng-container>
}
</div>

View File

@@ -3,9 +3,11 @@
</div>
<div class="thumbnails-wrapper">
<button *ngFor="let image of images" (click)="activeImage = image" [class.selected]="activeImage.url === image.url">
<img class="thumbnail" [src]="image.thumbUrl" />
</button>
@for (image of images; track image) {
<button (click)="activeImage = image" [class.selected]="activeImage.url === image.url">
<img class="thumbnail" [src]="image.thumbUrl" />
</button>
}
</div>
<div class="controls">

View File

@@ -18,9 +18,11 @@
<ui-quantity-dropdown [quantity]="itemQuantity$ | async" (quantityChange)="onQuantityChange($event)"></ui-quantity-dropdown>
</div>
<div class="relative">
<div *ngIf="stockWarning$ | async" class="text-warning font-bold absolute right-0 top-0 whitespace-nowrap">
Es befinden sich {{ availableQuantity$ | async }} Exemplare in der Filiale
</div>
@if (stockWarning$ | async) {
<div class="text-warning font-bold absolute right-0 top-0 whitespace-nowrap">
Es befinden sich {{ availableQuantity$ | async }} Exemplare in der Filiale
</div>
}
</div>
</div>
</div>

View File

@@ -20,18 +20,21 @@
</div>
</div>
<div class="overflow-y-auto -mx-4 scroll-bar">
<div *ngIf="emptyShoppingCart$ | async" class="h-full grid items-center justify-center">
<h3 class="text-xl font-bold text-center text-gray-500">
Warenkorb ist leer, bitte suchen oder scannen
<br />
Sie Artikel um den Warenkob zu füllen.
</h3>
</div>
<shared-kulturpass-order-item
class="border-b border-solid border-[#EFF1F5]"
*ngFor="let item of items$ | async; trackBy: trackItemById"
[item]="item"
></shared-kulturpass-order-item>
@if (emptyShoppingCart$ | async) {
<div class="h-full grid items-center justify-center">
<h3 class="text-xl font-bold text-center text-gray-500">
Warenkorb ist leer, bitte suchen oder scannen
<br />
Sie Artikel um den Warenkob zu füllen.
</h3>
</div>
}
@for (item of items$ | async; track trackItemById($index, item)) {
<shared-kulturpass-order-item
class="border-b border-solid border-[#EFF1F5]"
[item]="item"
></shared-kulturpass-order-item>
}
</div>
<div class="flex flex-row justify-evenly items-stretch border-t border-solid border-[#EFF1F5] py-3 px-4 -mx-4">
<div class="grid grid-flow-row text-xl">
@@ -49,13 +52,15 @@
</div>
</div>
<div class="grid items-end justify-between">
<div *ngIf="negativeBalance$ | async" class="text-xl text-warning font-bold text-center">Der Betrag übersteigt ihr Guthaben</div>
@if (negativeBalance$ | async) {
<div class="text-xl text-warning font-bold text-center">Der Betrag übersteigt ihr Guthaben</div>
}
<button
type="button"
class="bg-brand text-white px-6 py-3 font-bold rounded-full disabled:bg-disabled-branch disabled:text-active-branch"
[disabled]="orderButtonDisabled$ | async"
(click)="order()"
>
>
<shared-loader [loading]="ordering$ | async" hideContent="true">Kauf abschließen und Rechnung drucken</shared-loader>
</button>
</div>

View File

@@ -12,7 +12,13 @@ import {
ShoppingCartItemDTO,
} from '@generated/swagger/checkout-api';
import { DomainCheckoutService } from '@domain/checkout';
import { catchError, mergeMap, switchMap, tap, withLatestFrom } from 'rxjs/operators';
import {
catchError,
mergeMap,
switchMap,
tap,
withLatestFrom,
} from 'rxjs/operators';
import {
BranchService,
DisplayOrderDTO,
@@ -40,7 +46,10 @@ export interface KulturpassOrderModalState {
}
@Injectable()
export class KulturpassOrderModalStore extends ComponentStore<KulturpassOrderModalState> implements OnStoreInit {
export class KulturpassOrderModalStore
extends ComponentStore<KulturpassOrderModalState>
implements OnStoreInit
{
private _checkoutService = inject(DomainCheckoutService);
private _branchService = inject(BranchService);
private _authService = inject(AuthService);
@@ -87,23 +96,33 @@ export class KulturpassOrderModalStore extends ComponentStore<KulturpassOrderMod
readonly order$ = this.select((state) => state.order);
readonly updateCheckout = this.updater((state, checkout: CheckoutDTO) => ({ ...state, checkout }));
readonly updateCheckout = this.updater((state, checkout: CheckoutDTO) => ({
...state,
checkout,
}));
readonly updateOrder = this.updater((state, order: OrderDTO) => ({ ...state, order }));
readonly updateOrder = this.updater((state, order: OrderDTO) => ({
...state,
order,
}));
readonly fetchShoppingCart$ = this.select((state) => state.fetchShoppingCart);
readonly updateFetchShoppingCart = this.updater((state, fetchShoppingCart: boolean) => ({
...state,
fetchShoppingCart,
}));
readonly updateFetchShoppingCart = this.updater(
(state, fetchShoppingCart: boolean) => ({
...state,
fetchShoppingCart,
}),
);
readonly ordering$ = this.select((state) => state.ordering);
loadBranch = this.effect(($) =>
$.pipe(
switchMap(() =>
this._branchService.BranchGetBranches({}).pipe(tapResponse(this.handleBranchResponse, this.handleBranchError)),
this._branchService
.BranchGetBranches({})
.pipe(tapResponse(this.handleBranchResponse, this.handleBranchError)),
),
),
);
@@ -111,31 +130,45 @@ export class KulturpassOrderModalStore extends ComponentStore<KulturpassOrderMod
handleBranchResponse = (res: ResponseArgsOfIEnumerableOfBranchDTO) => {
const branchNumber = this._authService.getClaimByKey('branch_no');
this.patchState({ branch: res.result.find((b) => b.branchNumber === branchNumber) });
this.patchState({
branch: res.result.find((b) => b.branchNumber === branchNumber),
});
};
handleBranchError = (err) => {
this._modal.error('Fehler beim Laden der Filiale', err);
};
createShoppingCart = this.effect((orderItemListItem$: Observable<OrderItemListItemDTO>) =>
orderItemListItem$.pipe(
tap((orderItemListItem) => {
this.patchState({ orderItemListItem });
this.updateFetchShoppingCart(true);
}),
switchMap((orderItemListItem) =>
this._checkoutService
.getShoppingCart({ processId: this.processId })
.pipe(tapResponse(this.handleCreateShoppingCartResponse, this.handleCreateShoppingCartError)),
createShoppingCart = this.effect(
(orderItemListItem$: Observable<OrderItemListItemDTO>) =>
orderItemListItem$.pipe(
tap((orderItemListItem) => {
this.patchState({ orderItemListItem });
this.updateFetchShoppingCart(true);
}),
switchMap((orderItemListItem) =>
this._checkoutService
.getShoppingCart({ processId: this.processId })
.pipe(
tapResponse(
this.handleCreateShoppingCartResponse,
this.handleCreateShoppingCartError,
),
),
),
),
),
);
handleCreateShoppingCartResponse = (res: ShoppingCartDTO) => {
this.patchState({ shoppingCart: res });
this._checkoutService.setBuyer({ processId: this.processId, buyer: this.order.buyer });
this._checkoutService.setPayer({ processId: this.processId, payer: this.order.billing?.data });
this._checkoutService.setBuyer({
processId: this.processId,
buyer: this.order.buyer,
});
this._checkoutService.setPayer({
processId: this.processId,
payer: this.order.billing?.data,
});
this.updateFetchShoppingCart(false);
};
@@ -154,7 +187,9 @@ export class KulturpassOrderModalStore extends ComponentStore<KulturpassOrderMod
processId: this.processId,
items: [add],
})
.pipe(tapResponse(this.handleAddItemResponse, this.handleAddItemError)),
.pipe(
tapResponse(this.handleAddItemResponse, this.handleAddItemError),
),
),
),
);
@@ -174,7 +209,12 @@ export class KulturpassOrderModalStore extends ComponentStore<KulturpassOrderMod
shoppingCartItemId: change.id,
update: { quantity: change.quantity },
})
.pipe(tapResponse(this.handleQuantityChangeResponse, this.handleQuantityChangeError)),
.pipe(
tapResponse(
this.handleQuantityChangeResponse,
this.handleQuantityChangeError,
),
),
),
),
);
@@ -206,7 +246,10 @@ export class KulturpassOrderModalStore extends ComponentStore<KulturpassOrderMod
this.onOrderSuccess(res.result.item1[0], res.result.item2);
};
onOrderSuccess = (displayOrder: DisplayOrderDTO, action: KeyValueDTOOfStringAndString[]) => {};
onOrderSuccess = (
displayOrder: DisplayOrderDTO,
action: KeyValueDTOOfStringAndString[],
) => {};
handleOrderError = (err: any) => {
this._modal.error('Fehler beim Bestellen', err);
@@ -215,8 +258,9 @@ export class KulturpassOrderModalStore extends ComponentStore<KulturpassOrderMod
itemQuantityByCatalogProductNumber(catalogProductNumber: string) {
return (
this.shoppingCart?.items?.find((i) => getCatalogProductNumber(i?.data) === catalogProductNumber)?.data
?.quantity ?? 0
this.shoppingCart?.items?.find(
(i) => getCatalogProductNumber(i?.data) === catalogProductNumber,
)?.data?.quantity ?? 0
);
}
@@ -227,7 +271,11 @@ export class KulturpassOrderModalStore extends ComponentStore<KulturpassOrderMod
.canAddItemsKulturpass([item?.product])
.pipe(
tapResponse(
(results) => this.handleCanAddItemResponse({ item, result: results?.find((_) => true) }),
(results) =>
this.handleCanAddItemResponse({
item,
result: results?.find((_) => true),
}),
this.handleCanAddItemError,
),
),
@@ -235,14 +283,23 @@ export class KulturpassOrderModalStore extends ComponentStore<KulturpassOrderMod
),
);
handleCanAddItemResponse = ({ item, result }: { item: ItemDTO; result: KulturPassResult }) => {
handleCanAddItemResponse = ({
item,
result,
}: {
item: ItemDTO;
result: KulturPassResult;
}) => {
if (result?.canAdd) {
this.addItemToShoppingCart(item);
} else {
this._modal.open({
content: UiMessageModalComponent,
title: 'Artikel nicht förderfähig',
data: { message: result?.message, closeAction: 'ohne Artikel fortfahren' },
data: {
message: result?.message,
closeAction: 'ohne Artikel fortfahren',
},
});
}
};
@@ -254,14 +311,18 @@ export class KulturpassOrderModalStore extends ComponentStore<KulturpassOrderMod
addItemToShoppingCart = this.effect((item$: Observable<ItemDTO>) =>
item$.pipe(
mergeMap((item) => {
const takeAwayAvailability$ = this._availabilityService.getTakeAwayAvailability({
item: {
ean: item.product.ean,
itemId: item.id,
price: item.catalogAvailability.price,
},
quantity: this.itemQuantityByCatalogProductNumber(getCatalogProductNumber(item)) + 1,
});
const takeAwayAvailability$ =
this._availabilityService.getTakeAwayAvailability({
item: {
ean: item.product.ean,
itemId: item.id,
price: item.catalogAvailability.price,
},
quantity:
this.itemQuantityByCatalogProductNumber(
getCatalogProductNumber(item),
) + 1,
});
const deliveryAvailability$ = this._availabilityService
.getDeliveryAvailability({
@@ -270,7 +331,10 @@ export class KulturpassOrderModalStore extends ComponentStore<KulturpassOrderMod
itemId: item.id,
price: item.catalogAvailability.price,
},
quantity: this.itemQuantityByCatalogProductNumber(getCatalogProductNumber(item)) + 1,
quantity:
this.itemQuantityByCatalogProductNumber(
getCatalogProductNumber(item),
) + 1,
})
.pipe(
catchError((err) => {
@@ -279,7 +343,10 @@ export class KulturpassOrderModalStore extends ComponentStore<KulturpassOrderMod
);
return zip(takeAwayAvailability$, deliveryAvailability$).pipe(
tapResponse(this.handleAddItemToShoppingCartResponse2(item), this.handleAddItemToShoppingCartError),
tapResponse(
this.handleAddItemToShoppingCartResponse2(item),
this.handleAddItemToShoppingCartError,
),
);
}),
),
@@ -287,20 +354,27 @@ export class KulturpassOrderModalStore extends ComponentStore<KulturpassOrderMod
handleAddItemToShoppingCartResponse2 =
(item: ItemDTO) =>
([takeAwayAvailability, deliveryAvailability]: [AvailabilityDTO, AvailabilityDTO]) => {
([takeAwayAvailability, deliveryAvailability]: [
AvailabilityDTO,
AvailabilityDTO,
]) => {
let isPriceMaintained = item?.catalogAvailability?.priceMaintained;
let onlinePrice = -1;
if (deliveryAvailability) {
isPriceMaintained = isPriceMaintained ?? deliveryAvailability['priceMaintained'] ?? false;
isPriceMaintained =
isPriceMaintained ?? deliveryAvailability['priceMaintained'] ?? false;
onlinePrice = deliveryAvailability?.price?.value?.value ?? -1;
}
// Preis und priceMaintained werden immer erst vom Katalog genommen. Bei nicht Verfügbarkeit greifen die anderen Availabilities
const offlinePrice =
item?.catalogAvailability?.price?.value?.value ?? takeAwayAvailability?.price?.value?.value ?? -1;
item?.catalogAvailability?.price?.value?.value ??
takeAwayAvailability?.price?.value?.value ??
-1;
const availability = takeAwayAvailability;
availability.price = item?.catalogAvailability?.price ?? takeAwayAvailability?.price;
availability.price =
item?.catalogAvailability?.price ?? takeAwayAvailability?.price;
/**
* Onlinepreis ist niedliger als der Offlinepreis
@@ -314,7 +388,11 @@ export class KulturpassOrderModalStore extends ComponentStore<KulturpassOrderMod
* wenn der Artikel Preisgebunden ist, wird der Ladenpreis verwendet
*/
if (!!deliveryAvailability && onlinePrice < offlinePrice && !isPriceMaintained) {
if (
!!deliveryAvailability &&
onlinePrice < offlinePrice &&
!isPriceMaintained
) {
availability.price = deliveryAvailability.price;
}
@@ -333,10 +411,13 @@ export class KulturpassOrderModalStore extends ComponentStore<KulturpassOrderMod
},
},
promotion: {
points: 0,
value: 0,
},
itemType: item.type,
product: { catalogProductNumber: getCatalogProductNumber(item), ...item.product },
product: {
catalogProductNumber: getCatalogProductNumber(item),
...item.product,
},
};
this.addItem(addToShoppingCartDTO);
@@ -346,15 +427,28 @@ export class KulturpassOrderModalStore extends ComponentStore<KulturpassOrderMod
this._modal.error('Fehler beim Hinzufügen des Artikels', err);
};
setAvailability = this.updater((state, data: { catalogProductNumber: string; availability: AvailabilityDTO }) => {
return { ...state, availabilities: { ...state.availabilities, [data.catalogProductNumber]: data.availability } };
});
setAvailability = this.updater(
(
state,
data: { catalogProductNumber: string; availability: AvailabilityDTO },
) => {
return {
...state,
availabilities: {
...state.availabilities,
[data.catalogProductNumber]: data.availability,
},
};
},
);
getAvailability(catalogProductNumber: string): AvailabilityDTO | undefined {
return this.get((state) => state.availabilities[catalogProductNumber]);
}
getAvailability$(catalogProductNumber: string): Observable<AvailabilityDTO | undefined> {
getAvailability$(
catalogProductNumber: string,
): Observable<AvailabilityDTO | undefined> {
return this.select((state) => state.availabilities[catalogProductNumber]);
}
}

View File

@@ -2,19 +2,20 @@
{{ message }}
</p>
<div class="message-modal-actions grid grid-flow-col gap-4 items-center justify-center">
<button
*ngFor="let action of actions"
type="button"
class="btn rounded-full font-bold text-p1 border-[2px] border-solid border-brand px-6"
[class.bg-brand]="action.primary"
[class.hover:bg-brand]="action.primary"
[class.text-white]="action.primary"
[class.bg-white]="!action.primary"
[class.text-brand]="!action.primary"
[class.hover:bg-white]="!action.primary"
[class.hover:text-brand]="!action.primary"
(click)="clickAction(action)"
>
{{ action.label }}
</button>
@for (action of actions; track action) {
<button
type="button"
class="btn rounded-full font-bold text-p1 border-[2px] border-solid border-brand px-6"
[class.bg-brand]="action.primary"
[class.hover:bg-brand]="action.primary"
[class.text-white]="action.primary"
[class.bg-white]="!action.primary"
[class.text-brand]="!action.primary"
[class.hover:bg-white]="!action.primary"
[class.hover:text-brand]="!action.primary"
(click)="clickAction(action)"
>
{{ action.label }}
</button>
}
</div>

View File

@@ -1,4 +1,4 @@
import { CommonModule } from '@angular/common';
import { Component, ChangeDetectionStrategy } from '@angular/core';
import { UiModalRef } from '@ui/modal';
import { MessageModalData } from './message-modal.data';
@@ -10,7 +10,7 @@ import { MessageModalAction } from './message-modal.action';
styleUrls: ['message-modal.component.css'],
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'shared-message-modal' },
imports: [CommonModule],
imports: [],
})
export class MessageModalComponent {
get message() {

View File

@@ -4,8 +4,10 @@
<div class="notification-text">{{ item.text }}</div>
</div>
<div>
<button *ngIf="editButton" class="notification-edit-cta text-brand font-bold text-lg px-4 py-3" (click)="itemSelected.emit(item)">
{{ editButtonLabel }}
</button>
@if (editButton) {
<button class="notification-edit-cta text-brand font-bold text-lg px-4 py-3" (click)="itemSelected.emit(item)">
{{ editButtonLabel }}
</button>
}
</div>
</div>

View File

@@ -1,5 +1,5 @@
<div class="notification-list scroll-bar">
<ng-container *ngFor="let notification of notifications">
@for (notification of notifications; track notification) {
<modal-notifications-list-item
(click)="itemSelected(notification)"
[editButtonLabel]="'Packstück-Prüfung'"
@@ -7,5 +7,5 @@
(itemSelected)="itemSelected($event)"
></modal-notifications-list-item>
<hr />
</ng-container>
}
</div>

View File

@@ -1,8 +1,8 @@
<div class="notification-list scroll-bar">
<ng-container *ngFor="let notification of notifications">
@for (notification of notifications; track notification) {
<modal-notifications-list-item [item]="notification" (itemSelected)="itemSelected($event)"></modal-notifications-list-item>
<hr />
</ng-container>
}
</div>
<div class="actions">

View File

@@ -1,8 +1,8 @@
<div class="notification-list scroll-bar">
<ng-container *ngFor="let notification of notifications">
@for (notification of notifications; track notification) {
<modal-notifications-list-item [item]="notification" (itemSelected)="itemSelected($event)"></modal-notifications-list-item>
<hr />
</ng-container>
}
</div>
<div class="actions">

View File

@@ -1,8 +1,8 @@
<div class="notification-list scroll-bar">
<ng-container *ngFor="let notification of notifications">
@for (notification of notifications; track notification) {
<modal-notifications-list-item [item]="notification" (itemSelected)="itemSelected($event)"></modal-notifications-list-item>
<hr />
</ng-container>
}
</div>
<div class="actions">

View File

@@ -1,11 +1,11 @@
<div class="notification-list scroll-bar">
<ng-container *ngFor="let notification of notifications">
@for (notification of notifications; track notification) {
<div class="notification-headline">
<h1>{{ notification.headline }}</h1>
</div>
<div class="notification-text">{{ notification.text }}</div>
<hr />
</ng-container>
}
</div>
<div class="actions">

View File

@@ -1,6 +1,6 @@
<h1>Sie haben neue Nachrichten</h1>
<ng-container *ngFor="let notification of notifications$ | async | keyvalue">
@for (notification of notifications$ | async | keyvalue; track notification) {
<button type="button" class="notification-card" (click)="selectArea(notification.key)">
<div class="notification-icon">
<div class="notification-counter">{{ notification.value?.length }}</div>
@@ -9,30 +9,37 @@
<span>{{ notification.value?.[0]?.category }}</span>
</button>
<hr class="-mx-4" />
<ng-container *ngIf="notification.key === selectedArea" [ngSwitch]="notification.value?.[0]?.category">
<modal-notifications-update-group
*ngSwitchCase="'ISA-Update'"
[notifications]="notifications[selectedArea]"
></modal-notifications-update-group>
<modal-notifications-reservation-group
*ngSwitchCase="'Reservierungsanfragen'"
[notifications]="notifications[selectedArea]"
(navigated)="close()"
></modal-notifications-reservation-group>
<modal-notifications-remission-group
*ngSwitchCase="'Remission'"
[notifications]="notifications[selectedArea]"
(navigated)="close()"
></modal-notifications-remission-group>
<modal-notifications-task-calendar-group
*ngSwitchCase="'Tätigkeitskalender'"
[notifications]="notifications[selectedArea]"
(navigated)="close()"
></modal-notifications-task-calendar-group>
<modal-notifications-package-inspection-group
*ngSwitchCase="'Wareneingang Lagerware'"
[notifications]="notifications[selectedArea]"
(navigated)="close()"
></modal-notifications-package-inspection-group>
</ng-container>
</ng-container>
@if (notification.key === selectedArea) {
@switch (notification.value?.[0]?.category) {
@case ('ISA-Update') {
<modal-notifications-update-group
[notifications]="notifications[selectedArea]"
></modal-notifications-update-group>
}
@case ('Reservierungsanfragen') {
<modal-notifications-reservation-group
[notifications]="notifications[selectedArea]"
(navigated)="close()"
></modal-notifications-reservation-group>
}
@case ('Remission') {
<modal-notifications-remission-group
[notifications]="notifications[selectedArea]"
(navigated)="close()"
></modal-notifications-remission-group>
}
@case ('Tätigkeitskalender') {
<modal-notifications-task-calendar-group
[notifications]="notifications[selectedArea]"
(navigated)="close()"
></modal-notifications-task-calendar-group>
}
@case ('Wareneingang Lagerware') {
<modal-notifications-package-inspection-group
[notifications]="notifications[selectedArea]"
(navigated)="close()"
></modal-notifications-package-inspection-group>
}
}
}
}

View File

@@ -2,13 +2,17 @@
<div class="header">
<h1>Wählen Sie einen Drucker aus</h1>
<span *ngIf="error" class="error-message">{{ errorMessage }}</span>
@if (error) {
<span class="error-message">{{ errorMessage }}</span>
}
</div>
<div class="body">
<ui-spinner [show]="!loaded">
<ui-select class="select" [(ngModel)]="selectedPrinterKey">
<ui-select-option *ngFor="let printer of printers$ | async" [label]="printer.description" [value]="printer.key"></ui-select-option>
@for (printer of printers$ | async; track printer) {
<ui-select-option [label]="printer.description" [value]="printer.key"></ui-select-option>
}
</ui-select>
</ui-spinner>
</div>

View File

@@ -1,5 +1,9 @@
<div class="flex flex-col text-right" [class.hidden]="hideHeader$ | async">
<button type="button" class="font-bold text-[#0556B4]" *ngIf="selectButton$ | async" (click)="selectAll()">Alle auswählen</button>
<button type="button" class="font-bold text-[#0556B4]" *ngIf="unselectButton$ | async" (click)="unselectAll()">Alle abwählen</button>
@if (selectButton$ | async) {
<button type="button" class="font-bold text-[#0556B4]" (click)="selectAll()">Alle auswählen</button>
}
@if (unselectButton$ | async) {
<button type="button" class="font-bold text-[#0556B4]" (click)="unselectAll()">Alle abwählen</button>
}
<span class="mt-2">{{ selectedItemsCount$ | async }} von {{ itemsCount$ | async }} Artikel</span>
</div>

View File

@@ -15,147 +15,171 @@
</div>
<div class="shared-purchase-options-list-item__manufacturer-and-ean">
{{ product?.manufacturer }}
<span *ngIf="product?.manufacturer && product?.ean">|</span>
@if (product?.manufacturer && product?.ean) {
<span>|</span>
}
{{ product?.ean }}
</div>
<div class="shared-purchase-options-list-item__volume-and-publication-date">
{{ product?.volume }}
<span *ngIf="product?.volume && product?.publicationDate">|</span>
@if (product?.volume && product?.publicationDate) {
<span>|</span>
}
{{ product?.publicationDate | date: 'dd. MMMM yyyy' }}
</div>
<div class="shared-purchase-options-list-item__availabilities mt-3 grid grid-flow-row gap-2 justify-start">
<div class="whitespace-nowrap self-center" *ngIf="(availabilities$ | async)?.length">Verfügbar als</div>
<div *ngFor="let availability of availabilities$ | async" class="grid grid-flow-col gap-4 justify-start">
<div
[ngSwitch]="availability.purchaseOption"
class="shared-purchase-options-list-item__availability grid grid-flow-col gap-2 items-center"
[attr.data-option]="availability.purchaseOption"
>
<ng-container *ngSwitchCase="'delivery'">
<shared-icon icon="isa-truck" [size]="22"></shared-icon>
{{ availability.data.estimatedDelivery?.start | date: 'EE dd.MM.' }}
-
{{ availability.data.estimatedDelivery?.stop | date: 'EE dd.MM.' }}
</ng-container>
<ng-container *ngSwitchCase="'dig-delivery'">
<shared-icon icon="isa-truck" [size]="22"></shared-icon>
{{ availability.data.estimatedDelivery?.start | date: 'EE dd.MM.' }}
-
{{ availability.data.estimatedDelivery?.stop | date: 'EE dd.MM.' }}
</ng-container>
<ng-container *ngSwitchCase="'b2b-delivery'">
<shared-icon icon="isa-b2b-truck" [size]="24"></shared-icon>
{{ availability.data.estimatedShippingDate | date: 'dd. MMMM yyyy' }}
</ng-container>
<ng-container *ngSwitchCase="'pickup'">
<shared-icon
class="cursor-pointer"
#uiOverlayTrigger="uiOverlayTrigger"
[uiOverlayTrigger]="orderDeadlineTooltip"
[class.tooltip-active]="uiOverlayTrigger.opened"
icon="isa-box-out"
[size]="18"
></shared-icon>
{{ availability.data.estimatedShippingDate | date: 'dd. MMMM yyyy' }}
<ui-tooltip
#orderDeadlineTooltip
yPosition="above"
xPosition="after"
[yOffset]="-12"
[xOffset]="4"
[warning]="true"
[closeable]="true"
@if ((availabilities$ | async)?.length) {
<div class="whitespace-nowrap self-center">Verfügbar als</div>
}
@for (availability of availabilities$ | async; track availability) {
<div class="grid grid-flow-col gap-4 justify-start">
<div
class="shared-purchase-options-list-item__availability grid grid-flow-col gap-2 items-center"
[attr.data-option]="availability.purchaseOption"
>
<b>{{ availability.data?.orderDeadline | orderDeadline }}</b>
</ui-tooltip>
</ng-container>
<ng-container *ngSwitchCase="'in-store'">
<shared-icon icon="isa-shopping-bag" [size]="18"></shared-icon>
{{ availability.data.inStock }}x
<ng-container *ngIf="isEVT; else noEVT">ab {{ isEVT | date: 'dd. MMMM yyyy' }}</ng-container>
<ng-template #noEVT>ab sofort</ng-template>
</ng-container>
<ng-container *ngSwitchCase="'download'">
<shared-icon icon="isa-download" [size]="22"></shared-icon>
Download
</ng-container>
@switch (availability.purchaseOption) {
@case ('delivery') {
<shared-icon icon="isa-truck" [size]="22"></shared-icon>
{{ availability.data.estimatedDelivery?.start | date: 'EE dd.MM.' }}
-
{{ availability.data.estimatedDelivery?.stop | date: 'EE dd.MM.' }}
}
@case ('dig-delivery') {
<shared-icon icon="isa-truck" [size]="22"></shared-icon>
{{ availability.data.estimatedDelivery?.start | date: 'EE dd.MM.' }}
-
{{ availability.data.estimatedDelivery?.stop | date: 'EE dd.MM.' }}
}
@case ('b2b-delivery') {
<shared-icon icon="isa-b2b-truck" [size]="24"></shared-icon>
{{ availability.data.estimatedShippingDate | date: 'dd. MMMM yyyy' }}
}
@case ('pickup') {
<shared-icon
class="cursor-pointer"
#uiOverlayTrigger="uiOverlayTrigger"
[uiOverlayTrigger]="orderDeadlineTooltip"
[class.tooltip-active]="uiOverlayTrigger.opened"
icon="isa-box-out"
[size]="18"
></shared-icon>
{{ availability.data.estimatedShippingDate | date: 'dd. MMMM yyyy' }}
<ui-tooltip
#orderDeadlineTooltip
yPosition="above"
xPosition="after"
[yOffset]="-12"
[xOffset]="4"
[warning]="true"
[closeable]="true"
>
<b>{{ availability.data?.orderDeadline | orderDeadline }}</b>
</ui-tooltip>
}
@case ('in-store') {
<shared-icon icon="isa-shopping-bag" [size]="18"></shared-icon>
{{ availability.data.inStock }}x
@if (isEVT) {
ab {{ isEVT | date: 'dd. MMMM yyyy' }}
} @else {
ab sofort
}
}
@case ('download') {
<shared-icon icon="isa-download" [size]="22"></shared-icon>
Download
}
}
</div>
</div>
</div>
}
</div>
</div>
<div class="shared-purchase-options-list-item__price text-right ml-4 flex flex-col items-end">
<div class="shared-purchase-options-list-item__price-value font-bold text-xl flex flex-row items-center">
<div class="relative flex flex-row justify-end items-start">
<ui-select
*ngIf="canEditVat$ | async"
class="w-[6.5rem] min-h-[3.4375rem] p-4 rounded-card border border-solid border-[#AEB7C1] mr-4"
tabindex="-1"
[formControl]="manualVatFormControl"
[defaultLabel]="'MwSt'"
>
<ui-select-option *ngFor="let vat of vats$ | async" [label]="vat.name + '%'" [value]="vat.vatType"></ui-select-option>
</ui-select>
<shared-input-control
[class.ml-6]="priceFormControl?.invalid && priceFormControl?.dirty"
*ngIf="canEditPrice$ | async; else priceTmpl"
>
<shared-input-control-indicator>
<shared-icon *ngIf="priceFormControl?.invalid && priceFormControl?.dirty" icon="mat-info"></shared-icon>
</shared-input-control-indicator>
<input
[uiOverlayTrigger]="giftCardTooltip"
triggerOn="none"
#quantityInput
#priceOverlayTrigger="uiOverlayTrigger"
sharedInputControlInput
type="string"
class="w-24"
[formControl]="priceFormControl"
placeholder="00,00"
(sharedOnInit)="onPriceInputInit(quantityInput, priceOverlayTrigger)"
sharedNumberValue
/>
<shared-input-control-suffix>EUR</shared-input-control-suffix>
<shared-input-control-error error="required">Preis ist ungültig</shared-input-control-error>
<shared-input-control-error error="pattern">Preis ist ungültig</shared-input-control-error>
<shared-input-control-error error="min">Preis ist ungültig</shared-input-control-error>
<shared-input-control-error error="max">Preis ist ungültig</shared-input-control-error>
</shared-input-control>
@if (canEditVat$ | async) {
<ui-select
class="w-[6.5rem] min-h-[3.4375rem] p-4 rounded-card border border-solid border-[#AEB7C1] mr-4"
tabindex="-1"
[formControl]="manualVatFormControl"
[defaultLabel]="'MwSt'"
>
@for (vat of vats$ | async; track vat) {
<ui-select-option [label]="vat.name + '%'" [value]="vat.vatType"></ui-select-option>
}
</ui-select>
}
@if (canEditPrice$ | async) {
<shared-input-control
[class.ml-6]="priceFormControl?.invalid && priceFormControl?.dirty"
>
<shared-input-control-indicator>
@if (priceFormControl?.invalid && priceFormControl?.dirty) {
<shared-icon icon="mat-info"></shared-icon>
}
</shared-input-control-indicator>
<input
[uiOverlayTrigger]="giftCardTooltip"
triggerOn="none"
#quantityInput
#priceOverlayTrigger="uiOverlayTrigger"
sharedInputControlInput
type="string"
class="w-24"
[formControl]="priceFormControl"
placeholder="00,00"
(sharedOnInit)="onPriceInputInit(quantityInput, priceOverlayTrigger)"
sharedNumberValue
/>
<shared-input-control-suffix>EUR</shared-input-control-suffix>
<shared-input-control-error error="required">Preis ist ungültig</shared-input-control-error>
<shared-input-control-error error="pattern">Preis ist ungültig</shared-input-control-error>
<shared-input-control-error error="min">Preis ist ungültig</shared-input-control-error>
<shared-input-control-error error="max">Preis ist ungültig</shared-input-control-error>
</shared-input-control>
} @else {
{{ priceValue$ | async | currency: 'EUR' : 'code' }}
}
<ui-tooltip [warning]="true" xPosition="after" yPosition="below" [xOffset]="-55" [yOffset]="18" [closeable]="true" #giftCardTooltip>
Tragen Sie hier den
<br />
Gutscheinbetrag ein.
</ui-tooltip>
<ui-tooltip [warning]="true" xPosition="after" yPosition="below" [xOffset]="-55" [yOffset]="18" [closeable]="true" #giftCardTooltip>
Tragen Sie hier den
<br />
Gutscheinbetrag ein.
</ui-tooltip>
</div>
</div>
<ui-quantity-dropdown class="mt-2" [formControl]="quantityFormControl" [range]="maxSelectableQuantity$ | async"></ui-quantity-dropdown>
<div class="pt-7">
@if ((canAddResult$ | async)?.canAdd) {
<input
class="fancy-checkbox"
[class.checked]="selectedFormControl?.value"
[formControl]="selectedFormControl"
type="checkbox"
/>
}
</div>
<ng-template #priceTmpl>
{{ priceValue$ | async | currency: 'EUR' : 'code' }}
</ng-template>
</div>
<ui-quantity-dropdown class="mt-2" [formControl]="quantityFormControl" [range]="maxSelectableQuantity$ | async"></ui-quantity-dropdown>
<div class="pt-7">
<input
*ngIf="(canAddResult$ | async)?.canAdd"
class="fancy-checkbox"
[class.checked]="selectedFormControl?.value"
[formControl]="selectedFormControl"
type="checkbox"
/>
</div>
<ng-container *ngIf="canAddResult$ | async; let canAddResult">
<span *ngIf="!canAddResult.canAdd" class="inline-block font-bold text-[#BE8100] mt-[14px] max-w-[19rem]">
{{ canAddResult.message }}
</span>
</ng-container>
@if (canAddResult$ | async; as canAddResult) {
@if (!canAddResult.canAdd) {
<span class="inline-block font-bold text-[#BE8100] mt-[14px] max-w-[19rem]">
{{ canAddResult.message }}
</span>
}
}
<span *ngIf="showMaxAvailableQuantity$ | async" class="font-bold text-[#BE8100] mt-[14px]">
{{ (availability$ | async)?.inStock }} Exemplare sofort lieferbar
</span>
<span *ngIf="showNotAvailable$ | async" class="font-bold text-[#BE8100] mt-[14px]">Derzeit nicht bestellbar</span>
@if (showMaxAvailableQuantity$ | async) {
<span class="font-bold text-[#BE8100] mt-[14px]">
{{ (availability$ | async)?.inStock }} Exemplare sofort lieferbar
</span>
}
@if (showNotAvailable$ | async) {
<span class="font-bold text-[#BE8100] mt-[14px]">Derzeit nicht bestellbar</span>
}
</div>
</div>
<div class="flex flex-row">
<div class="w-16"></div>
<div class="grow shared-purchase-options-list-item__availabilities"></div>
</div>
</div>
<div class="flex flex-row">
<div class="w-16"></div>
<div class="grow shared-purchase-options-list-item__availabilities"></div>
</div>

View File

@@ -1,28 +1,37 @@
<h3 class="text-center font-bold text-h3">Lieferung auswählen</h3>
<p class="text-center font-2xl mt-4">Wie möchten Sie die Artikel erhalten?</p>
<div class="rounded p-4 shadow-card mt-4 grid grid-flow-col gap-4 justify-center items-center relative">
<ng-container *ngIf="!(isDownloadOnly$ | async)">
<ng-container *ngIf="!(isGiftCardOnly$ | async)">
<app-in-store-purchase-options-tile *ngIf="showOption('in-store')"></app-in-store-purchase-options-tile>
<app-pickup-purchase-options-tile *ngIf="showOption('pickup')"></app-pickup-purchase-options-tile>
</ng-container>
<app-delivery-purchase-options-tile *ngIf="showOption('delivery')"></app-delivery-purchase-options-tile>
</ng-container>
@if (!(isDownloadOnly$ | async)) {
@if (!(isGiftCardOnly$ | async)) {
@if (showOption('in-store')) {
<app-in-store-purchase-options-tile></app-in-store-purchase-options-tile>
}
@if (showOption('pickup')) {
<app-pickup-purchase-options-tile></app-pickup-purchase-options-tile>
}
}
@if (showOption('delivery')) {
<app-delivery-purchase-options-tile></app-delivery-purchase-options-tile>
}
}
<ng-container *ngIf="hasDownload$ | async">
<app-download-purchase-options-tile *ngIf="showOption('download')"></app-download-purchase-options-tile>
</ng-container>
@if (hasDownload$ | async) {
@if (showOption('download')) {
<app-download-purchase-options-tile></app-download-purchase-options-tile>
}
}
</div>
<shared-purchase-options-list-header></shared-purchase-options-list-header>
<div class="shared-purchase-options-modal__items -mx-4">
<shared-purchase-options-list-item
class="border-t border-gray-200 p-4 border-solid"
*ngFor="let item of items$ | async; trackBy: itemTrackBy"
[item]="item"
></shared-purchase-options-list-item>
@for (item of items$ | async; track itemTrackBy($index, item)) {
<shared-purchase-options-list-item
class="border-t border-gray-200 p-4 border-solid"
[item]="item"
></shared-purchase-options-list-item>
}
</div>
<div class="text-center -mx-4 border-t border-gray-200 p-4 border-solid">
<ng-container *ngIf="type === 'add'">
@if (type === 'add') {
<button type="button" class="isa-cta-button" [disabled]="!(canContinue$ | async) || saving" (click)="save('continue-shopping')">
Weiter einkaufen
</button>
@@ -31,18 +40,18 @@
class="ml-4 isa-cta-button isa-button-primary"
[disabled]="!(canContinue$ | async) || saving"
(click)="save('continue')"
>
>
Fortfahren
</button>
</ng-container>
<ng-container *ngIf="type === 'update'">
}
@if (type === 'update') {
<button
type="button"
class="ml-4 isa-cta-button isa-button-primary"
[disabled]="!(canContinue$ | async) || saving"
(click)="save('continue')"
>
>
Fortfahren
</button>
</ng-container>
}
</div>

View File

@@ -1,4 +1,4 @@
import { CommonModule } from '@angular/common';
import { Component, ChangeDetectionStrategy, ChangeDetectorRef } from '@angular/core';
import { PurchaseOptionsStore } from '../store';
import { BasePurchaseOptionDirective } from './base-purchase-option.directive';
@@ -9,7 +9,7 @@ import { IconComponent } from '@shared/components/icon';
templateUrl: 'download-purchase-options-tile.component.html',
styleUrls: ['purchase-options-tile.component.css'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule, IconComponent],
imports: [IconComponent],
})
export class DownloadPurchaseOptionTileComponent extends BasePurchaseOptionDirective {
constructor(

View File

@@ -26,7 +26,11 @@ import {
} from './purchase-options.helpers';
import { Observable } from 'rxjs';
import { first, switchMap } from 'rxjs/operators';
import { DEFAULT_PRICE_DTO, DEFAULT_PRICE_VALUE, DEFAULT_VAT_VALUE } from '../constants';
import {
DEFAULT_PRICE_DTO,
DEFAULT_PRICE_VALUE,
DEFAULT_VAT_VALUE,
} from '../constants';
import {
AddToShoppingCartDTO,
EntityDTOContainerOfDestinationDTO,
@@ -103,7 +107,9 @@ export class PurchaseOptionsStore extends ComponentStore<PurchaseOptionsState> {
return this.get(Selectors.getPurchaseOptionsInAvailabilities);
}
getPurchaseOptionsInAvailabilities$ = this.select(Selectors.getPurchaseOptionsInAvailabilities);
getPurchaseOptionsInAvailabilities$ = this.select(
Selectors.getPurchaseOptionsInAvailabilities,
);
get itemsForList() {
return this.get(Selectors.getItemsForList);
@@ -160,29 +166,48 @@ export class PurchaseOptionsStore extends ComponentStore<PurchaseOptionsState> {
});
}
addFetchingAvailability = this.updater((state, fetchState: FetchingAvailability) => {
return {
...state,
fetchingAvailabilities: [...state.fetchingAvailabilities, fetchState],
};
});
addFetchingAvailability = this.updater(
(state, fetchState: FetchingAvailability) => {
return {
...state,
fetchingAvailabilities: [...state.fetchingAvailabilities, fetchState],
};
},
);
removeFetchingAvailability = this.updater((state, fetchState: FetchingAvailability) => {
return {
...state,
fetchingAvailabilities: state.fetchingAvailabilities.filter((f) => f.id !== fetchState.id),
};
});
removeFetchingAvailability = this.updater(
(state, fetchState: FetchingAvailability) => {
return {
...state,
fetchingAvailabilities: state.fetchingAvailabilities.filter(
(f) => f.id !== fetchState.id,
),
};
},
);
async initialize({ items, processId, type, inStoreBranch, pickupBranch }: PurchaseOptionsModalData) {
const selectedBranch = await this._service.getSelectedBranchForProcess(processId).toPromise();
async initialize({
items,
processId,
type,
inStoreBranch,
pickupBranch,
}: PurchaseOptionsModalData) {
const selectedBranch = await this._service
.getSelectedBranchForProcess(processId)
.toPromise();
const defaultBranch = await this._service.fetchDefaultBranch().toPromise();
const customerFeatures = await this._service.getCustomerFeatures(processId).toPromise();
const customerFeatures = await this._service
.getCustomerFeatures(processId)
.toPromise();
this.patchState({
processId: processId,
type: type,
items: items.map((item) => ({ ...item, quantity: item['quantity'] ?? 1 })),
items: items.map((item) => ({
...item,
quantity: item['quantity'] ?? 1,
})),
defaultBranch: selectedBranch ?? defaultBranch,
pickupBranch: pickupBranch ?? selectedBranch,
inStoreBranch: inStoreBranch ?? selectedBranch,
@@ -255,7 +280,8 @@ export class PurchaseOptionsStore extends ComponentStore<PurchaseOptionsState> {
const items = await this._catalogService
.searchByEans({ eans: this.items.map((item) => item.product.ean) })
.toPromise();
if (items.result.length > 0) await this._addCatalogueAvailabilities(items.result);
if (items.result.length > 0)
await this._addCatalogueAvailabilities(items.result);
} else {
await this._addCatalogueAvailabilities(items as ItemDTO[]);
}
@@ -264,7 +290,9 @@ export class PurchaseOptionsStore extends ComponentStore<PurchaseOptionsState> {
// #4813 - Function updated - "this.updater" didn't work, so "this.patchState" gets used instead
// Added firstDayOfSale for catalogAvailability via ShoppingCart Route
private async _addCatalogueAvailabilities(items: ItemDTO[]) {
const currentAvailabilities = await this.availabilities$.pipe(first()).toPromise();
const currentAvailabilities = await this.availabilities$
.pipe(first())
.toPromise();
const availabilities = [...currentAvailabilities];
items.forEach((item) => {
const catalogAvailability = item.catalogAvailability;
@@ -272,7 +300,10 @@ export class PurchaseOptionsStore extends ComponentStore<PurchaseOptionsState> {
purchaseOption: 'catalog',
itemId: item.id,
ean: item.product.ean, // Hier brauchen wir zusätzlich die EAN - siehe purchase-options-list-item.component.ts: "get isEVT()"
data: { price: catalogAvailability?.price, firstDayOfSale: catalogAvailability?.firstDayOfSale },
data: {
price: catalogAvailability?.price,
firstDayOfSale: catalogAvailability?.firstDayOfSale,
},
});
});
@@ -287,8 +318,14 @@ export class PurchaseOptionsStore extends ComponentStore<PurchaseOptionsState> {
const id = uniqueId('availability_');
try {
this.addFetchingAvailability({ id, itemId: itemData.sourceId, purchaseOption: 'pickup' });
const res = await this._service.fetchPickupAvailability(itemData, itemData.quantity, branch).toPromise();
this.addFetchingAvailability({
id,
itemId: itemData.sourceId,
purchaseOption: 'pickup',
});
const res = await this._service
.fetchPickupAvailability(itemData, itemData.quantity, branch)
.toPromise();
const availability: Availability = {
itemId: itemData.sourceId,
@@ -301,7 +338,11 @@ export class PurchaseOptionsStore extends ComponentStore<PurchaseOptionsState> {
console.error('_loadPickupAvailability', err);
}
this.removeFetchingAvailability({ id, itemId: itemData.sourceId, purchaseOption: 'pickup' });
this.removeFetchingAvailability({
id,
itemId: itemData.sourceId,
purchaseOption: 'pickup',
});
}
private async _loadInStoreAvailability(itemData: ItemData) {
@@ -310,8 +351,14 @@ export class PurchaseOptionsStore extends ComponentStore<PurchaseOptionsState> {
const id = uniqueId('availability_');
try {
this.addFetchingAvailability({ id, itemId: itemData.sourceId, purchaseOption: 'in-store' });
const res = await this._service.fetchInStoreAvailability(itemData, itemData.quantity, branch).toPromise();
this.addFetchingAvailability({
id,
itemId: itemData.sourceId,
purchaseOption: 'in-store',
});
const res = await this._service
.fetchInStoreAvailability(itemData, itemData.quantity, branch)
.toPromise();
const availability: Availability = {
itemId: itemData.sourceId,
@@ -324,14 +371,24 @@ export class PurchaseOptionsStore extends ComponentStore<PurchaseOptionsState> {
console.error('_loadInStoreAvailability', err);
}
this.removeFetchingAvailability({ id, itemId: itemData.sourceId, purchaseOption: 'in-store' });
this.removeFetchingAvailability({
id,
itemId: itemData.sourceId,
purchaseOption: 'in-store',
});
}
private async _loadDeliveryAvailability(itemData: ItemData) {
const id = uniqueId('availability_');
try {
this.addFetchingAvailability({ id, itemId: itemData.sourceId, purchaseOption: 'delivery' });
const res = await this._service.fetchDeliveryAvailability(itemData, itemData.quantity).toPromise();
this.addFetchingAvailability({
id,
itemId: itemData.sourceId,
purchaseOption: 'delivery',
});
const res = await this._service
.fetchDeliveryAvailability(itemData, itemData.quantity)
.toPromise();
const availability: Availability = {
itemId: itemData.sourceId,
purchaseOption: 'delivery',
@@ -343,14 +400,24 @@ export class PurchaseOptionsStore extends ComponentStore<PurchaseOptionsState> {
console.error('_loadDeliveryAvailability', error);
}
this.removeFetchingAvailability({ id, itemId: itemData.sourceId, purchaseOption: 'delivery' });
this.removeFetchingAvailability({
id,
itemId: itemData.sourceId,
purchaseOption: 'delivery',
});
}
private async _loadDigDeliveryAvailability(itemData: ItemData) {
const id = uniqueId('availability_');
try {
this.addFetchingAvailability({ id, itemId: itemData.sourceId, purchaseOption: 'dig-delivery' });
const res = await this._service.fetchDigDeliveryAvailability(itemData, itemData.quantity).toPromise();
this.addFetchingAvailability({
id,
itemId: itemData.sourceId,
purchaseOption: 'dig-delivery',
});
const res = await this._service
.fetchDigDeliveryAvailability(itemData, itemData.quantity)
.toPromise();
const availability: Availability = {
itemId: itemData.sourceId,
@@ -363,14 +430,24 @@ export class PurchaseOptionsStore extends ComponentStore<PurchaseOptionsState> {
console.error('_loadDigDeliveryAvailability', error);
}
this.removeFetchingAvailability({ id, itemId: itemData.sourceId, purchaseOption: 'dig-delivery' });
this.removeFetchingAvailability({
id,
itemId: itemData.sourceId,
purchaseOption: 'dig-delivery',
});
}
private async _loadB2bDeliveryAvailability(itemData: ItemData) {
const id = uniqueId('availability_');
try {
this.addFetchingAvailability({ id, itemId: itemData.sourceId, purchaseOption: 'b2b-delivery' });
const res = await this._service.fetchB2bDeliveryAvailability(itemData, itemData.quantity).toPromise();
this.addFetchingAvailability({
id,
itemId: itemData.sourceId,
purchaseOption: 'b2b-delivery',
});
const res = await this._service
.fetchB2bDeliveryAvailability(itemData, itemData.quantity)
.toPromise();
const availability: Availability = {
itemId: itemData.sourceId,
@@ -383,14 +460,24 @@ export class PurchaseOptionsStore extends ComponentStore<PurchaseOptionsState> {
console.error('_loadB2bDeliveryAvailability', error);
}
this.removeFetchingAvailability({ id, itemId: itemData.sourceId, purchaseOption: 'b2b-delivery' });
this.removeFetchingAvailability({
id,
itemId: itemData.sourceId,
purchaseOption: 'b2b-delivery',
});
}
private async _loadDownloadAvailability(itemData: ItemData) {
const id = uniqueId('availability_');
try {
this.addFetchingAvailability({ id, itemId: itemData.sourceId, purchaseOption: 'download' });
const res = await this._service.fetchDownloadAvailability(itemData).toPromise();
this.addFetchingAvailability({
id,
itemId: itemData.sourceId,
purchaseOption: 'download',
});
const res = await this._service
.fetchDownloadAvailability(itemData)
.toPromise();
const availability: Availability = {
itemId: itemData.sourceId,
@@ -403,7 +490,11 @@ export class PurchaseOptionsStore extends ComponentStore<PurchaseOptionsState> {
console.error('_loadDownloadAvailability', error);
}
this.removeFetchingAvailability({ id, itemId: itemData.sourceId, purchaseOption: 'download' });
this.removeFetchingAvailability({
id,
itemId: itemData.sourceId,
purchaseOption: 'download',
});
}
private _checkAndSetAvailability(availability: Availability) {
@@ -411,7 +502,9 @@ export class PurchaseOptionsStore extends ComponentStore<PurchaseOptionsState> {
const availabilities = this.availabilities;
const index = availabilities.findIndex(
(a) => a.itemId === availability.itemId && a.purchaseOption === availability.purchaseOption,
(a) =>
a.itemId === availability.itemId &&
a.purchaseOption === availability.purchaseOption,
);
if (index > -1) {
@@ -423,7 +516,11 @@ export class PurchaseOptionsStore extends ComponentStore<PurchaseOptionsState> {
this.patchState({ availabilities });
} else {
let availabilities = this.availabilities.filter(
(a) => !(a.itemId === availability.itemId && a.purchaseOption === availability.purchaseOption),
(a) =>
!(
a.itemId === availability.itemId &&
a.purchaseOption === availability.purchaseOption
),
);
this.patchState({ availabilities });
}
@@ -432,7 +529,11 @@ export class PurchaseOptionsStore extends ComponentStore<PurchaseOptionsState> {
private _addCanAddResult(canAdd: CanAdd) {
let canAddResults = this.canAddResults;
canAddResults = canAddResults.filter(
(c) => !(c.itemId === canAdd.itemId && c.purchaseOption === canAdd.purchaseOption),
(c) =>
!(
c.itemId === canAdd.itemId &&
c.purchaseOption === canAdd.purchaseOption
),
);
canAddResults.push(canAdd);
this.patchState({ canAddResults });
@@ -474,7 +575,10 @@ export class PurchaseOptionsStore extends ComponentStore<PurchaseOptionsState> {
const b2bDeliveryAvailability = this.availabilities.find(
(a) => a.itemId === item.id && a.purchaseOption === 'b2b-delivery',
);
deliveryAvailability = deliveryAvailability || digDeliveryAvailability || b2bDeliveryAvailability;
deliveryAvailability =
deliveryAvailability ||
digDeliveryAvailability ||
b2bDeliveryAvailability;
if (deliveryAvailability) {
payloads['Versand'].push(
mapToItemPayload({
@@ -487,7 +591,9 @@ export class PurchaseOptionsStore extends ComponentStore<PurchaseOptionsState> {
}
// Get Abholung availability
let pickupAvailability = this.availabilities.find((a) => a.itemId === item.id && a.purchaseOption === 'pickup');
let pickupAvailability = this.availabilities.find(
(a) => a.itemId === item.id && a.purchaseOption === 'pickup',
);
if (pickupAvailability) {
payloads['Abholung'].push(
mapToItemPayload({
@@ -519,7 +625,9 @@ export class PurchaseOptionsStore extends ComponentStore<PurchaseOptionsState> {
const itemPayloads = payloads[key];
if (itemPayloads.length > 0) {
try {
const res = await this._service.fetchCanAdd(this.processId, key, itemPayloads).toPromise();
const res = await this._service
.fetchCanAdd(this.processId, key, itemPayloads)
.toPromise();
res.forEach((canAdd) => {
const item = itemPayloads.find((i) => i.id === canAdd.id);
this._addCanAddResult({
@@ -549,7 +657,9 @@ export class PurchaseOptionsStore extends ComponentStore<PurchaseOptionsState> {
if (value && !selectedItemIds.includes(itemId)) {
this.patchState({ selectedItemIds: [...selectedItemIds, itemId] });
} else if (!value && selectedItemIds.includes(itemId)) {
this.patchState({ selectedItemIds: selectedItemIds.filter((id) => id !== itemId) });
this.patchState({
selectedItemIds: selectedItemIds.filter((id) => id !== itemId),
});
}
}
@@ -561,7 +671,9 @@ export class PurchaseOptionsStore extends ComponentStore<PurchaseOptionsState> {
canAddItem(itemId: number): boolean {
const purchaseOption = this.purchaseOption;
const canAdd = this.canAddResults.find((c) => c.itemId === itemId && c.purchaseOption === purchaseOption);
const canAdd = this.canAddResults.find(
(c) => c.itemId === itemId && c.purchaseOption === purchaseOption,
);
if (canAdd) {
return canAdd.canAdd;
@@ -574,20 +686,40 @@ export class PurchaseOptionsStore extends ComponentStore<PurchaseOptionsState> {
this.patchState({ selectedItemIds: [] });
}
getItemsThatHaveAnAvailabilityForPurchaseOption(purchaseOption: PurchaseOption): Item[] {
return this.get(Selectors.getItemsThatHaveAnAvailabilityForPurchaseOption(purchaseOption));
getItemsThatHaveAnAvailabilityForPurchaseOption(
purchaseOption: PurchaseOption,
): Item[] {
return this.get(
Selectors.getItemsThatHaveAnAvailabilityForPurchaseOption(purchaseOption),
);
}
getItemsThatHaveAnAvailabilityForPurchaseOption$(purchaseOption: PurchaseOption): Observable<Item[]> {
return this.select(Selectors.getItemsThatHaveAnAvailabilityForPurchaseOption(purchaseOption));
getItemsThatHaveAnAvailabilityForPurchaseOption$(
purchaseOption: PurchaseOption,
): Observable<Item[]> {
return this.select(
Selectors.getItemsThatHaveAnAvailabilityForPurchaseOption(purchaseOption),
);
}
getItemsThatHaveAnAvailabilityAndCanAddForPurchaseOption(purchaseOption: PurchaseOption): Item[] {
return this.get(Selectors.getItemsThatHaveAnAvailabilityAndCanAddForPurchaseOption(purchaseOption));
getItemsThatHaveAnAvailabilityAndCanAddForPurchaseOption(
purchaseOption: PurchaseOption,
): Item[] {
return this.get(
Selectors.getItemsThatHaveAnAvailabilityAndCanAddForPurchaseOption(
purchaseOption,
),
);
}
getItemsThatHaveAnAvailabilityAndCanAddForPurchaseOption$(purchaseOption: PurchaseOption): Observable<Item[]> {
return this.select(Selectors.getItemsThatHaveAnAvailabilityAndCanAddForPurchaseOption(purchaseOption));
getItemsThatHaveAnAvailabilityAndCanAddForPurchaseOption$(
purchaseOption: PurchaseOption,
): Observable<Item[]> {
return this.select(
Selectors.getItemsThatHaveAnAvailabilityAndCanAddForPurchaseOption(
purchaseOption,
),
);
}
getAvailabilitiesForItem(itemId: number): Availability[] {
@@ -679,15 +811,21 @@ export class PurchaseOptionsStore extends ComponentStore<PurchaseOptionsState> {
}
getPrice$(itemId: number) {
return this.purchaseOption$.pipe(switchMap((po) => this.getPriceForPurchaseOption$(itemId, po)));
return this.purchaseOption$.pipe(
switchMap((po) => this.getPriceForPurchaseOption$(itemId, po)),
);
}
getPriceForPurchaseOption(itemId: number, purchaseOption: PurchaseOption) {
return this.get(Selectors.getPriceForPurchaseOption(itemId, purchaseOption));
return this.get(
Selectors.getPriceForPurchaseOption(itemId, purchaseOption),
);
}
getPriceForPurchaseOption$(itemId: number, purchaseOption: PurchaseOption) {
return this.select(Selectors.getPriceForPurchaseOption(itemId, purchaseOption));
return this.select(
Selectors.getPriceForPurchaseOption(itemId, purchaseOption),
);
}
getCanEditPrice(itemId: number) {
@@ -748,27 +886,51 @@ export class PurchaseOptionsStore extends ComponentStore<PurchaseOptionsState> {
}
getCanAddResultForItemAndCurrentPurchaseOption(itemId: number) {
return this.get(Selectors.getCanAddResultForItemAndCurrentPurchaseOption(itemId));
return this.get(
Selectors.getCanAddResultForItemAndCurrentPurchaseOption(itemId),
);
}
getCanAddResultForItemAndCurrentPurchaseOption$(itemId: number) {
return this.select(Selectors.getCanAddResultForItemAndCurrentPurchaseOption(itemId));
return this.select(
Selectors.getCanAddResultForItemAndCurrentPurchaseOption(itemId),
);
}
getAvailabilityWithPurchaseOption(itemId: number, purchaseOption: PurchaseOption) {
return this.get(Selectors.getAvailabilityWithPurchaseOption(itemId, purchaseOption));
getAvailabilityWithPurchaseOption(
itemId: number,
purchaseOption: PurchaseOption,
) {
return this.get(
Selectors.getAvailabilityWithPurchaseOption(itemId, purchaseOption),
);
}
getAvailabilityWithPurchaseOption$(itemId: number, purchaseOption: PurchaseOption) {
return this.select(Selectors.getAvailabilityWithPurchaseOption(itemId, purchaseOption));
getAvailabilityWithPurchaseOption$(
itemId: number,
purchaseOption: PurchaseOption,
) {
return this.select(
Selectors.getAvailabilityWithPurchaseOption(itemId, purchaseOption),
);
}
getCanAddResultWithPurchaseOption(itemId: number, purchaseOption: PurchaseOption) {
return this.get(Selectors.getCanAddResultWithPurchaseOption(itemId, purchaseOption));
getCanAddResultWithPurchaseOption(
itemId: number,
purchaseOption: PurchaseOption,
) {
return this.get(
Selectors.getCanAddResultWithPurchaseOption(itemId, purchaseOption),
);
}
getCanAddResultWithPurchaseOption$(itemId: number, purchaseOption: PurchaseOption) {
return this.select(Selectors.getCanAddResultWithPurchaseOption(itemId, purchaseOption));
getCanAddResultWithPurchaseOption$(
itemId: number,
purchaseOption: PurchaseOption,
) {
return this.select(
Selectors.getCanAddResultWithPurchaseOption(itemId, purchaseOption),
);
}
addItemsToShoppingCart() {
@@ -777,10 +939,16 @@ export class PurchaseOptionsStore extends ComponentStore<PurchaseOptionsState> {
}
}
getAddToShoppingCartDTOForItem(itemId: number, purchaseOption: PurchaseOption): AddToShoppingCartDTO {
getAddToShoppingCartDTOForItem(
itemId: number,
purchaseOption: PurchaseOption,
): AddToShoppingCartDTO {
const item = this.items.find((i) => i.id === itemId);
const price = this.getPriceForPurchaseOption(itemId, this.purchaseOption);
const availability = this.getAvailabilityWithPurchaseOption(itemId, purchaseOption);
const availability = this.getAvailabilityWithPurchaseOption(
itemId,
purchaseOption,
);
if (!isItemDTO(item, this.type)) {
throw new Error('Invalid item');
@@ -802,8 +970,12 @@ export class PurchaseOptionsStore extends ComponentStore<PurchaseOptionsState> {
availability: { ...availability.data, price },
destination,
itemType: item.type,
product: { ...item.product, catalogProductNumber: item?.product?.catalogProductNumber ?? String(item.id) },
promotion: { points: item.promoPoints },
product: {
...item.product,
catalogProductNumber:
item?.product?.catalogProductNumber ?? String(item.id),
},
promotion: { value: item.promoPoints },
// retailPrice: {
// value: price.value.value,
// currency: price.value.currency,
@@ -813,10 +985,16 @@ export class PurchaseOptionsStore extends ComponentStore<PurchaseOptionsState> {
};
}
getUpdateShoppingCartItemDTOForItem(itemId: number, purchaseOption: PurchaseOption): UpdateShoppingCartItemDTO {
getUpdateShoppingCartItemDTOForItem(
itemId: number,
purchaseOption: PurchaseOption,
): UpdateShoppingCartItemDTO {
const item = this.items.find((i) => i.id === itemId);
const price = this.getPriceForPurchaseOption(itemId, this.purchaseOption);
const availability = this.getAvailabilityWithPurchaseOption(itemId, purchaseOption);
const availability = this.getAvailabilityWithPurchaseOption(
itemId,
purchaseOption,
);
if (!isShoppingCartItemDTO(item, this.type)) {
throw new Error('Invalid item');
@@ -856,7 +1034,9 @@ export class PurchaseOptionsStore extends ComponentStore<PurchaseOptionsState> {
const payloads = this.selectedItemIds.map((itemId) =>
this.getAddToShoppingCartDTOForItem(itemId, purchaseOption),
);
await this._service.addItemToShoppingCart(this.processId, payloads).toPromise();
await this._service
.addItemToShoppingCart(this.processId, payloads)
.toPromise();
} else if (type === 'update') {
const payloads = this.selectedItemIds.map((itemId) =>
this.getUpdateShoppingCartItemDTOForItem(itemId, purchaseOption),
@@ -864,8 +1044,13 @@ export class PurchaseOptionsStore extends ComponentStore<PurchaseOptionsState> {
for (const itemId of this.selectedItemIds) {
const item = this.items.find((i) => i.id === itemId);
const payload = this.getUpdateShoppingCartItemDTOForItem(itemId, purchaseOption);
await this._service.updateItemInShoppingCart(this.processId, item.id, payload).toPromise();
const payload = this.getUpdateShoppingCartItemDTOForItem(
itemId,
purchaseOption,
);
await this._service
.updateItemInShoppingCart(this.processId, item.id, payload)
.toPromise();
}
} else {
throw new Error('Invalid type');

View File

@@ -2,30 +2,31 @@
<hr />
<ng-container *ngIf="orderItem$ | async; let orderItem">
@if (orderItem$ | async; as orderItem) {
<div class="header">
<img
class="thumbnail"
loading="lazy"
*ngIf="orderItem?.product?.ean | productImage; let productImage"
[src]="productImage"
[alt]="orderItem?.product?.name"
/>
@if (orderItem?.product?.ean | productImage; as productImage) {
<img
class="thumbnail"
loading="lazy"
[src]="productImage"
[alt]="orderItem?.product?.name"
/>
}
<div class="details">
<div class="product-name">{{ orderItem.product?.name }}</div>
<div *ngIf="orderItem.product?.format && orderItem.product.formatDetail" class="product-format">
<img class="format-icon" [src]="'/assets/images/Icon_' + orderItem.product?.format + '.svg'" alt="format icon" />
{{ orderItem.product.formatDetail }}
</div>
@if (orderItem.product?.format && orderItem.product.formatDetail) {
<div class="product-format">
<img class="format-icon" [src]="'/assets/images/Icon_' + orderItem.product?.format + '.svg'" alt="format icon" />
{{ orderItem.product.formatDetail }}
</div>
}
<div class="product-ean">
{{ orderItem.product?.ean }}
</div>
<div class="quantity">{{ orderItem.quantity }}x</div>
</div>
</div>
<ng-container *ngIf="availabilities$ | async; let availabilities; else: showLoadingSpinner">
@if (availabilities$ | async; as availabilities) {
<div class="supplier-grid">
<span></span>
<span class="number">Bestand</span>
@@ -34,53 +35,56 @@
<span>vsl. Lieferdatum</span>
<span class="number">Preis</span>
<span></span>
<ng-container *ngFor="let availability of availabilities; let i = index">
<ng-container *ngIf="availability">
@for (availability of availabilities; track availability; let i = $index) {
@if (availability) {
<span class="first-cell">{{ availability.supplier | supplierName }}</span>
<span class="number">{{ availability.qty || 0 }}</span>
<span>{{ availability.ssc }}</span>
<span>
<ui-checkbox *ngIf="availability.supplier !== 'F'" [(ngModel)]="availability.isPrebooked"></ui-checkbox>
@if (availability.supplier !== 'F') {
<ui-checkbox [(ngModel)]="availability.isPrebooked"></ui-checkbox>
}
</span>
<span>{{ availability.at | date: 'dd.MM.yy' }}</span>
<span class="number">{{ availability.price?.value?.value | currency: 'EUR' : 'code' }}</span>
<span>
<ui-select-bullet
*ngIf="availability.supplier !== 'F' || availability.qty > 0"
[(ngModel)]="checkedSupplier"
[value]="availability.supplier"
(ngModelChange)="checked($event, availability)"
></ui-select-bullet>
@if (availability.supplier !== 'F' || availability.qty > 0) {
<ui-select-bullet
[(ngModel)]="checkedSupplier"
[value]="availability.supplier"
(ngModelChange)="checked($event, availability)"
></ui-select-bullet>
}
</span>
</ng-container>
</ng-container>
}
}
</div>
<ng-container *ngIf="storeAvailabilityError$ | async">
@if (storeAvailabilityError$ | async) {
<div class="availability-error">Lieferantenbestand nicht verfügbar</div>
<hr />
</ng-container>
<ng-container *ngIf="takeAwayAvailabilityError$ | async">
}
@if (takeAwayAvailabilityError$ | async) {
<div class="availability-error">Filialbestand nicht verfügbar</div>
<hr />
</ng-container>
<div class="reason" *ngIf="showReasons$ | async">
<button class="reason-dropdown" [uiOverlayTrigger]="statusDropdown" #dropdown="uiOverlayTrigger">
{{ selectedReason || 'Warum wird nachbestellt?' }}
<ui-icon [rotate]="dropdown.opened ? '270deg' : '90deg'" icon="arrow_head"></ui-icon>
</button>
<ui-dropdown #statusDropdown yPosition="above" xPosition="after" [xOffset]="8">
<button uiDropdownItem *ngFor="let reason of reorderReasons$ | async" (click)="selectedReason = reason.value; dropdown.close()">
{{ reason.value }}
}
@if (showReasons$ | async) {
<div class="reason">
<button class="reason-dropdown" [uiOverlayTrigger]="statusDropdown" #dropdown="uiOverlayTrigger">
{{ selectedReason || 'Warum wird nachbestellt?' }}
<ui-icon [rotate]="dropdown.opened ? '270deg' : '90deg'" icon="arrow_head"></ui-icon>
</button>
</ui-dropdown>
<span *ngIf="showReasonError$ | async" class="error">Bitte wählen Sie einen Grund für das nachbestellen</span>
</div>
<ui-dropdown #statusDropdown yPosition="above" xPosition="after" [xOffset]="8">
@for (reason of reorderReasons$ | async; track reason) {
<button uiDropdownItem (click)="selectedReason = reason.value; dropdown.close()">
{{ reason.value }}
</button>
}
</ui-dropdown>
@if (showReasonError$ | async) {
<span class="error">Bitte wählen Sie einen Grund für das nachbestellen</span>
}
</div>
}
<div class="actions">
<button class="cta-not-available cta-action-secondary" [disabled]="ctaDisabled$ | async" (click)="notAvailable()">
<ui-spinner [show]="ctaDisabled$ | async">Nicht lieferbar</ui-spinner>
@@ -89,9 +93,7 @@
<ui-spinner [show]="ctaDisabled$ | async">Bestellen</ui-spinner>
</button>
</div>
</ng-container>
<ng-template #showLoadingSpinner>
} @else {
<ui-spinner class="load-spinner" [show]="true"></ui-spinner>
</ng-template>
</ng-container>
}
}

View File

@@ -5,7 +5,7 @@
<div class="reviews">
<hr />
<ng-container *ngFor="let review of reviews">
@for (review of reviews; track review) {
<div class="review">
<div class="row">
<ui-stars [rating]="review.rating"></ui-stars>
@@ -16,21 +16,29 @@
</div>
<div class="row">
<span>
<span class="text" *ngIf="expandIds.indexOf(review.id) === -1">{{ review.text | substr: 150 }}</span>
<span class="text" *ngIf="expandIds.indexOf(review.id) > -1">{{ review.text }}</span>
@if (expandIds.indexOf(review.id) === -1) {
<span class="text">{{ review.text | substr: 150 }}</span>
}
@if (expandIds.indexOf(review.id) > -1) {
<span class="text">{{ review.text }}</span>
}
</span>
</div>
<div class="row right">
<button *ngIf="expandIds.indexOf(review.id) === -1" class="btn-expand" (click)="expand(review.id)">
Mehr
<ui-icon icon="arrow"></ui-icon>
</button>
<button *ngIf="expandIds.indexOf(review.id) > -1" class="btn-collapse" (click)="expand(review.id)">
<ui-icon icon="arrow" rotate="180deg"></ui-icon>
Weniger
</button>
@if (expandIds.indexOf(review.id) === -1) {
<button class="btn-expand" (click)="expand(review.id)">
Mehr
<ui-icon icon="arrow"></ui-icon>
</button>
}
@if (expandIds.indexOf(review.id) > -1) {
<button class="btn-collapse" (click)="expand(review.id)">
<ui-icon icon="arrow" rotate="180deg"></ui-icon>
Weniger
</button>
}
</div>
</div>
<hr />
</ng-container>
}
</div>

View File

@@ -1,6 +1,6 @@
<div
class="page-price-update-item__item-header flex flex-row w-full items-center justify-between bg-[rgba(0,128,121,0.15)] mb-px-2 px-5 h-[53px] rounded-t"
>
>
<p class="page-price-update-item__item-instruction font-bold text-lg">{{ item?.task?.instruction }}</p>
<p class="page-price-update-item__item-due-date text-p2">
gültig ab
@@ -10,13 +10,14 @@
<div class="page-price-update-item__item-card p-5 bg-white">
<div class="page-price-update-item__item-thumbnail text-center mr-4 w-[47px] h-[73px]">
<img
class="page-price-update-item__item-image w-[47px] max-h-[73px]"
loading="lazy"
*ngIf="item?.product?.ean | productImage; let productImage"
[src]="productImage"
[alt]="item?.product?.name"
/>
@if (item?.product?.ean | productImage; as productImage) {
<img
class="page-price-update-item__item-image w-[47px] max-h-[73px]"
loading="lazy"
[src]="productImage"
[alt]="item?.product?.name"
/>
}
</div>
<div class="page-price-update-item__item-details">
@@ -31,28 +32,33 @@
[class.text-md]="item?.product?.name?.length >= 50"
[class.text-p3]="item?.product?.name?.length >= 60"
[class.text-xs]="item?.product?.name?.length >= 100"
>
>
{{ item?.product?.name }}
</div>
<div class="page-price-update-item__item-format">
<div *ngIf="item?.product?.format && item?.product?.formatDetail" class="font-bold flex flex-row">
<img
class="mr-3"
*ngIf="item?.product?.format !== '--'"
loading="lazy"
src="assets/images/Icon_{{ item?.product?.format }}.svg"
[alt]="item?.product?.formatDetail"
/>
{{ environment.isTablet() ? (item?.product?.formatDetail | substr: 25) : item?.product?.formatDetail }}
</div>
@if (item?.product?.format && item?.product?.formatDetail) {
<div class="font-bold flex flex-row">
@if (item?.product?.format !== '--') {
<img
class="mr-3"
loading="lazy"
src="assets/images/Icon_{{ item?.product?.format }}.svg"
[alt]="item?.product?.formatDetail"
/>
}
{{ environment.isTablet() ? (item?.product?.formatDetail | substr: 25) : item?.product?.formatDetail }}
</div>
}
</div>
<div class="page-price-update-item__item-misc">
{{ environment.isTablet() ? (item?.product?.manufacturer | substr: 18) : item?.product?.manufacturer }} | {{ item?.product?.ean }}
<br />
{{ item?.product?.volume }}
<span *ngIf="item?.product?.volume && item?.product?.publicationDate">|</span>
@if (item?.product?.volume && item?.product?.publicationDate) {
<span>|</span>
}
{{ publicationDate }}
</div>
</div>
@@ -69,18 +75,21 @@
</div>
<div class="page-price-update-item__item-select-bullet">
<input *ngIf="isSelectable" [ngModel]="selected" (ngModelChange)="setSelected()" class="isa-select-bullet" type="checkbox" />
@if (isSelectable) {
<input [ngModel]="selected" (ngModelChange)="setSelected()" class="isa-select-bullet" type="checkbox" />
}
</div>
<div class="page-price-update-item__item-stock flex flex-row font-bold">
<ui-icon class="mt-px-2 mr-1" icon="home" size="1em"></ui-icon>
<span
*ngIf="inStock$ | async; let stock"
[class.skeleton]="stock?.inStock === undefined"
class="min-w-[1rem] text-right inline-block"
>
{{ stock?.inStock }}
</span>
@if (inStock$ | async; as stock) {
<span
[class.skeleton]="stock?.inStock === undefined"
class="min-w-[1rem] text-right inline-block"
>
{{ stock?.inStock }}
</span>
}
x
</div>
</div>

View File

@@ -4,20 +4,26 @@
(click)="print()"
type="button"
class="page-price-update-list__print-cta text-lg font-bold text-[#F70400] pr-5 mb-3"
>
>
Drucken
</button>
<div class="flex flex-row items-center justify-end">
<div *ngIf="getSelectableItems().length > 0" class="text-[#0556B4] font-bold text-p3 mr-5">
<ng-container *ngIf="selectedItemUids$ | async; let selectedItems">
<button class="page-price-update-list__cta-unselect-all" *ngIf="selectedItems?.length > 0" type="button" (click)="unselectAll()">
Alle entfernen ({{ selectedItems?.length }})
</button>
<button class="page-price-update-list__cta-select-all" type="button" (click)="selectAll()" *ngIf="selectedItems?.length === 0">
Alle auswählen ({{ getSelectableItems().length }})
</button>
</ng-container>
</div>
@if (getSelectableItems().length > 0) {
<div class="text-[#0556B4] font-bold text-p3 mr-5">
@if (selectedItemUids$ | async; as selectedItems) {
@if (selectedItems?.length > 0) {
<button class="page-price-update-list__cta-unselect-all" type="button" (click)="unselectAll()">
Alle entfernen ({{ selectedItems?.length }})
</button>
}
@if (selectedItems?.length === 0) {
<button class="page-price-update-list__cta-select-all" type="button" (click)="selectAll()">
Alle auswählen ({{ getSelectableItems().length }})
</button>
}
}
</div>
}
<div class="page-price-update-list__items-count inline-flex flex-row items-center pr-5 text-p3">
{{ items?.length ?? 0 }}
Titel
@@ -32,38 +38,41 @@
<div class="items scroll-bar">
@for (item of items; track item.uId; let first = $first) {
@defer (on viewport) {
<page-price-update-item [item]="item" [selected]="isSelected(item)" [class.mt-px-10]="!first"></page-price-update-item>
<page-price-update-item [item]="item" [selected]="isSelected(item)" [class.mt-px-10]="!first"></page-price-update-item>
} @placeholder {
<page-price-update-item-loader></page-price-update-item-loader>
}
<page-price-update-item-loader></page-price-update-item-loader>
}
}
<page-price-update-item-loader *ngIf="fetching"></page-price-update-item-loader>
<div class="h-28"></div>
@if (fetching) {
<page-price-update-item-loader></page-price-update-item-loader>
}
<div class="h-28"></div>
</div>
<!-- <cdk-virtual-scroll-viewport #scrollContainer [itemSize]="267" minBufferPx="1200" maxBufferPx="1200" class="scroll-bar">
<page-price-update-item
*cdkVirtualFor="let item of items; let first; trackBy: trackByFn"
[item]="item"
[selected]="isSelected(item)"
[class.mt-px-10]="!first"
></page-price-update-item>
<page-price-update-item
*cdkVirtualFor="let item of items; let first; trackBy: trackByFn"
[item]="item"
[selected]="isSelected(item)"
[class.mt-px-10]="!first"
></page-price-update-item>
<page-price-update-item-loader *ngIf="fetching"> </page-price-update-item-loader>
<page-price-update-item-loader *ngIf="fetching"> </page-price-update-item-loader>
<div class="h-28"></div>
<div class="h-28"></div>
</cdk-virtual-scroll-viewport> -->
<div class="page-price-update-list__action-wrapper">
<button
[@cta]
*ngIf="!fetching"
[disabled]="(selectedItemUids$ | async).length === 0 || (loading$ | async)"
class="page-price-update-list__complete-items isa-button isa-cta-button isa-button-primary px-11"
type="button"
(click)="onComplete()"
>
<ui-spinner [show]="loading$ | async">Erledigt</ui-spinner>
</button>
@if (!fetching) {
<button
[@cta]
[disabled]="(selectedItemUids$ | async).length === 0 || (loading$ | async)"
class="page-price-update-list__complete-items isa-button isa-cta-button isa-button-primary px-11"
type="button"
(click)="onComplete()"
>
<ui-spinner [show]="loading$ | async">Erledigt</ui-spinner>
</button>
}
</div>

View File

@@ -5,17 +5,20 @@
id="asortment-filter-button"
class="absolute right-0 top-0 h-14 rounded px-5 text-lg bg-cadet-blue flex flex-row flex-nowrap items-center justify-center"
type="button"
>
>
<shared-icon class="mr-2" icon="filter-variant"></shared-icon>
Filter
</button>
</div>
<page-price-update-list
*ngIf="showList$ | async; else noResults"
[items]="store.items$ | async"
[fetching]="store.fetching$ | async"
></page-price-update-list>
@if (showList$ | async) {
<page-price-update-list
[items]="store.items$ | async"
[fetching]="store.fetching$ | async"
></page-price-update-list>
} @else {
<div class="bg-white text-h3 text-center pt-10 font-bold rounded-b h-[calc(100vh_-_370px)]">Keine Preisänderungen vorhanden.</div>
}
<shell-filter-overlay #filterOverlay class="relative">
<div class="relative">
@@ -26,22 +29,23 @@
<h3 class="text-3xl text-center font-bold mt-8">Filter</h3>
<ui-filter
*ngIf="filterOverlay.isOpen"
#filter
class="mx-4"
[filter]="store.pendingFilter$ | async"
(search)="applyFilter()"
[loading]="store.fetching$ | async"
[hint]="hint$ | async"
></ui-filter>
@if (filterOverlay.isOpen) {
<ui-filter
#filter
class="mx-4"
[filter]="store.pendingFilter$ | async"
(search)="applyFilter()"
[loading]="store.fetching$ | async"
[hint]="hint$ | async"
></ui-filter>
}
<div class="absolute bottom-8 left-0 right-0 grid grid-flow-col gap-4 justify-center">
<button
type="button"
class="px-6 py-4 font-bold bg-white text-brand border-2 border-solid border-brand rounded-full"
(click)="store.resetPendingFilter()"
>
>
Filter zurücksetzen
</button>
<button
@@ -49,12 +53,9 @@
class="px-6 py-4 font-bold bg-brand text-white border-2 border-solid border-brand rounded-full disabled:bg-cadet-blue disabled:cursor-progress disabled:border-cadet-blue"
(click)="applyFilter()"
[disabled]="store.fetching$ | async"
>
>
<ui-spinner [show]="store.fetching$ | async">Filter anwenden</ui-spinner>
</button>
</div>
</shell-filter-overlay>
<ng-template #noResults>
<div class="bg-white text-h3 text-center pt-10 font-bold rounded-b h-[calc(100vh_-_370px)]">Keine Preisänderungen vorhanden.</div>
</ng-template>

View File

@@ -1,14 +1,16 @@
<ng-container *ngFor="let line of lines">
<ng-container [ngSwitch]="line | lineType">
<ng-container *ngSwitchCase="'reihe'">
<page-article-details-text-link *ngFor="let reihe of getReihen(line)" [route]="reihe | reiheRoute">
{{ reihe }}
</page-article-details-text-link>
@for (line of lines; track line) {
@switch (line | lineType) {
@case ('reihe') {
@for (reihe of getReihen(line); track reihe) {
<page-article-details-text-link [route]="reihe | reiheRoute">
{{ reihe }}
</page-article-details-text-link>
}
<br />
</ng-container>
<ng-container *ngSwitchDefault>
}
@default {
{{ line }}
<br />
</ng-container>
</ng-container>
</ng-container>
}
}
}

View File

@@ -1,7 +1,7 @@
import { Component, ChangeDetectionStrategy, Input } from '@angular/core';
import { TextDTO } from '@generated/swagger/cat-search-api';
import { ArticleDetailsTextLinkComponent } from './article-details-text-link.component';
import { NgFor, NgSwitch, NgSwitchCase, NgSwitchDefault } from '@angular/common';
import { LineTypePipe } from './line-type.pipe';
import { ReiheRoutePipe } from './reihe-route.pipe';
@@ -13,13 +13,9 @@ import { ReiheRoutePipe } from './reihe-route.pipe';
host: { class: 'page-article-details-text' },
imports: [
ArticleDetailsTextLinkComponent,
NgFor,
NgSwitch,
NgSwitchCase,
NgSwitchDefault,
LineTypePipe,
ReiheRoutePipe,
],
ReiheRoutePipe
],
})
export class ArticleDetailsTextComponent {
@Input()

View File

File diff suppressed because it is too large Load Diff

View File

@@ -1,37 +1,39 @@
<ng-container *ngIf="store.item$ | async; let item">
@if (store.item$ | async; as item) {
<button
class="h-[3.75rem] shadow-[0_-2px_24px_0_#dce2e9] flex flex-row justify-center items-center w-full text-xl bg-white text-[#0556B4] font-bold border-none outline-none rounded-t"
(click)="close.emit()"
>
>
{{ item?.product?.name }}
</button>
<h1>Empfehlungen für Sie</h1>
<p>Neben dem Titel "{{ item.product?.name }}" gibt es noch andere Artikel, die Sie interessieren könnten.</p>
<div class="articles">
<span class="label mb-2">
<ui-icon icon="recommendation" size="20px"></ui-icon>
Artikel
</span>
<ng-container *ngIf="store.recommendations$ | async; let recommendations">
<span *ngIf="recommendations.length === 0" class="empty-message">Keine Empfehlungen verfügbar</span>
<ui-slider *ngIf="recommendations.length > 0" [scrollDistance]="210">
<a
class="article"
*ngFor="let recommendation of store.recommendations$ | async"
[routerLink]="getDetailsPath(recommendation.product.ean)"
[queryParams]="{ main_qs: recommendation.product.ean, filter_format: '' }"
(click)="close.emit()"
>
<img [src]="recommendation.product?.ean | productImage: 195 : 315 : true" alt="product-image" />
<div class="flex flex-col">
<span class="format">{{ recommendation.product?.formatDetail }}</span>
<span class="price">{{ recommendation.catalogAvailability?.price?.value?.value | currency: ' ' }} EUR</span>
</div>
</a>
</ui-slider>
</ng-container>
@if (store.recommendations$ | async; as recommendations) {
@if (recommendations.length === 0) {
<span class="empty-message">Keine Empfehlungen verfügbar</span>
}
@if (recommendations.length > 0) {
<ui-slider [scrollDistance]="210">
@for (recommendation of store.recommendations$ | async; track recommendation) {
<a
class="article"
[routerLink]="getDetailsPath(recommendation.product.ean)"
[queryParams]="{ main_qs: recommendation.product.ean, filter_format: '' }"
(click)="close.emit()"
>
<img [src]="recommendation.product?.ean | productImage: 195 : 315 : true" alt="product-image" />
<div class="flex flex-col">
<span class="format">{{ recommendation.product?.formatDetail }}</span>
<span class="price">{{ recommendation.catalogAvailability?.price?.value?.value | currency: ' ' }} EUR</span>
</div>
</a>
}
</ui-slider>
}
}
</div>
</ng-container>
}

View File

@@ -1,19 +1,19 @@
<div class="hidden desktop-large:block" [class.show-filter]="showFilter">
<ng-container *ngIf="filter$ | async; let filter">
@if (filter$ | async; as filter) {
<div class="catalog-search-filter-content">
<div class="w-full flex flex-row justify-end items-center">
<button (click)="clearFilter(filter)" class="text-[#0556B4] p-4">Alle Filter entfernen</button>
<a
*ngIf="showFilterClose$ | async"
class="text-black p-4 outline-none border-none bg-transparent"
[routerLink]="closeFilterRoute"
(click)="showFilter = false"
queryParamsHandling="preserve"
>
<shared-icon icon="close" [size]="25"></shared-icon>
</a>
@if (showFilterClose$ | async) {
<a
class="text-black p-4 outline-none border-none bg-transparent"
[routerLink]="closeFilterRoute"
(click)="showFilter = false"
queryParamsHandling="preserve"
>
<shared-icon icon="close" [size]="25"></shared-icon>
</a>
}
</div>
<div class="catalog-search-filter-content-main -mt-14 desktop-small:-mt-8 desktop-large:-mt-12">
<h1 class="text-h3 text-[1.625rem] font-bold text-center pt-6 pb-10">Filter</h1>
<shared-filter
@@ -24,16 +24,14 @@
[scanner]="true"
></shared-filter>
</div>
<div class="cta-wrapper">
<button class="cta-reset-filter" (click)="resetFilter(filter)" [disabled]="fetching$ | async">Filter zurücksetzen</button>
<button class="cta-apply-filter" (click)="applyFilter(filter)" [disabled]="(fetching$ | async) || !hasSelectedOptions(filter)">
<ui-spinner [show]="fetching$ | async">Filter anwenden</ui-spinner>
</button>
</div>
</div>
</ng-container>
}
</div>
<div class="desktop-large:hidden" [class.hidden]="showFilter">
<page-article-search-main (showFilter)="showFilter = true"></page-article-search-main>

View File

@@ -1,12 +1,13 @@
<div class="bg-white rounded py-10 px-4 text-center shadow-[0_-2px_24px_0_#dce2e9] h-full">
<h1 class="text-h3 text-[1.625rem] font-bold mb-[0.375rem]">Artikelsuche</h1>
<p class="text-lg mb-10">Welchen Artikel suchen Sie?</p>
<ng-container *ngIf="filter$ | async; let filter">
<shared-filter-filter-group-main
class="mb-8 w-full"
*ngIf="!(isDesktop$ | async)"
[inputGroup]="filter?.filter | group: 'main'"
></shared-filter-filter-group-main>
@if (filter$ | async; as filter) {
@if (!(isDesktop$ | async)) {
<shared-filter-filter-group-main
class="mb-8 w-full"
[inputGroup]="filter?.filter | group: 'main'"
></shared-filter-filter-group-main>
}
<div class="flex flex-row px-12 justify-center desktop-large:px-0">
<shared-filter-input-group-main
class="block w-full mr-3 desktop-large:mx-auto"
@@ -17,38 +18,40 @@
[showDescription]="false"
[scanner]="true"
></shared-filter-input-group-main>
<button
type="button"
*ngIf="!(isDesktop$ | async)"
(click)="showFilter.emit()"
class="page-search-main__filter w-[6.75rem] h-14 rounded font-bold px-5 mb-4 text-lg bg-[#AEB7C1] flex flex-row flex-nowrap items-center justify-center"
[class.active]="hasFilter$ | async"
>
<shared-icon class="mr-2" icon="filter-variant"></shared-icon>
Filter
</button>
@if (!(isDesktop$ | async)) {
<button
type="button"
(click)="showFilter.emit()"
class="page-search-main__filter w-[6.75rem] h-14 rounded font-bold px-5 mb-4 text-lg bg-[#AEB7C1] flex flex-row flex-nowrap items-center justify-center"
[class.active]="hasFilter$ | async"
>
<shared-icon class="mr-2" icon="filter-variant"></shared-icon>
Filter
</button>
}
</div>
<div class="flex flex-col items-start ml-12 desktop-large:ml-8 py-6 bg-white overflow-hidden h-[calc(100%-13.5rem)]">
<h3 class="text-p3 font-bold mb-3">Deine letzten Suchanfragen</h3>
<ul class="flex flex-col justify-start overflow-hidden overflow-y-scroll items-start m-0 p-0 bg-white w-full">
<li class="list-none pb-3" *ngFor="let recentQuery of history$ | async">
<button
class="flex flex-row items-center outline-none border-none bg-white text-black text-p2 m-0 p-0"
(click)="setQueryHistory(filter, recentQuery.friendlyName)"
matomoClickCategory="search"
matomoClickAction="click"
matomoClickName="recent-search"
>
<shared-icon
class="flex w-8 h-8 justify-center items-center mr-3 rounded-full text-black bg-[#edeff0]"
icon="magnify"
[size]="20"
></shared-icon>
<p class="m-0 p-0 whitespace-nowrap overflow-hidden overflow-ellipsis max-w-[25rem]">{{ recentQuery.friendlyName }}</p>
</button>
</li>
@for (recentQuery of history$ | async; track recentQuery) {
<li class="list-none pb-3">
<button
class="flex flex-row items-center outline-none border-none bg-white text-black text-p2 m-0 p-0"
(click)="setQueryHistory(filter, recentQuery.friendlyName)"
matomoClickCategory="search"
matomoClickAction="click"
matomoClickName="recent-search"
>
<shared-icon
class="flex w-8 h-8 justify-center items-center mr-3 rounded-full text-black bg-[#edeff0]"
icon="magnify"
[size]="20"
></shared-icon>
<p class="m-0 p-0 whitespace-nowrap overflow-hidden overflow-ellipsis max-w-[25rem]">{{ recentQuery.friendlyName }}</p>
</button>
</li>
}
</ul>
</div>
</ng-container>
}
</div>

View File

@@ -1,4 +1,6 @@
<p class="can-add-message" *ngIf="ref.data.canAddMessage">{{ ref.data.canAddMessage }}</p>
@if (ref.data.canAddMessage) {
<p class="can-add-message">{{ ref.data.canAddMessage }}</p>
}
<div class="actions">
<button (click)="continue()" class="cta cta-action-secondary">Weiter Einkaufen</button>

View File

@@ -1,4 +1,4 @@
<ng-container *ngIf="!primaryOutletActive; else primaryOutlet">
@if (!primaryOutletActive) {
<div class="bg-ucla-blue rounded w-[4.375rem] h-[5.625rem] animate-[load_1s_linear_infinite]"></div>
<div class="flex flex-col flex-grow">
<div class="h-4 bg-ucla-blue ml-4 mb-2 w-[7.8125rem] animate-[load_1s_linear_infinite]"></div>
@@ -16,9 +16,7 @@
<div class="h-4 bg-ucla-blue ml-4 w-[7.8125rem] animate-[load_1s_linear_infinite]"></div>
</div>
</div>
</ng-container>
<ng-template #primaryOutlet>
} @else {
<div class="bg-ucla-blue rounded w-[3rem] h-[4.125rem] animate-[load_1s_linear_infinite]"></div>
<div class="flex flex-col ml-4 w-[36.6%]">
<div class="h-4 bg-ucla-blue mb-2 w-[8.8125rem] animate-[load_1s_linear_infinite]"></div>
@@ -35,4 +33,5 @@
<div class="h-4 bg-ucla-blue mb-2 w-[8.8125rem] animate-[load_1s_linear_infinite]"></div>
<div class="h-4 bg-ucla-blue w-[8.8125rem] animate-[load_1s_linear_infinite]"></div>
</div>
</ng-template>
}

View File

@@ -2,32 +2,34 @@
class="page-search-result-item__item-card hover p-5 desktop-small:px-4 desktop-small:py-[0.625rem] h-[13.25rem] desktop-small:h-[11.3125rem] bg-white border border-solid border-transparent rounded"
[class.page-search-result-item__item-card-primary]="primaryOutletActive"
[class.active]="isActive"
>
>
<div class="page-search-result-item__item-thumbnail text-center mr-4 w-[3.125rem] h-[4.9375rem]">
<img
class="page-search-result-item__item-image w-[3.125rem] max-h-[4.9375rem]"
loading="lazy"
*ngIf="item?.imageId | thumbnailUrl; let thumbnailUrl"
[src]="thumbnailUrl"
[alt]="item?.product?.name"
/>
@if (item?.imageId | thumbnailUrl; as thumbnailUrl) {
<img
class="page-search-result-item__item-image w-[3.125rem] max-h-[4.9375rem]"
loading="lazy"
[src]="thumbnailUrl"
[alt]="item?.product?.name"
/>
}
</div>
<div
class="page-search-result-item__item-grid-container"
[class.page-search-result-item__item-grid-container-primary]="primaryOutletActive"
>
>
<div
class="page-search-result-item__item-contributors desktop-small:text-p3 font-bold text-[#0556B4] text-ellipsis overflow-hidden max-w-[24rem] whitespace-nowrap"
>
<a
*ngFor="let contributor of contributors; let last = last"
[routerLink]="resultsPath"
[queryParams]="{ main_qs: contributor, main_author: 'author' }"
(click)="$event?.stopPropagation()"
>
{{ contributor }}{{ last ? '' : ';' }}
</a>
@for (contributor of contributors; track contributor; let last = $last) {
<a
[routerLink]="resultsPath"
[queryParams]="{ main_qs: contributor, main_author: 'author' }"
(click)="$event?.stopPropagation()"
>
{{ contributor }}{{ last ? '' : ';' }}
</a>
}
</div>
<div
@@ -37,21 +39,24 @@
[class.text-md]="item?.product?.name?.length >= 50 && isTablet"
[class.text-p3]="item?.product?.name?.length >= 60 || !isTablet"
[class.text-xs]="item?.product?.name?.length >= 100"
>
>
{{ item?.product?.name }}
</div>
<div class="page-search-result-item__item-format desktop-small:text-p3">
<div *ngIf="item?.product?.format && item?.product?.formatDetail" class="font-bold flex flex-row">
<img
class="mr-3"
*ngIf="item?.product?.format !== '--'"
loading="lazy"
src="assets/images/Icon_{{ item?.product?.format }}.svg"
[alt]="item?.product?.formatDetail"
/>
{{ item?.product?.formatDetail | substr: 30 }}
</div>
@if (item?.product?.format && item?.product?.formatDetail) {
<div class="font-bold flex flex-row">
@if (item?.product?.format !== '--') {
<img
class="mr-3"
loading="lazy"
src="assets/images/Icon_{{ item?.product?.format }}.svg"
[alt]="item?.product?.formatDetail"
/>
}
{{ item?.product?.formatDetail | substr: 30 }}
</div>
}
</div>
<div class="page-search-result-item__item-manufacturer desktop-small:text-p3">
@@ -60,31 +65,34 @@
<div class="page-search-result-item__item-misc desktop-small:text-p3">
{{ item?.product?.volume }}
<span *ngIf="item?.product?.volume && item?.product?.publicationDate">|</span>
@if (item?.product?.volume && item?.product?.publicationDate) {
<span>|</span>
}
{{ publicationDate }}
</div>
<div
class="page-search-result-item__item-price desktop-small:text-p3 font-bold justify-self-end"
[class.page-search-result-item__item-price-primary]="primaryOutletActive"
>
>
{{ item?.catalogAvailability?.price?.value?.value | currency: 'EUR' : 'code' }}
</div>
<div class="page-search-result-item__item-select-bullet justify-self-end">
<input
*ngIf="selectable"
(click)="$event.stopPropagation()"
[ngModel]="selected"
@if (selectable) {
<input
(click)="$event.stopPropagation()"
[ngModel]="selected"
(ngModelChange)="
setSelected();
tracker.trackEvent({ category: 'Trefferliste', action: 'select', name: item.product.name, value: selected ? 1 : 0 })
"
class="isa-select-bullet"
type="checkbox"
matomoTracker
#tracker="matomo"
/>
class="isa-select-bullet"
type="checkbox"
matomoTracker
#tracker="matomo"
/>
}
</div>
<button
@@ -94,20 +102,21 @@
[overlayTriggerDisabled]="!(stockTooltipText$ | async)"
type="button"
(click)="$event.stopPropagation(); $event.preventDefault(); showTooltip()"
>
>
<ui-icon class="mr-[0.125rem] -mt-[0.275rem]" icon="home" size="1rem"></ui-icon>
<ng-container *ngIf="isOrderBranch$ | async">
<span
*ngIf="inStock$ | async; let stock"
[class.skeleton]="stock.inStock === undefined"
class="min-w-[0.75rem] text-right inline-block"
>
{{ stock?.inStock }}
</span>
</ng-container>
<ng-container *ngIf="!(isOrderBranch$ | async)">
@if (isOrderBranch$ | async) {
@if (inStock$ | async; as stock) {
<span
[class.skeleton]="stock.inStock === undefined"
class="min-w-[0.75rem] text-right inline-block"
>
{{ stock?.inStock }}
</span>
}
}
@if (!(isOrderBranch$ | async)) {
<span class="min-w-[1rem] text-center inline-block">-</span>
</ng-container>
}
<span>x</span>
</button>
<ui-tooltip #tooltip yPosition="above" xPosition="after" [yOffset]="-12" [closeable]="true">
@@ -117,14 +126,14 @@
<div
class="page-search-result-item__item-ssc desktop-small:text-p3 w-full text-right overflow-hidden text-ellipsis whitespace-nowrap"
[class.page-search-result-item__item-ssc-primary]="primaryOutletActive"
>
<ng-container *ngIf="ssc$ | async; let ssc">
>
@if (ssc$ | async; as ssc) {
<div class="hidden" [class.page-search-result-item__item-ssc-tooltip]="primaryOutletActive">
{{ ssc?.ssc }} - {{ ssc?.sscText }}
</div>
<strong>{{ ssc?.ssc }}</strong>
- {{ ssc?.sscText }}
</ng-container>
}
</div>
</div>
</div>

View File

@@ -2,50 +2,53 @@
class="page-search-results__header bg-background-liste flex items-end justify-between"
[class.pb-4]="!(primaryOutletActive$ | async)"
[class.flex-col]="!(primaryOutletActive$ | async)"
>
>
<div class="flex flex-row w-full desktop:w-min" [class.desktop-large:w-full]="!(primaryOutletActive$ | async)">
<shared-filter-input-group-main
*ngIf="filter$ | async; let filter"
class="block mr-3 w-full desktop:w-[23.5rem]"
[class.desktop-large:w-full]="!(primaryOutletActive$ | async)"
[hint]="searchboxHint$ | async"
[loading]="fetching$ | async"
[inputGroup]="filter?.input | group: 'main'"
(search)="search({ filter, clear: true })"
[showDescription]="false"
[scanner]="true"
></shared-filter-input-group-main>
@if (filter$ | async; as filter) {
<shared-filter-input-group-main
class="block mr-3 w-full desktop:w-[23.5rem]"
[class.desktop-large:w-full]="!(primaryOutletActive$ | async)"
[hint]="searchboxHint$ | async"
[loading]="fetching$ | async"
[inputGroup]="filter?.input | group: 'main'"
(search)="search({ filter, clear: true })"
[showDescription]="false"
[scanner]="true"
></shared-filter-input-group-main>
}
<a
class="page-search-results__filter w-[6.75rem] h-14 rounded font-bold px-5 mb-4 text-lg bg-[#AEB7C1] flex flex-row flex-nowrap items-center justify-center"
[class.active]="hasFilter$ | async"
[routerLink]="filterRoute"
[queryParams]="filterQueryParams"
>
>
<shared-icon class="mr-2" icon="filter-variant"></shared-icon>
Filter
</a>
</div>
<div
*ngIf="hits$ | async; let hits"
class="page-search-results__items-count inline-flex flex-row items-center pr-5 text-p3"
[class.mb-4]="primaryOutletActive$ | async"
>
{{ hits ?? 0 }}
Titel
</div>
@if (hits$ | async; as hits) {
<div
class="page-search-results__items-count inline-flex flex-row items-center pr-5 text-p3"
[class.mb-4]="primaryOutletActive$ | async"
>
{{ hits ?? 0 }}
Titel
</div>
}
</div>
<div class="page-search-results__order-by mb-[0.125rem]" [class.page-search-results__order-by-primary]="primaryOutletActive$ | async">
<shared-order-by-filter
*ngIf="filter$ | async; let filter"
[orderBy]="filter?.orderBy"
(selectedOrderByChange)="search({ filter, clear: true, orderBy: true }); updateBreadcrumbs()"
></shared-order-by-filter>
@if (filter$ | async; as filter) {
<shared-order-by-filter
[orderBy]="filter?.orderBy"
(selectedOrderByChange)="search({ filter, clear: true, orderBy: true }); updateBreadcrumbs()"
></shared-order-by-filter>
}
</div>
<ng-container *ngIf="primaryOutletActive$ | async; else sideOutlet">
@if (primaryOutletActive$ | async) {
<cdk-virtual-scroll-viewport class="product-list" [itemSize]="103 * (scale$ | async)" (scrolledIndexChange)="scrolledIndexChange($event)">
<a
*cdkVirtualFor="let item of results$ | async; let i = index; trackBy: trackByItemId"
@@ -54,7 +57,7 @@
#rla="routerLinkActive"
queryParamsHandling="preserve"
(click)="scrollToItem(i)"
>
>
<search-result-item
class="page-search-results__result-item page-search-results__result-item-primary"
(selectedChange)="addToCart($event)"
@@ -65,24 +68,25 @@
[isActive]="rla.isActive"
></search-result-item>
</a>
<page-search-result-item-loading [primaryOutletActive]="true" *ngIf="fetching$ | async"></page-search-result-item-loading>
@if (fetching$ | async) {
<page-search-result-item-loading [primaryOutletActive]="true"></page-search-result-item-loading>
}
</cdk-virtual-scroll-viewport>
<div class="actions z-sticky h-0">
<button
[disabled]="loading$ | async"
*ngIf="(selectedItemIds$ | async)?.length > 0"
class="cta-cart cta-action-primary"
(click)="addToCart()"
matomoClickCategory="Trefferliste"
matomoClickAction="click"
matomoClickName="In den Warenkorb legen"
>
<ui-spinner [show]="loading$ | async">In den Warenkorb legen</ui-spinner>
</button>
@if ((selectedItemIds$ | async)?.length > 0) {
<button
[disabled]="loading$ | async"
class="cta-cart cta-action-primary"
(click)="addToCart()"
matomoClickCategory="Trefferliste"
matomoClickAction="click"
matomoClickName="In den Warenkorb legen"
>
<ui-spinner [show]="loading$ | async">In den Warenkorb legen</ui-spinner>
</button>
}
</div>
</ng-container>
<ng-template #sideOutlet>
} @else {
<cdk-virtual-scroll-viewport class="product-list" [itemSize]="191 * (scale$ | async)" (scrolledIndexChange)="scrolledIndexChange($event)">
<a
*cdkVirtualFor="let item of results$ | async; let i = index; trackBy: trackByItemId"
@@ -91,7 +95,7 @@
#rla="routerLinkActive"
queryParamsHandling="preserve"
(click)="scrollToItem(i)"
>
>
<search-result-item
class="page-search-results__result-item"
(selectedChange)="addToCart($event)"
@@ -102,16 +106,20 @@
[isActive]="rla.isActive"
></search-result-item>
</a>
<page-search-result-item-loading [primaryOutletActive]="false" *ngIf="fetching$ | async"></page-search-result-item-loading>
@if (fetching$ | async) {
<page-search-result-item-loading [primaryOutletActive]="false"></page-search-result-item-loading>
}
</cdk-virtual-scroll-viewport>
<div class="actions z-sticky h-0">
<button
[disabled]="loading$ | async"
*ngIf="(selectedItemIds$ | async)?.length > 0"
class="cta-cart cta-action-primary"
(click)="addToCart()"
>
<ui-spinner [show]="loading$ | async">In den Warenkorb legen</ui-spinner>
</button>
@if ((selectedItemIds$ | async)?.length > 0) {
<button
[disabled]="loading$ | async"
class="cta-cart cta-action-primary"
(click)="addToCart()"
>
<ui-spinner [show]="loading$ | async">In den Warenkorb legen</ui-spinner>
</button>
}
</div>
</ng-template>
}

View File

@@ -22,12 +22,21 @@ import { UiErrorModalComponent, UiModalService } from '@ui/modal';
import { CacheService } from '@core/cache';
import { isEqual } from 'lodash';
import { BehaviorSubject, combineLatest, Subscription } from 'rxjs';
import { debounceTime, first, map, switchMap, withLatestFrom } from 'rxjs/operators';
import {
debounceTime,
first,
map,
switchMap,
withLatestFrom,
} from 'rxjs/operators';
import { ArticleSearchService } from '../article-search.store';
import { AddedToCartModalComponent } from './added-to-cart-modal/added-to-cart-modal.component';
import { SearchResultItemComponent } from './search-result-item.component';
import { ProductCatalogNavigationService } from '@shared/services/navigation';
import { Filter, FilterInputGroupMainComponent } from '@shared/components/filter';
import {
Filter,
FilterInputGroupMainComponent,
} from '@shared/components/filter';
import { DomainAvailabilityService, ItemData } from '@domain/availability';
import { asapScheduler } from 'rxjs';
import { ShellService } from '@shared/shell';
@@ -39,8 +48,11 @@ import { ShellService } from '@shared/shell';
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: false,
})
export class ArticleSearchResultsComponent implements OnInit, OnDestroy, AfterViewInit {
@ViewChildren(SearchResultItemComponent) listItems: QueryList<SearchResultItemComponent>;
export class ArticleSearchResultsComponent
implements OnInit, OnDestroy, AfterViewInit
{
@ViewChildren(SearchResultItemComponent)
listItems: QueryList<SearchResultItemComponent>;
@ViewChild(CdkVirtualScrollViewport, { static: false })
scrollContainer: CdkVirtualScrollViewport;
@@ -59,7 +71,9 @@ export class ArticleSearchResultsComponent implements OnInit, OnDestroy, AfterVi
selectedItems$ = combineLatest([this.results$, this.selectedItemIds$]).pipe(
map(([items, selectedItemIds]) => {
return items?.filter((item) => selectedItemIds?.find((selectedItemId) => item.id === selectedItemId));
return items?.filter((item) =>
selectedItemIds?.find((selectedItemId) => item.id === selectedItemId),
);
}),
);
@@ -81,7 +95,10 @@ export class ArticleSearchResultsComponent implements OnInit, OnDestroy, AfterVi
return this._environment.matchDesktopLarge();
}
hasFilter$ = combineLatest([this.searchService.filter$, this.searchService.defaultSettings$]).pipe(
hasFilter$ = combineLatest([
this.searchService.filter$,
this.searchService.defaultSettings$,
]).pipe(
map(([filter, defaultFilter]) => {
const filterQueryParams = filter?.getQueryParams();
return !isEqual(
@@ -100,11 +117,15 @@ export class ArticleSearchResultsComponent implements OnInit, OnDestroy, AfterVi
}
get filterQueryParams() {
return this.cleanupQueryParams(this.searchService?.filter?.getQueryParams());
return this.cleanupQueryParams(
this.searchService?.filter?.getQueryParams(),
);
}
get primaryOutletActive$() {
return this._environment.matchDesktop$.pipe(map((matches) => matches && this.route.outlet === 'primary'));
return this._environment.matchDesktop$.pipe(
map((matches) => matches && this.route.outlet === 'primary'),
);
}
private readonly SCROLL_INDEX_TOKEN = 'CATALOG_RESULTS_LIST_SCROLL_INDEX';
@@ -129,28 +150,42 @@ export class ArticleSearchResultsComponent implements OnInit, OnDestroy, AfterVi
ngOnInit() {
this.subscriptions.add(
combineLatest([this.application.activatedProcessId$, this.route.queryParams])
combineLatest([
this.application.activatedProcessId$,
this.route.queryParams,
])
.pipe(
debounceTime(0),
switchMap(([processId, queryParams]) =>
this.application
.getSelectedBranch$(processId)
.pipe(map((selectedBranch) => ({ processId, queryParams, selectedBranch }))),
this.application.getSelectedBranch$(processId).pipe(
map((selectedBranch) => ({
processId,
queryParams,
selectedBranch,
})),
),
),
)
.subscribe(async ({ processId, queryParams, selectedBranch }) => {
const processChanged = processId !== this.searchService.processId;
const branchChanged = selectedBranch?.id !== this.searchService?.selectedBranch?.id;
const branchChanged =
selectedBranch?.id !== this.searchService?.selectedBranch?.id;
if (processChanged) {
if (!!this.searchService.processId && this.searchService.filter instanceof Filter) {
if (
!!this.searchService.processId &&
this.searchService.filter instanceof Filter
) {
this.cacheCurrentData(
this.searchService.processId,
this.searchService.filter.getQueryParams(),
this.searchService?.selectedBranch?.id,
);
this.updateBreadcrumbs(this.searchService.processId, this.searchService.filter.getQueryParams());
this.updateBreadcrumbs(
this.searchService.processId,
this.searchService.filter.getQueryParams(),
);
}
this.searchService.setProcess(processId);
}
@@ -169,9 +204,20 @@ export class ArticleSearchResultsComponent implements OnInit, OnDestroy, AfterVi
this.scrollToItem(await this._getScrollIndexFromCache());
}
if (!isEqual(cleanQueryParams, this.cleanupQueryParams(this.searchService.filter.getQueryParams()))) {
if (
!isEqual(
cleanQueryParams,
this.cleanupQueryParams(
this.searchService.filter.getQueryParams(),
),
)
) {
await this.searchService.setDefaultFilter(queryParams);
const data = await this.getCachedData(processId, queryParams, selectedBranch?.id);
const data = await this.getCachedData(
processId,
queryParams,
selectedBranch?.id,
);
if (data.items?.length > 0) {
this.searchService.setItems(data.items);
@@ -179,21 +225,29 @@ export class ArticleSearchResultsComponent implements OnInit, OnDestroy, AfterVi
}
if (
data.items?.length === 0 &&
this.route?.parent?.children?.find((childRoute) => childRoute?.outlet === 'side')?.snapshot?.routeConfig
?.path !== 'filter'
this.route?.parent?.children?.find(
(childRoute) => childRoute?.outlet === 'side',
)?.snapshot?.routeConfig?.path !== 'filter'
) {
this.search({ clear: true });
} else {
const selectedItemIds: Array<string> = queryParams?.selected_item_ids?.split(',') ?? [];
const selectedItemIds: Array<string> =
queryParams?.selected_item_ids?.split(',') ?? [];
for (const id of selectedItemIds) {
if (id) {
this.searchService.setSelected({ selected: true, itemId: Number(id) });
this.searchService.setSelected({
selected: true,
itemId: Number(id),
});
}
}
}
}
const process = await this.application.getProcessById$(processId).pipe(first()).toPromise();
const process = await this.application
.getProcessById$(processId)
.pipe(first())
.toPromise();
if (process) {
await this.updateBreadcrumbs(processId, queryParams);
await this.createBreadcrumb(processId, queryParams);
@@ -240,7 +294,10 @@ export class ArticleSearchResultsComponent implements OnInit, OnDestroy, AfterVi
})
.navigate();
}
} else if (searchCompleted?.clear || this.route.outlet === 'primary') {
} else if (
searchCompleted?.clear ||
this.route.outlet === 'primary'
) {
const ean = this.route?.snapshot?.params?.ean;
if (ean) {
@@ -253,7 +310,9 @@ export class ArticleSearchResultsComponent implements OnInit, OnDestroy, AfterVi
.navigate();
} else {
await this._navigationService
.getArticleSearchResultsPath(processId, { queryParams: params })
.getArticleSearchResultsPath(processId, {
queryParams: params,
})
.navigate();
}
}
@@ -266,7 +325,9 @@ export class ArticleSearchResultsComponent implements OnInit, OnDestroy, AfterVi
this.searchService.searchStarted.subscribe(async (options) => {
if (!options?.clear) {
const queryParams = {
...this.cleanupQueryParams(this.searchService.filter.getQueryParams()),
...this.cleanupQueryParams(
this.searchService.filter.getQueryParams(),
),
main_qs: this.sharedFilterInputGroupMain?.uiInput?.value,
};
@@ -281,11 +342,19 @@ export class ArticleSearchResultsComponent implements OnInit, OnDestroy, AfterVi
}
private _addScrollIndexToCache(index: number): void {
this.cache.set<number>({ processId: this.getProcessId(), token: this.SCROLL_INDEX_TOKEN }, index);
this.cache.set<number>(
{ processId: this.getProcessId(), token: this.SCROLL_INDEX_TOKEN },
index,
);
}
private async _getScrollIndexFromCache(): Promise<number> {
return (await this.cache.get<number>({ processId: this.getProcessId(), token: this.SCROLL_INDEX_TOKEN })) ?? 0;
return (
(await this.cache.get<number>({
processId: this.getProcessId(),
token: this.SCROLL_INDEX_TOKEN,
})) ?? 0
);
}
async scrollToItem(i?: number) {
@@ -303,12 +372,14 @@ export class ArticleSearchResultsComponent implements OnInit, OnDestroy, AfterVi
}
scrolledIndexChange(index: number) {
const completeListFetched = this.searchService.items.length === this.searchService.hits;
const completeListFetched =
this.searchService.items.length === this.searchService.hits;
if (
index &&
!completeListFetched &&
this.searchService.items.length <= this.scrollContainer?.getRenderedRange()?.end
this.searchService.items.length <=
this.scrollContainer?.getRenderedRange()?.end
) {
this.search({ clear: false });
}
@@ -326,7 +397,10 @@ export class ArticleSearchResultsComponent implements OnInit, OnDestroy, AfterVi
this.searchService.filter.getQueryParams(),
this.searchService?.selectedBranch?.id,
);
await this.updateBreadcrumbs(this.searchService.processId, this.searchService.filter.getQueryParams());
await this.updateBreadcrumbs(
this.searchService.processId,
this.searchService.filter.getQueryParams(),
);
this.unselectAll();
}
@@ -345,7 +419,15 @@ export class ArticleSearchResultsComponent implements OnInit, OnDestroy, AfterVi
return clean;
}
search({ filter, clear = false, orderBy = false }: { filter?: Filter; clear?: boolean; orderBy?: boolean }) {
search({
filter,
clear = false,
orderBy = false,
}: {
filter?: Filter;
clear?: boolean;
orderBy?: boolean;
}) {
if (filter) {
this.sharedFilterInputGroupMain.cancelAutocomplete();
}
@@ -354,19 +436,28 @@ export class ArticleSearchResultsComponent implements OnInit, OnDestroy, AfterVi
}
getDetailsPath(itemId: number) {
return this._navigationService.getArticleDetailsPath({ processId: this.application.activatedProcessId, itemId })
.path;
return this._navigationService.getArticleDetailsPath({
processId: this.application.activatedProcessId,
itemId,
}).path;
}
async updateBreadcrumbs(
processId: number = this.searchService.processId,
queryParams: Record<string, string> = this.searchService.filter?.getQueryParams(),
queryParams: Record<
string,
string
> = this.searchService.filter?.getQueryParams(),
) {
const selected_item_ids = this.searchService?.selectedItemIds?.toString();
if (queryParams) {
const crumbs = await this.breadcrumb
.getBreadcrumbsByKeyAndTags$(processId, ['catalog', 'filter', 'results'])
.getBreadcrumbsByKeyAndTags$(processId, [
'catalog',
'filter',
'results',
])
.pipe(first())
.toPromise();
@@ -382,13 +473,18 @@ export class ArticleSearchResultsComponent implements OnInit, OnDestroy, AfterVi
}
}
async createBreadcrumb(processId: number, queryParams: Record<string, string>) {
async createBreadcrumb(
processId: number,
queryParams: Record<string, string>,
) {
if (queryParams) {
const name = queryParams.main_qs ? queryParams.main_qs : 'Alle Artikel';
await this.breadcrumb.addBreadcrumbIfNotExists({
key: processId,
name,
path: this._navigationService.getArticleSearchResultsPath(processId, { queryParams }).path,
path: this._navigationService.getArticleSearchResultsPath(processId, {
queryParams,
}).path,
params: queryParams,
section: 'customer',
tags: ['catalog', 'filter', 'results'],
@@ -405,8 +501,16 @@ export class ArticleSearchResultsComponent implements OnInit, OnDestroy, AfterVi
crumbs?.forEach((crumb) => this.breadcrumb.removeBreadcrumb(crumb.id));
}
cacheCurrentData(processId: number, params: Record<string, string> = {}, branchId: number) {
const qparams = this.cleanupQueryParams({ ...params, processId: String(processId), branchId: String(branchId) });
cacheCurrentData(
processId: number,
params: Record<string, string> = {},
branchId: number,
) {
const qparams = this.cleanupQueryParams({
...params,
processId: String(processId),
branchId: String(branchId),
});
this.cache.set(qparams, {
items: this.searchService.items,
@@ -414,8 +518,16 @@ export class ArticleSearchResultsComponent implements OnInit, OnDestroy, AfterVi
});
}
async getCachedData(processId: number, params: Record<string, string> = {}, branchId: number) {
const qparams = this.cleanupQueryParams({ ...params, processId: String(processId), branchId: String(branchId) });
async getCachedData(
processId: number,
params: Record<string, string> = {},
branchId: number,
) {
const qparams = this.cleanupQueryParams({
...params,
processId: String(processId),
branchId: String(branchId),
});
const cacheData = await this.cache.get<{
items: ItemDTO[];
hits: number;
@@ -452,7 +564,12 @@ export class ArticleSearchResultsComponent implements OnInit, OnDestroy, AfterVi
}
unselectAll() {
this.listItems.forEach((listItem) => this.searchService.setSelected({ selected: false, itemId: listItem.item.id }));
this.listItems.forEach((listItem) =>
this.searchService.setSelected({
selected: false,
itemId: listItem.item.id,
}),
);
this.searchService.patchState({ selectedItemIds: [] });
}
@@ -474,39 +591,46 @@ export class ArticleSearchResultsComponent implements OnInit, OnDestroy, AfterVi
availability: {
availabilityType: item?.catalogAvailability?.status,
price: item?.catalogAvailability?.price,
supplierProductNumber: item?.ids?.dig ? String(item?.ids?.dig) : item?.product?.supplierProductNumber,
supplierProductNumber: item?.ids?.dig
? String(item?.ids?.dig)
: item?.product?.supplierProductNumber,
},
product: {
catalogProductNumber: String(item?.id),
...item?.product,
},
itemType: item?.type,
promotion: { points: item?.promoPoints },
promotion: { value: item?.promoPoints },
};
}
async addItemsToCart(item?: ItemDTO) {
const selectedItems = !item ? await this.selectedItems$.pipe(first()).toPromise() : [item];
const selectedItems = !item
? await this.selectedItems$.pipe(first()).toPromise()
: [item];
const items: AddToShoppingCartDTO[] = [];
const canAddItemsPayload = [];
for (const item of selectedItems) {
const isDownload = item?.product?.format === 'EB' || item?.product?.format === 'DL';
const isDownload =
item?.product?.format === 'EB' || item?.product?.format === 'DL';
const price = item?.catalogAvailability?.price;
const shoppingCartItem: AddToShoppingCartDTO = {
quantity: 1,
availability: {
availabilityType: item?.catalogAvailability?.status,
price,
supplierProductNumber: item?.ids?.dig ? String(item.ids?.dig) : item?.product?.supplierProductNumber,
supplierProductNumber: item?.ids?.dig
? String(item.ids?.dig)
: item?.product?.supplierProductNumber,
},
product: {
catalogProductNumber: String(item?.id),
...item?.product,
},
itemType: item.type,
promotion: { points: item?.promoPoints },
promotion: { value: item?.promoPoints },
};
if (isDownload) {
@@ -519,9 +643,14 @@ export class ArticleSearchResultsComponent implements OnInit, OnDestroy, AfterVi
.getDownloadAvailability({ item: downloadItem })
.pipe(first())
.toPromise();
shoppingCartItem.destination = { data: { target: 16, logistician: downloadAvailability?.logistician } };
shoppingCartItem.destination = {
data: { target: 16, logistician: downloadAvailability?.logistician },
};
if (downloadAvailability) {
shoppingCartItem.availability = { ...shoppingCartItem.availability, ...downloadAvailability };
shoppingCartItem.availability = {
...shoppingCartItem.availability,
...downloadAvailability,
};
}
canAddItemsPayload.push({
availabilities: [{ ...item.catalogAvailability, format: 'DL' }],
@@ -546,7 +675,10 @@ export class ArticleSearchResultsComponent implements OnInit, OnDestroy, AfterVi
if (response) {
const cantAdd = (response as any)?.filter((r) => r.status >= 2);
if (cantAdd?.length > 0) {
this.openModal({ itemLength: cantAdd.length, canAddMessage: cantAdd[0].message });
this.openModal({
itemLength: cantAdd.length,
canAddMessage: cantAdd[0].message,
});
return;
}
}
@@ -571,7 +703,15 @@ export class ArticleSearchResultsComponent implements OnInit, OnDestroy, AfterVi
}
}
openModal({ itemLength, canAddMessage, error }: { itemLength: number; canAddMessage?: string; error?: Error }) {
openModal({
itemLength,
canAddMessage,
error,
}: {
itemLength: number;
canAddMessage?: string;
error?: Error;
}) {
const modal = this._uiModal.open({
title:
!error && !canAddMessage

View File

@@ -9,87 +9,93 @@
</p>
</div>
</div>
<form *ngIf="control" [formGroup]="control" (submit)="submit()">
<ui-form-control class="searchbox-control" label="EAN/ISBN">
<ui-searchbox
formControlName="ean"
[query]="query$ | async"
(queryChange)="setQuery($event)"
(search)="search($event)"
(scan)="search($event)"
[loading]="loading$ | async"
[hint]="message$ | async"
tabindex="0"
[scanner]="true"
></ui-searchbox>
</ui-form-control>
<ui-form-control label="Titel" requiredMark="*">
<input tabindex="0" uiInput formControlName="name" />
</ui-form-control>
<div class="control-row">
<ui-form-control label="Menge" requiredMark="*">
<input tabindex="0" uiInput formControlName="quantity" />
@if (control) {
<form [formGroup]="control" (submit)="submit()">
<ui-form-control class="searchbox-control" label="EAN/ISBN">
<ui-searchbox
formControlName="ean"
[query]="query$ | async"
(queryChange)="setQuery($event)"
(search)="search($event)"
(scan)="search($event)"
[loading]="loading$ | async"
[hint]="message$ | async"
tabindex="0"
[scanner]="true"
></ui-searchbox>
</ui-form-control>
<ui-form-control class="datepicker" label="vsl. Lieferdatum" requiredMark="*">
<button
tabindex="-1"
class="date-btn"
type="button"
[class.content-selected]="!!(estimatedShippingDate$ | async)"
[uiOverlayTrigger]="uiDatepicker"
#datepicker="uiOverlayTrigger"
>
<strong>
{{ estimatedShippingDate$ | async | date: 'dd.MM.yy' }}
</strong>
<ui-icon icon="arrow_head" class="dp-button-icon" size="20px" [rotate]="datepicker.opened ? '270deg' : '90deg'"></ui-icon>
</button>
<ui-datepicker
formControlName="estimatedShippingDate"
#uiDatepicker
yPosition="below"
xPosition="after"
[min]="minDate"
[disabledDaysOfWeek]="[0]"
[selected]="estimatedShippingDate$ | async"
saveLabel="Übernehmen"
(save)="changeEstimatedShippingDate($event); uiDatepicker.close()"
></ui-datepicker>
<ui-form-control label="Titel" requiredMark="*">
<input tabindex="0" uiInput formControlName="name" />
</ui-form-control>
</div>
<ui-form-control label="Autor">
<input tabindex="0" uiInput formControlName="contributors" />
</ui-form-control>
<ui-form-control label="Verlag">
<input tabindex="0" uiInput formControlName="manufacturer" />
</ui-form-control>
<ui-form-control class="supplier-dropdown" label="Lieferant" requiredMark="*">
<ui-select tabindex="-1" formControlName="supplier">
<ui-select-option *ngFor="let supplier of suppliers$ | async" [label]="supplier.name" [value]="supplier.id"></ui-select-option>
</ui-select>
</ui-form-control>
<div class="control-row">
<ui-form-control class="price" label="Stückpreis" [suffix]="price.value ? '' : ''" requiredMark="*">
<input tabindex="0" #price uiInput formControlName="price" />
<div class="control-row">
<ui-form-control label="Menge" requiredMark="*">
<input tabindex="0" uiInput formControlName="quantity" />
</ui-form-control>
<ui-form-control class="datepicker" label="vsl. Lieferdatum" requiredMark="*">
<button
tabindex="-1"
class="date-btn"
type="button"
[class.content-selected]="!!(estimatedShippingDate$ | async)"
[uiOverlayTrigger]="uiDatepicker"
#datepicker="uiOverlayTrigger"
>
<strong>
{{ estimatedShippingDate$ | async | date: 'dd.MM.yy' }}
</strong>
<ui-icon icon="arrow_head" class="dp-button-icon" size="20px" [rotate]="datepicker.opened ? '270deg' : '90deg'"></ui-icon>
</button>
<ui-datepicker
formControlName="estimatedShippingDate"
#uiDatepicker
yPosition="below"
xPosition="after"
[min]="minDate"
[disabledDaysOfWeek]="[0]"
[selected]="estimatedShippingDate$ | async"
saveLabel="Übernehmen"
(save)="changeEstimatedShippingDate($event); uiDatepicker.close()"
></ui-datepicker>
</ui-form-control>
</div>
<ui-form-control label="Autor">
<input tabindex="0" uiInput formControlName="contributors" />
</ui-form-control>
<ui-form-control class="mwst-dropdown" label="MwSt" requiredMark="*">
<ui-select tabindex="-1" formControlName="vat">
<ui-select-option *ngFor="let vat of vats$ | async" [label]="vat.name + '%'" [value]="vat.vatType"></ui-select-option>
<ui-form-control label="Verlag">
<input tabindex="0" uiInput formControlName="manufacturer" />
</ui-form-control>
<ui-form-control class="supplier-dropdown" label="Lieferant" requiredMark="*">
<ui-select tabindex="-1" formControlName="supplier">
@for (supplier of suppliers$ | async; track supplier) {
<ui-select-option [label]="supplier.name" [value]="supplier.id"></ui-select-option>
}
</ui-select>
</ui-form-control>
</div>
<div class="actions">
<button
class="cta-secondary"
(click)="nextItem()"
[disabled]="control.invalid || control.disabled || (loading$ | async)"
type="button"
>
Weitere Artikel hinzufügen
</button>
<button class="cta-primary" [disabled]="control.invalid || control.disabled || (loading$ | async)" type="submit">
Bestellung anlegen
</button>
</div>
</form>
<div class="control-row">
<ui-form-control class="price" label="Stückpreis" [suffix]="price.value ? '' : ''" requiredMark="*">
<input tabindex="0" #price uiInput formControlName="price" />
</ui-form-control>
<ui-form-control class="mwst-dropdown" label="MwSt" requiredMark="*">
<ui-select tabindex="-1" formControlName="vat">
@for (vat of vats$ | async; track vat) {
<ui-select-option [label]="vat.name + '%'" [value]="vat.vatType"></ui-select-option>
}
</ui-select>
</ui-form-control>
</div>
<div class="actions">
<button
class="cta-secondary"
(click)="nextItem()"
[disabled]="control.invalid || control.disabled || (loading$ | async)"
type="button"
>
Weitere Artikel hinzufügen
</button>
<button class="cta-primary" [disabled]="control.invalid || control.disabled || (loading$ | async)" type="submit">
Bestellung anlegen
</button>
</div>
</form>
}
</div>

View File

@@ -114,15 +114,21 @@ export class CheckoutDummyStore extends ComponentStore<CheckoutDummyState> {
readonly processId$ = this._application.activatedProcessId$;
readonly customer$ = this.processId$.pipe(switchMap((processId) => this._checkoutService.getBuyer({ processId })));
readonly customer$ = this.processId$.pipe(
switchMap((processId) => this._checkoutService.getBuyer({ processId })),
);
readonly customerFeatures$ = this.processId$.pipe(
switchMap((processId) => this._checkoutService.getCustomerFeatures({ processId })),
switchMap((processId) =>
this._checkoutService.getCustomerFeatures({ processId }),
),
);
readonly customerFilter$ = this.customerFeatures$.pipe(
withLatestFrom(this.processId$),
switchMap(([customerFeatures, processId]) => this._checkoutService.canSetCustomer({ processId, customerFeatures })),
switchMap(([customerFeatures, processId]) =>
this._checkoutService.canSetCustomer({ processId, customerFeatures }),
),
map((res) => res.filter),
);
@@ -169,7 +175,11 @@ export class CheckoutDummyStore extends ComponentStore<CheckoutDummyState> {
tapResponse(
(res) => {
const item = res.result[0];
if (!!item && item?.product?.format !== 'EB' && item?.product?.format !== 'DL') {
if (
!!item &&
item?.product?.format !== 'EB' &&
item?.product?.format !== 'DL'
) {
this.patchState({
item: res.result[0],
message: '',
@@ -229,12 +239,22 @@ export class CheckoutDummyStore extends ComponentStore<CheckoutDummyState> {
updateCart = this.effect((cb$: Observable<Function>) =>
cb$.pipe(
tap((_) => this.patchState({ fetching: true })),
withLatestFrom(this.processId$, this.addToCartItem$, this.shoppingCartItemId$),
withLatestFrom(
this.processId$,
this.addToCartItem$,
this.shoppingCartItemId$,
),
switchMap(([cb, processId, newItem, shoppingCartItemId]) => {
const availability = newItem.availability;
const quantity = newItem.quantity;
const destination = newItem.destination;
return this.updateCartRequest({ processId, shoppingCartItemId, availability, quantity, destination }).pipe(
return this.updateCartRequest({
processId,
shoppingCartItemId,
availability,
quantity,
destination,
}).pipe(
tapResponse(
(res) => {
this.patchState({
@@ -270,7 +290,10 @@ export class CheckoutDummyStore extends ComponentStore<CheckoutDummyState> {
}
addToCartRequest(processId: number, newItem: AddToShoppingCartDTO) {
return this._checkoutService.addItemToShoppingCart({ processId, items: [newItem] });
return this._checkoutService.addItemToShoppingCart({
processId,
items: [newItem],
});
}
updateCartRequest({
@@ -297,7 +320,11 @@ export class CheckoutDummyStore extends ComponentStore<CheckoutDummyState> {
});
}
async createAddToCartItem(control: UntypedFormGroup, branch: BranchDTO, update?: boolean) {
async createAddToCartItem(
control: UntypedFormGroup,
branch: BranchDTO,
update?: boolean,
) {
let item: ItemDTO;
const quantity = Number(control.get('quantity').value);
const price = this._createPriceDTO(control);
@@ -305,7 +332,11 @@ export class CheckoutDummyStore extends ComponentStore<CheckoutDummyState> {
// Check if item exists or ean inside the control changed in the meantime
if (!!this.item && this.item.product.ean === control.get('ean').value) {
item = this.item;
promoPoints = await this._getPromoPoints({ itemId: item.id, quantity, price: price.value.value });
promoPoints = await this._getPromoPoints({
itemId: item.id,
quantity,
price: price.value.value,
});
} else {
item = undefined;
}
@@ -316,21 +347,33 @@ export class CheckoutDummyStore extends ComponentStore<CheckoutDummyState> {
quantity,
availability,
product,
promotion: item ? { points: promoPoints } : undefined,
promotion: item ? { value: promoPoints } : undefined,
destination: {
data: { target: 1, targetBranch: { id: branch?.id } },
},
itemType: this.item?.type ?? this.get((s) => s.shoppingCartItem)?.itemType,
itemType:
this.item?.type ?? this.get((s) => s.shoppingCartItem)?.itemType,
};
if (update) {
const existingItem = this.get((s) => s.shoppingCartItem);
this.patchState({ addToCartItem: newItem, shoppingCartItemId: existingItem?.id });
this.patchState({
addToCartItem: newItem,
shoppingCartItemId: existingItem?.id,
});
}
this.patchState({ addToCartItem: newItem });
}
private async _getPromoPoints({ itemId, quantity, price }: { itemId: number; quantity: number; price: number }) {
private async _getPromoPoints({
itemId,
quantity,
price,
}: {
itemId: number;
quantity: number;
price: number;
}) {
let points: number;
try {
points = await this._catalogService
@@ -371,7 +414,13 @@ export class CheckoutDummyStore extends ComponentStore<CheckoutDummyState> {
};
}
private _createAvailabilityDTO({ price, control }: { price: PriceDTO; control: UntypedFormGroup }): AvailabilityDTO {
private _createAvailabilityDTO({
price,
control,
}: {
price: PriceDTO;
control: UntypedFormGroup;
}): AvailabilityDTO {
return {
availabilityType: 1024,
supplier: {
@@ -383,7 +432,13 @@ export class CheckoutDummyStore extends ComponentStore<CheckoutDummyState> {
};
}
private _createProductDTO({ item, control }: { item?: ItemDTO; control: UntypedFormGroup }): ProductDTO {
private _createProductDTO({
item,
control,
}: {
item?: ItemDTO;
control: UntypedFormGroup;
}): ProductDTO {
const formValues: Partial<ProductDTO> = {
ean: control.get('ean').value,
name: control.get('name').value,

View File

@@ -1,10 +1,9 @@
<ng-container *ngIf="(groupedItems$ | async)?.length <= 0 && !(fetching$ | async); else shoppingCart">
@if ((groupedItems$ | async)?.length <= 0 && !(fetching$ | async)) {
<div class="card stretch">
<div class="empty-message">
<span class="cart-icon flex items-center justify-center">
<shared-icon icon="shopping-cart-bold" [size]="24"></shared-icon>
</span>
<h1>Ihr Warenkorb ist leer.</h1>
<p>
Sie haben alle Artikel aus dem
@@ -13,84 +12,75 @@
<br />
keinen Artikel hinzugefügt.
</p>
<div class="btn-wrapper">
<a class="cta-primary" [routerLink]="productSearchBasePath">Artikel suchen</a>
<button class="cta-secondary" (click)="openDummyModal({})">Neuanlage</button>
</div>
</div>
</div>
</ng-container>
<div class="flex items-center justify-center card stretch" *ngIf="fetching$ | async">
<ui-spinner [show]="true"></ui-spinner>
</div>
<ng-template #shoppingCart>
<ng-container *ngIf="shoppingCart$ | async; let shoppingCart">
} @else {
@if (shoppingCart$ | async; as shoppingCart) {
<div class="card stretch">
<div class="cta-print-wrapper">
<button class="cta-print" (click)="openPrintModal()">Drucken</button>
</div>
<h1 class="header">Warenkorb</h1>
<ng-container *ngIf="!(isDesktop$ | async)">
@if (!(isDesktop$ | async)) {
<page-checkout-review-details></page-checkout-review-details>
</ng-container>
<ng-container *ngFor="let group of groupedItems$ | async; let lastGroup = last; trackBy: trackByGroupedItems">
<ng-container *ngIf="group?.orderType !== undefined">
}
@for (group of groupedItems$ | async; track trackByGroupedItems($index, group); let lastGroup = $last) {
@if (group?.orderType !== undefined) {
<hr />
<div class="row item-group-header bg-[#F5F7FA]">
<shared-icon
*ngIf="group.orderType !== 'Dummy'"
class="icon-order-type"
[size]="group.orderType === 'B2B-Versand' ? 36 : 24"
[icon]="group.orderType"
></shared-icon>
@if (group.orderType !== 'Dummy') {
<shared-icon
class="icon-order-type"
[size]="group.orderType === 'B2B-Versand' ? 36 : 24"
[icon]="group.orderType"
></shared-icon>
}
<div class="label" [class.dummy]="group.orderType === 'Dummy'">
{{ group.orderType !== 'Dummy' ? group.orderType : 'Manuelle Anlage / Dummy Bestellung' }}
<button
*ngIf="group.orderType === 'Dummy'"
class="text-brand border-none font-bold text-p1 outline-none pl-4"
(click)="openDummyModal({ changeDataFromCart: true })"
>
Hinzufügen
</button>
@if (group.orderType === 'Dummy') {
<button
class="text-brand border-none font-bold text-p1 outline-none pl-4"
(click)="openDummyModal({ changeDataFromCart: true })"
>
Hinzufügen
</button>
}
</div>
<div class="grow"></div>
<div class="pl-4" *ngIf="group.orderType !== 'Download' && group.orderType !== 'Dummy'">
<button class="cta-edit" (click)="showPurchasingListModal(group.items)">Ändern</button>
</div>
@if (group.orderType !== 'Download' && group.orderType !== 'Dummy') {
<div class="pl-4">
<button class="cta-edit" (click)="showPurchasingListModal(group.items)">Ändern</button>
</div>
}
</div>
<hr
*ngIf="
group.orderType === 'Download' ||
group.orderType === 'Versand' ||
group.orderType === 'B2B-Versand' ||
group.orderType === 'DIG-Versand'
"
/>
</ng-container>
<ng-container *ngFor="let item of group.items; let lastItem = last; let i = index; trackBy: trackByItemId">
<ng-container
*ngIf="group?.orderType !== undefined && (item.features?.orderType === 'Abholung' || item.features?.orderType === 'Rücklage')"
>
<ng-container *ngIf="item?.destination?.data?.targetBranch?.data; let targetBranch">
<ng-container *ngIf="i === 0 || checkIfMultipleDestinationsForOrderTypeExist(targetBranch, group, i)">
@if (
group.orderType === 'Download' ||
group.orderType === 'Versand' ||
group.orderType === 'B2B-Versand' ||
group.orderType === 'DIG-Versand'
) {
<hr
/>
}
}
@for (item of group.items; track trackByItemId(i, item); let lastItem = $last; let i = $index) {
@if (group?.orderType !== undefined && (item.features?.orderType === 'Abholung' || item.features?.orderType === 'Rücklage')) {
@if (item?.destination?.data?.targetBranch?.data; as targetBranch) {
@if (i === 0 || checkIfMultipleDestinationsForOrderTypeExist(targetBranch, group, i)) {
<div
class="flex flex-row items-center px-5 pt-0 pb-[0.875rem] -mt-2 bg-[#F5F7FA]"
[class.multiple-destinations]="checkIfMultipleDestinationsForOrderTypeExist(targetBranch, group, i)"
>
>
<span class="branch-name">{{ targetBranch?.name }} | {{ targetBranch | branchAddress }}</span>
</div>
<hr />
</ng-container>
</ng-container>
</ng-container>
}
}
}
<page-shopping-cart-item
(changeItem)="changeItem($event)"
(changeDummyItem)="changeDummyItem($event)"
@@ -101,19 +91,22 @@
[loadingOnItemChangeById]="loadingOnItemChangeById$ | async"
[loadingOnQuantityChangeById]="loadingOnQuantityChangeById$ | async"
></page-shopping-cart-item>
<hr *ngIf="!lastItem" />
</ng-container>
</ng-container>
@if (!lastItem) {
<hr />
}
}
}
<div class="h-[8.9375rem]"></div>
</div>
<div class="card footer flex flex-col justify-center items-center">
<div class="flex flex-row items-start justify-between w-full mb-1">
<ng-container *ngIf="totalItemCount$ | async; let totalItemCount">
<div *ngIf="totalReadingPoints$ | async; let totalReadingPoints" class="total-item-reading-points w-full">
{{ totalItemCount }} Artikel | {{ totalReadingPoints }} Lesepunkte
</div>
</ng-container>
@if (totalItemCount$ | async; as totalItemCount) {
@if (totalReadingPoints$ | async; as totalReadingPoints) {
<div class="total-item-reading-points w-full">
{{ totalItemCount }} Artikel | {{ totalReadingPoints }} Lesepunkte
</div>
}
}
<div class="flex flex-col w-full">
<strong class="total-value">
Zwischensumme {{ shoppingCart?.total?.value | currency: shoppingCart?.total?.currency : 'code' }}
@@ -130,11 +123,18 @@
notificationsControl?.invalid ||
((primaryCtaLabel$ | async) === 'Bestellen' && ((checkingOla$ | async) || (checkoutIsInValid$ | async)))
"
>
>
<ui-spinner [show]="showOrderButtonSpinner">
{{ primaryCtaLabel$ | async }}
</ui-spinner>
</button>
</div>
</ng-container>
</ng-template>
}
}
@if (fetching$ | async) {
<div class="flex items-center justify-center card stretch">
<ui-spinner [show]="true"></ui-spinner>
</div>
}

View File

@@ -2,56 +2,61 @@
Überprüfen Sie die Details.
</h1>
<ng-container *ngIf="buyer$ | async; let buyer">
<div *ngIf="!(showAddresses$ | async)" class="flex flex-row items-start justify-between p-5">
<div class="flex flex-row flex-wrap pr-4">
<ng-container *ngIf="getNameFromBuyer(buyer); let name">
<div class="mr-3">{{ name.label }}</div>
<div class="font-bold">{{ name.value }}</div>
</ng-container>
</div>
<button (click)="changeAddress()" class="text-p1 font-bold text-[#F70400]">Ändern</button>
</div>
</ng-container>
<ng-container *ngIf="showNotificationChannels$ | async">
<form class="pb-4" *ngIf="control" [formGroup]="control">
<shared-notification-channel-control
[communicationDetails]="communicationDetails$ | async"
(channelActionEvent)="updateNotifications($event)"
[channelActionName]="'Speichern'"
[channelActionLoading]="notificationChannelLoading$ | async"
formGroupName="notificationChannel"
></shared-notification-channel-control>
</form>
</ng-container>
<ng-container *ngIf="payer$ | async; let payer">
<div *ngIf="showAddresses$ | async" class="flex flex-row items-start justify-between p-5 pt-0">
<div class="flex flex-row flex-wrap pr-4" data-address-type="Rechnungsadresse" data-which="Rechnungsadresse">
<div class="mr-3" data-what="title">Rechnungsadresse</div>
<div class="font-bold" data-what="address">
{{ payer | payerAddress }}
@if (buyer$ | async; as buyer) {
@if (!(showAddresses$ | async)) {
<div class="flex flex-row items-start justify-between p-5">
<div class="flex flex-row flex-wrap pr-4">
@if (getNameFromBuyer(buyer); as name) {
<div class="mr-3">{{ name.label }}</div>
<div class="font-bold">{{ name.value }}</div>
}
</div>
<button (click)="changeAddress()" class="text-p1 font-bold text-[#F70400]">Ändern</button>
</div>
}
}
<button (click)="changeAddress()" class="text-p1 font-bold text-[#F70400]">Ändern</button>
</div>
</ng-container>
@if (showNotificationChannels$ | async) {
@if (control) {
<form class="pb-4" [formGroup]="control">
<shared-notification-channel-control
[communicationDetails]="communicationDetails$ | async"
(channelActionEvent)="updateNotifications($event)"
[channelActionName]="'Speichern'"
[channelActionLoading]="notificationChannelLoading$ | async"
formGroupName="notificationChannel"
></shared-notification-channel-control>
</form>
}
}
<ng-container *ngIf="payer$ | async; let payer">
<div *ngIf="showAddresses$ | async" class="flex flex-row items-start justify-between px-5">
<div class="flex flex-row flex-wrap pr-4" data-address-type="Lieferadresse" data-which="Lieferadresse">
<div class="mr-3" data-what="title">Lieferadresse</div>
<div class="font-bold" data-what="address">
{{ shippingAddress$ | async | shippingAddress }}
@if (payer$ | async; as payer) {
@if (showAddresses$ | async) {
<div class="flex flex-row items-start justify-between p-5 pt-0">
<div class="flex flex-row flex-wrap pr-4" data-address-type="Rechnungsadresse" data-which="Rechnungsadresse">
<div class="mr-3" data-what="title">Rechnungsadresse</div>
<div class="font-bold" data-what="address">
{{ payer | payerAddress }}
</div>
</div>
<button (click)="changeAddress()" class="text-p1 font-bold text-[#F70400]">Ändern</button>
</div>
}
}
<button (click)="changeAddress()" class="text-p1 font-bold text-[#F70400]">Ändern</button>
</div>
</ng-container>
@if (payer$ | async; as payer) {
@if (showAddresses$ | async) {
<div class="flex flex-row items-start justify-between px-5">
<div class="flex flex-row flex-wrap pr-4" data-address-type="Lieferadresse" data-which="Lieferadresse">
<div class="mr-3" data-what="title">Lieferadresse</div>
<div class="font-bold" data-what="address">
{{ shippingAddress$ | async | shippingAddress }}
</div>
</div>
<button (click)="changeAddress()" class="text-p1 font-bold text-[#F70400]">Ändern</button>
</div>
}
}
<page-special-comment
class="mb-6 mt-4"

View File

@@ -1,18 +1,21 @@
<div class="item-thumbnail">
<a [routerLink]="productSearchDetailsPath" [queryParams]="{ main_qs: item?.product?.ean }">
<img loading="lazy" *ngIf="item?.product?.ean | productImage; let thumbnailUrl" [src]="thumbnailUrl" [alt]="item?.product?.name" />
@if (item?.product?.ean | productImage; as thumbnailUrl) {
<img loading="lazy" [src]="thumbnailUrl" [alt]="item?.product?.name" />
}
</a>
</div>
<div class="item-contributors">
<a
*ngFor="let contributor of contributors$ | async; let last = last"
[routerLink]="productSearchResultsPath"
[queryParams]="{ main_qs: contributor, main_author: 'author' }"
(click)="$event?.stopPropagation()"
>
{{ contributor }}{{ last ? '' : ';' }}
</a>
@for (contributor of contributors$ | async; track contributor; let last = $last) {
<a
[routerLink]="productSearchResultsPath"
[queryParams]="{ main_qs: contributor, main_author: 'author' }"
(click)="$event?.stopPropagation()"
>
{{ contributor }}{{ last ? '' : ';' }}
</a>
}
</div>
<div
@@ -21,86 +24,106 @@
[class.text-p1]="item?.product?.name?.length >= 50 || !isTablet"
[class.text-p2]="item?.product?.name?.length >= 60 && isTablet"
[class.text-p3]="item?.product?.name?.length >= 100"
>
>
<a [routerLink]="productSearchDetailsPath" [queryParams]="{ main_qs: item?.product?.ean }">{{ item?.product?.name }}</a>
</div>
<div class="item-format" *ngIf="item?.product?.format && item?.product?.formatDetail">
<img
*ngIf="item?.product?.format !== '--'"
src="assets/images/Icon_{{ item?.product?.format }}.svg"
[alt]="item?.product?.formatDetail"
/>
{{ item?.product?.formatDetail }}
</div>
@if (item?.product?.format && item?.product?.formatDetail) {
<div class="item-format">
@if (item?.product?.format !== '--') {
<img
src="assets/images/Icon_{{ item?.product?.format }}.svg"
[alt]="item?.product?.formatDetail"
/>
}
{{ item?.product?.formatDetail }}
</div>
}
<div class="item-info text-p2">
<div class="mb-1">{{ item?.product?.manufacturer | substr: 25 }} | {{ item?.product?.ean }}</div>
<div class="mb-1">
{{ item?.product?.volume }}
<span *ngIf="item?.product?.volume && item?.product?.publicationDate">|</span>
@if (item?.product?.volume && item?.product?.publicationDate) {
<span>|</span>
}
{{ item?.product?.publicationDate | date }}
</div>
<div *ngIf="notAvailable$ | async">
<span class="text-brand item-date">Nicht verfügbar</span>
</div>
<shared-skeleton-loader class="w-40" *ngIf="refreshingAvailabilit$ | async; else avaTmplt"></shared-skeleton-loader>
<ng-template #avaTmplt>
<div class="item-date" [class.availability-changed]="estimatedShippingDateChanged$ | async" *ngIf="orderType === 'Abholung'">
Abholung ab {{ item?.availability?.estimatedShippingDate | date }}
@if (notAvailable$ | async) {
<div>
<span class="text-brand item-date">Nicht verfügbar</span>
</div>
<div
class="item-date"
[class.availability-changed]="estimatedShippingDateChanged$ | async"
*ngIf="orderType === 'Versand' || orderType === 'B2B-Versand' || orderType === 'DIG-Versand'"
>
<ng-container *ngIf="item?.availability?.estimatedDelivery; else estimatedShippingDate">
Zustellung zwischen {{ (item?.availability?.estimatedDelivery?.start | date: 'EEE, dd.MM.')?.replace('.', '') }}
und
{{ (item?.availability?.estimatedDelivery?.stop | date: 'EEE, dd.MM.')?.replace('.', '') }}
</ng-container>
<ng-template #estimatedShippingDate>Versand {{ item?.availability?.estimatedShippingDate | date }}</ng-template>
</div>
</ng-template>
}
<div class="item-availability-message" *ngIf="olaError$ | async">Artikel nicht verfügbar</div>
@if (refreshingAvailabilit$ | async) {
<shared-skeleton-loader class="w-40"></shared-skeleton-loader>
} @else {
@if (orderType === 'Abholung') {
<div class="item-date" [class.availability-changed]="estimatedShippingDateChanged$ | async">
Abholung ab {{ item?.availability?.estimatedShippingDate | date }}
</div>
}
@if (orderType === 'Versand' || orderType === 'B2B-Versand' || orderType === 'DIG-Versand') {
<div
class="item-date"
[class.availability-changed]="estimatedShippingDateChanged$ | async"
>
@if (item?.availability?.estimatedDelivery) {
Zustellung zwischen {{ (item?.availability?.estimatedDelivery?.start | date: 'EEE, dd.MM.')?.replace('.', '') }}
und
{{ (item?.availability?.estimatedDelivery?.stop | date: 'EEE, dd.MM.')?.replace('.', '') }}
} @else {
Versand {{ item?.availability?.estimatedShippingDate | date }}
}
</div>
}
}
@if (olaError$ | async) {
<div class="item-availability-message">Artikel nicht verfügbar</div>
}
</div>
<div class="item-price-stock flex flex-col">
<div class="text-p2 font-bold">{{ item?.availability?.price?.value?.value | currency: 'EUR' : 'code' }}</div>
<div class="text-p2 font-normal">
<ui-quantity-dropdown
*ngIf="!(isDummy$ | async); else quantityDummy"
[ngModel]="item?.quantity"
(ngModelChange)="onChangeQuantity($event)"
[showSpinner]="(loadingOnQuantityChangeById$ | async) === item?.id"
[disabled]="(loadingOnItemChangeById$ | async) === item?.id"
[range]="quantityRange$ | async"
></ui-quantity-dropdown>
<ng-template #quantityDummy>
@if (!(isDummy$ | async)) {
<ui-quantity-dropdown
[ngModel]="item?.quantity"
(ngModelChange)="onChangeQuantity($event)"
[showSpinner]="(loadingOnQuantityChangeById$ | async) === item?.id"
[disabled]="(loadingOnItemChangeById$ | async) === item?.id"
[range]="quantityRange$ | async"
></ui-quantity-dropdown>
} @else {
<div class="mt-2">{{ item?.quantity }}x</div>
</ng-template>
</div>
<div class="quantity-error" *ngIf="quantityError">
{{ quantityError }}
}
</div>
@if (quantityError) {
<div class="quantity-error">
{{ quantityError }}
</div>
}
</div>
<div class="actions" *ngIf="orderType !== 'Download'">
<button
[disabled]="(loadingOnQuantityChangeById$ | async) === item?.id || (loadingOnItemChangeById$ | async) === item?.id"
(click)="onChangeItem()"
*ngIf="!(hasOrderType$ | async)"
>
<ui-spinner [show]="(loadingOnItemChangeById$ | async) === item?.id">Lieferweg auswählen</ui-spinner>
</button>
<button
[disabled]="(loadingOnQuantityChangeById$ | async) === item?.id || (loadingOnItemChangeById$ | async) === item?.id"
(click)="onChangeItem()"
*ngIf="canEdit$ | async"
>
<ui-spinner [show]="(loadingOnItemChangeById$ | async) === item?.id">Lieferweg ändern</ui-spinner>
</button>
</div>
@if (orderType !== 'Download') {
<div class="actions">
@if (!(hasOrderType$ | async)) {
<button
[disabled]="(loadingOnQuantityChangeById$ | async) === item?.id || (loadingOnItemChangeById$ | async) === item?.id"
(click)="onChangeItem()"
>
<ui-spinner [show]="(loadingOnItemChangeById$ | async) === item?.id">Lieferweg auswählen</ui-spinner>
</button>
}
@if (canEdit$ | async) {
<button
[disabled]="(loadingOnQuantityChangeById$ | async) === item?.id || (loadingOnItemChangeById$ | async) === item?.id"
(click)="onChangeItem()"
>
<ui-spinner [show]="(loadingOnItemChangeById$ | async) === item?.id">Lieferweg ändern</ui-spinner>
</button>
}
</div>
}

View File

@@ -24,11 +24,15 @@
></textarea>
<div class="comment-actions py-4">
<button type="reset" class="clear pl-4" *ngIf="!disabled && !!value" (click)="clear(); triggerResize()">
<shared-icon icon="close" [size]="24"></shared-icon>
</button>
@if (!disabled && !!value) {
<button type="reset" class="clear pl-4" (click)="clear(); triggerResize()">
<shared-icon icon="close" [size]="24"></shared-icon>
</button>
}
</div>
</div>
<div *ngIf="!(hasPayer || hasBuyer)" class="text-p3">Zur Info: Sie haben dem Warenkorb noch keinen Kunden hinzugefügt.</div>
@if (!(hasPayer || hasBuyer)) {
<div class="text-p3">Zur Info: Sie haben dem Warenkorb noch keinen Kunden hinzugefügt.</div>
}
</div>

View File

@@ -7,66 +7,63 @@
<h1 class="text-center text-h2 my-1 font-bold">Bestellbestätigung</h1>
<p class="text-center text-p1 mb-10">Nachfolgend erhalten Sie die Übersicht Ihrer Bestellung.</p>
<ng-container *ngFor="let displayOrder of displayOrders$ | async; let i = index; let orderLast = last">
<ng-container *ngIf="i === 0">
@for (displayOrder of displayOrders$ | async; track displayOrder; let i = $index; let orderLast = $last) {
@if (i === 0) {
<div class="flex flex-row items-center bg-white shadow-card min-h-[3.3125rem]">
<div class="text-h3 font-bold px-5 py-[0.875rem]">
{{ displayOrder?.buyer | buyerName }}
</div>
</div>
<hr />
</ng-container>
}
<div class="flex flex-row items-center bg-[#F5F7FA] min-h-[3.3125rem]">
<div class="flex flex-row items-center justify-center px-5 py-[0.875rem]">
<shared-icon
*ngIf="(displayOrder?.items)[0]?.features?.orderType !== 'Dummy'"
class="mr-2"
[size]="(displayOrder?.items)[0]?.features?.orderType === 'B2B-Versand' ? 36 : 24"
[icon]="(displayOrder?.items)[0]?.features?.orderType"
></shared-icon>
@if ((displayOrder?.items)[0]?.features?.orderType !== 'Dummy') {
<shared-icon
class="mr-2"
[size]="(displayOrder?.items)[0]?.features?.orderType === 'B2B-Versand' ? 36 : 24"
[icon]="(displayOrder?.items)[0]?.features?.orderType"
></shared-icon>
}
<p class="text-p1 font-bold mr-3">{{ (displayOrder?.items)[0]?.features?.orderType }}</p>
<div
*ngIf="
(displayOrder?.items)[0]?.features?.orderType === 'Abholung' || (displayOrder?.items)[0]?.features?.orderType === 'Rücklage';
else shippingAddress
"
>
{{ displayOrder.targetBranch?.name }}, {{ displayOrder.targetBranch | branchAddress }}
</div>
<ng-template #shippingAddress>
@if (
(displayOrder?.items)[0]?.features?.orderType === 'Abholung' || (displayOrder?.items)[0]?.features?.orderType === 'Rücklage') {
<div
>
{{ displayOrder.targetBranch?.name }}, {{ displayOrder.targetBranch | branchAddress }}
</div>
} @else {
{{ displayOrder.shippingAddress | branchAddress }}
</ng-template>
<div *ngIf="(displayOrder?.items)[0]?.features?.orderType === 'Download'">
| {{ displayOrder.buyer?.communicationDetails?.email }}
</div>
}
@if ((displayOrder?.items)[0]?.features?.orderType === 'Download') {
<div>
| {{ displayOrder.buyer?.communicationDetails?.email }}
</div>
}
</div>
</div>
<hr />
<div class="flex flex-row justify-between items-center">
<div class="flex flex-col px-5 py-4 bg-white" [attr.data-order-type]="(displayOrder?.items)[0]?.features?.orderType">
<div class="flex flex-row justify-between items-center mb-[0.375rem]">
<div class="flex flex-row">
<span class="w-32">Vorgangs-ID</span>
<ng-container *ngIf="customer$ | async; let customer">
<a
data-which="Vorgangs-ID"
data-what="link"
*ngIf="customer$ | async; let customer"
class="font-bold text-[#0556B4] no-underline"
[routerLink]="['/kunde', processId, 'customer', 'search', customer?.id, 'orders', displayOrder.id]"
[queryParams]="{ main_qs: customer?.customerNumber, filter_customertype: '' }"
>
{{ displayOrder.orderNumber }}
</a>
</ng-container>
@if (customer$ | async; as customer) {
@if (customer$ | async; as customer) {
<a
data-which="Vorgangs-ID"
data-what="link"
class="font-bold text-[#0556B4] no-underline"
[routerLink]="['/kunde', processId, 'customer', 'search', customer?.id, 'orders', displayOrder.id]"
[queryParams]="{ main_qs: customer?.customerNumber, filter_customertype: '' }"
>
{{ displayOrder.orderNumber }}
</a>
}
}
<ui-spinner class="text-[#0556B4] h-4 w-4" [show]="!(customer$ | async)"></ui-spinner>
</div>
</div>
<div class="flex flex-row justify-between items-center">
<div class="flex flex-row">
<span class="w-32">Bestelldatum</span>
@@ -74,14 +71,13 @@
</div>
</div>
</div>
<div class="mr-4">
<button
(click)="expanded[i] = !expanded[i]"
type="button"
class="text-[#0556B4] font-bold flex flex-row items-center justify-center"
[class.flex-row-reverse]="!expanded[i]"
>
>
<shared-icon
class="mr-1"
icon="arrow-back"
@@ -93,135 +89,139 @@
</button>
</div>
</div>
<ng-container *ngFor="let order of displayOrder.items; let last = last">
<ng-container *ngIf="expanded[i]">
@for (order of displayOrder.items; track order; let last = $last) {
@if (expanded[i]) {
<div
class="page-checkout-summary__items-tablet px-5 pb-[1.875rem] bg-white"
[class.page-checkout-summary__items]="isDesktop$ | async"
[class.last]="last"
>
>
<div class="page-checkout-summary__items-thumbnail flex flex-row">
<a [routerLink]="getProductSearchDetailsPath(order?.product?.ean)" [queryParams]="getProductSearchDetailsQueryParams(order)">
<img class="w-[3.125rem] max-h-20 mr-2" [src]="order.product?.ean | productImage: 195 : 315 : true" />
</a>
</div>
<div class="page-checkout-summary__items-title whitespace-nowrap overflow-ellipsis overflow-hidden">
<a
class="font-bold no-underline text-[#0556B4]"
[routerLink]="getProductSearchDetailsPath(order?.product?.ean)"
[queryParams]="getProductSearchDetailsQueryParams(order)"
>
>
{{ order?.product?.name }}
</a>
</div>
<div class="page-checkout-summary__items-ssc" *ngIf="(order?.subsetItems)[0]; let subsetItem">
<span class="mr-2">{{ subsetItem.supplierName }}</span>
<span *ngIf="subsetItem?.ssc && subsetItem?.sscText" class="font-bold border-l border-black pl-2">
{{ subsetItem.ssc }} - {{ subsetItem.sscText }}
</span>
</div>
@if ((order?.subsetItems)[0]; as subsetItem) {
<div class="page-checkout-summary__items-ssc">
<span class="mr-2">{{ subsetItem.supplierName }}</span>
@if (subsetItem?.ssc && subsetItem?.sscText) {
<span class="font-bold border-l border-black pl-2">
{{ subsetItem.ssc }} - {{ subsetItem.sscText }}
</span>
}
</div>
}
<div class="page-checkout-summary__items-quantity font-bold justify-self-end">
<span>{{ order.quantity }}x</span>
</div>
<div class="page-checkout-summary__items-price font-bold justify-self-end">
<span>{{ order.price?.value?.value | currency: ' ' }} {{ order.price?.value?.currency }}</span>
</div>
<div class="page-checkout-summary__items-delivery product-details">
<div class="delivery-row" [ngSwitch]="order?.features?.orderType">
<ng-container *ngSwitchCase="'Abholung'">
<span class="order-type">
Abholung ab {{ (order?.subsetItems)[0]?.estimatedShippingDate | date }}
<ng-container [ngTemplateOutlet]="abholfrist" [ngTemplateOutletContext]="{ order: order }"></ng-container>
</span>
</ng-container>
<ng-container *ngSwitchCase="'Rücklage'">
<span class="order-type">
{{ order?.features?.orderType }}
<ng-container [ngTemplateOutlet]="abholfrist" [ngTemplateOutletContext]="{ order: order }"></ng-container>
</span>
</ng-container>
<ng-container *ngSwitchCase="['Versand', 'B2B-Versand', 'DIG-Versand'].indexOf(order?.features?.orderType) > -1">
<ng-container *ngIf="(order?.subsetItems)[0]?.estimatedDelivery; else estimatedShippingDate">
<div class="delivery-row">
@switch (order?.features?.orderType) {
@case ('Abholung') {
<span class="order-type">
Zustellung zwischen
{{ ((order?.subsetItems)[0]?.estimatedDelivery?.start | date: 'EEE, dd.MM.')?.replace('.', '') }} und
{{ ((order?.subsetItems)[0]?.estimatedDelivery?.stop | date: 'EEE, dd.MM.')?.replace('.', '') }}
Abholung ab {{ (order?.subsetItems)[0]?.estimatedShippingDate | date }}
<ng-container [ngTemplateOutlet]="abholfrist" [ngTemplateOutletContext]="{ order: order }"></ng-container>
</span>
</ng-container>
<ng-template #estimatedShippingDate>
<span class="order-type">Versanddatum {{ (order?.subsetItems)[0]?.estimatedShippingDate | date }}</span>
</ng-template>
</ng-container>
<ng-container *ngSwitchDefault>
<span class="order-type">{{ order?.features?.orderType }}</span>
</ng-container>
}
@case ('Rücklage') {
<span class="order-type">
{{ order?.features?.orderType }}
<ng-container [ngTemplateOutlet]="abholfrist" [ngTemplateOutletContext]="{ order: order }"></ng-container>
</span>
}
@case (['Versand', 'B2B-Versand', 'DIG-Versand'].indexOf(order?.features?.orderType) > -1) {
@if ((order?.subsetItems)[0]?.estimatedDelivery) {
<span class="order-type">
Zustellung zwischen
{{ ((order?.subsetItems)[0]?.estimatedDelivery?.start | date: 'EEE, dd.MM.')?.replace('.', '') }} und
{{ ((order?.subsetItems)[0]?.estimatedDelivery?.stop | date: 'EEE, dd.MM.')?.replace('.', '') }}
</span>
} @else {
<span class="order-type">Versanddatum {{ (order?.subsetItems)[0]?.estimatedShippingDate | date }}</span>
}
}
@default {
<span class="order-type">{{ order?.features?.orderType }}</span>
}
}
</div>
</div>
</div>
</ng-container>
<hr *ngIf="last" />
</ng-container>
<ng-container *ngIf="orderLast">
}
@if (last) {
<hr />
}
}
@if (orderLast) {
<div class="flex flex-row justify-between items-center min-h-[3.3125rem] bg-white px-5 py-4 rounded-b">
<span *ngIf="totalReadingPoints$ | async; let totalReadingPoints" class="text-p2 font-bold">
{{ totalItemCount$ | async }} Artikel | {{ totalReadingPoints }} Lesepunkte
</span>
@if (totalReadingPoints$ | async; as totalReadingPoints) {
<span class="text-p2 font-bold">
{{ totalItemCount$ | async }} Artikel | {{ totalReadingPoints }} Lesepunkte
</span>
}
<div class="flex flex-row items-center justify-center">
<div class="text-p1 font-bold flex flex-row items-center">
<div class="mr-1">Gesamtsumme {{ totalPrice$ | async | currency: ' ' }} {{ totalPriceCurrency$ | async }}</div>
</div>
<div
class="bg-brand text-white font-bold text-p1 outline-none border-none rounded-full px-6 py-3 ml-2"
*ngIf="(takeNowOrders$ | async)?.length === 1 && (isB2BCustomer$ | async)"
>
<button class="cta-goods-out" (click)="navigateToShelfOut()">Zur Warenausgabe</button>
</div>
@if ((takeNowOrders$ | async)?.length === 1 && (isB2BCustomer$ | async)) {
<div
class="bg-brand text-white font-bold text-p1 outline-none border-none rounded-full px-6 py-3 ml-2"
>
<button class="cta-goods-out" (click)="navigateToShelfOut()">Zur Warenausgabe</button>
</div>
}
</div>
</div>
</ng-container>
</ng-container>
}
}
</div>
</div>
<ng-template #abholfrist let-order="order">
<div *ngIf="!(updatingPreferredPickUpDate$ | async)[(order?.subsetItems)[0].id]" class="inline-flex">
<button [uiOverlayTrigger]="deadlineDatepicker" #deadlineDatepickerTrigger="uiOverlayTrigger" class="flex flex-row items-center">
<span class="mx-[0.625rem] font-normal">bis</span>
<strong class="border-r border-[#AEB7C1] pr-4">
{{ ((order?.subsetItems)[0]?.preferredPickUpDate | date: 'dd.MM.yy') || 'TT.MM.JJJJ' }}
</strong>
<shared-icon class="text-[#596470] ml-4" [size]="24" icon="isa-calendar"></shared-icon>
</button>
<ui-datepicker
#deadlineDatepicker
yPosition="below"
xPosition="after"
[xOffset]="8"
[min]="minDateDatepicker"
[disabledDaysOfWeek]="[0]"
[(selected)]="selectedDate"
>
<div #content class="grid grid-flow-row gap-2">
<button
class="rounded-full font-bold text-white bg-brand py-px-15 px-px-25"
(click)="updatePreferredPickUpDate(undefined, selectedDate); deadlineDatepickerTrigger.close()"
@if (!(updatingPreferredPickUpDate$ | async)[(order?.subsetItems)[0].id]) {
<div class="inline-flex">
<button [uiOverlayTrigger]="deadlineDatepicker" #deadlineDatepickerTrigger="uiOverlayTrigger" class="flex flex-row items-center">
<span class="mx-[0.625rem] font-normal">bis</span>
<strong class="border-r border-[#AEB7C1] pr-4">
{{ ((order?.subsetItems)[0]?.preferredPickUpDate | date: 'dd.MM.yy') || 'TT.MM.JJJJ' }}
</strong>
<shared-icon class="text-[#596470] ml-4" [size]="24" icon="isa-calendar"></shared-icon>
</button>
<ui-datepicker
#deadlineDatepicker
yPosition="below"
xPosition="after"
[xOffset]="8"
[min]="minDateDatepicker"
[disabledDaysOfWeek]="[0]"
[(selected)]="selectedDate"
>
Für den Warenkorb festlegen
</button>
</div>
</ui-datepicker>
</div>
<div class="fetching" *ngIf="!!(updatingPreferredPickUpDate$ | async)[(order?.subsetItems)[0].id]"></div>
<div #content class="grid grid-flow-row gap-2">
<button
class="rounded-full font-bold text-white bg-brand py-px-15 px-px-25"
(click)="updatePreferredPickUpDate(undefined, selectedDate); deadlineDatepickerTrigger.close()"
>
Für den Warenkorb festlegen
</button>
</div>
</ui-datepicker>
</div>
}
@if (!!(updatingPreferredPickUpDate$ | async)[(order?.subsetItems)[0].id]) {
<div class="fetching"></div>
}
</ng-template>
<div class="relative">
@@ -232,17 +232,18 @@
type="button"
class="px-6 py-2 rounded-full border-2 border-solid border-brand text-brand bg-white font-bold text-lg whitespace-nowrap h-14 flex flex-row items-center justify-center print-button"
(click)="printOrderConfirmation()"
>
>
<ui-spinner class="min-h-4 min-w-4" [show]="isPrinting$ | async">Bestellbestätigung drucken</ui-spinner>
</button>
<button
*ngIf="hasAbholung$ | async"
type="button"
class="px-6 py-2 rounded-full border-2 border-solid border-brand text-brand bg-white font-bold text-lg whitespace-nowrap h-14"
(click)="sendOrderConfirmation()"
>
Bestellbestätigung senden
</button>
@if (hasAbholung$ | async) {
<button
type="button"
class="px-6 py-2 rounded-full border-2 border-solid border-brand text-brand bg-white font-bold text-lg whitespace-nowrap h-14"
(click)="sendOrderConfirmation()"
>
Bestellbestätigung senden
</button>
}
</div>
</div>

View File

@@ -15,7 +15,11 @@ import { CrmCustomerService } from '@domain/crm';
import { ActivatedRoute, Router } from '@angular/router';
import { DomainOmsService } from '@domain/oms';
import { DomainCatalogService } from '@domain/catalog';
import { DisplayOrderDTO, DisplayOrderItemDTO, DisplayOrderItemSubsetDTO } from '@generated/swagger/oms-api';
import {
DisplayOrderDTO,
DisplayOrderItemDTO,
DisplayOrderItemSubsetDTO,
} from '@generated/swagger/oms-api';
import { BreadcrumbService } from '@core/breadcrumb';
import { ApplicationService } from '@core/application';
import { DomainPrinterService } from '@domain/printer';
@@ -49,45 +53,73 @@ export class CheckoutSummaryComponent implements OnInit, OnDestroy {
private _onDestroy$ = new Subject<void>();
processId = Date.now();
selectedDate = this.dateAdapter.today();
minDateDatepicker = this.dateAdapter.addCalendarDays(this.dateAdapter.today(), -1);
minDateDatepicker = this.dateAdapter.addCalendarDays(
this.dateAdapter.today(),
-1,
);
updatingPreferredPickUpDate$ = new BehaviorSubject<Record<string, string>>({});
updatingPreferredPickUpDate$ = new BehaviorSubject<Record<string, string>>(
{},
);
displayOrders$ = combineLatest([this.domainCheckoutService.getOrders(), this._route.params]).pipe(
displayOrders$ = combineLatest([
this.domainCheckoutService.getOrders(),
this._route.params,
]).pipe(
map(([orders, params]) => {
let filteredOrders: DisplayOrderDTO[] = [];
if (params?.orderIds) {
const orderIds: string[] = params.orderIds.split(',');
filteredOrders = orders.filter((order) => orderIds.find((id) => Number(id) === order.id));
filteredOrders = orders.filter((order) =>
orderIds.find((id) => Number(id) === order.id),
);
} else {
return filteredOrders;
}
// Ticket #4228 Für die korrekte Gruppierung der Items bei gleichem Bestellziel (Aufsplitten von Abholung und Rücklage)
const ordersWithMultipleFeatures = filteredOrders.filter((order) =>
order.items.find((item) => item.features.orderType !== order.features.orderType),
order.items.find(
(item) => item.features.orderType !== order.features.orderType,
),
);
if (ordersWithMultipleFeatures) {
for (let orderWithMultipleFeatures of ordersWithMultipleFeatures) {
if (orderWithMultipleFeatures?.items?.length > 1) {
const itemsWithOrderFeature = orderWithMultipleFeatures.items.filter(
(item) => item.features.orderType === orderWithMultipleFeatures.features.orderType,
);
const itemsWithDifferentOrderFeature = orderWithMultipleFeatures.items.filter(
(item) => item.features.orderType !== orderWithMultipleFeatures.features.orderType,
);
const itemsWithOrderFeature =
orderWithMultipleFeatures.items.filter(
(item) =>
item.features.orderType ===
orderWithMultipleFeatures.features.orderType,
);
const itemsWithDifferentOrderFeature =
orderWithMultipleFeatures.items.filter(
(item) =>
item.features.orderType !==
orderWithMultipleFeatures.features.orderType,
);
filteredOrders = [...filteredOrders.filter((order) => order.id !== orderWithMultipleFeatures.id)];
filteredOrders = [
...filteredOrders.filter(
(order) => order.id !== orderWithMultipleFeatures.id,
),
];
if (itemsWithOrderFeature?.length > 0) {
filteredOrders = [...filteredOrders, { ...orderWithMultipleFeatures, items: itemsWithOrderFeature }];
filteredOrders = [
...filteredOrders,
{ ...orderWithMultipleFeatures, items: itemsWithOrderFeature },
];
}
if (itemsWithDifferentOrderFeature?.length > 0) {
filteredOrders = [
...filteredOrders,
{ ...orderWithMultipleFeatures, items: itemsWithDifferentOrderFeature },
{
...orderWithMultipleFeatures,
items: itemsWithDifferentOrderFeature,
},
];
}
}
@@ -97,7 +129,9 @@ export class CheckoutSummaryComponent implements OnInit, OnDestroy {
return filteredOrders?.map((order) => {
return {
...order,
items: [...order.items]?.sort((a, b) => a.product?.name.localeCompare(b.product?.name)),
items: [...order.items]?.sort((a, b) =>
a.product?.name.localeCompare(b.product?.name),
),
};
});
}),
@@ -105,14 +139,23 @@ export class CheckoutSummaryComponent implements OnInit, OnDestroy {
);
hasAbholung$ = this.displayOrders$.pipe(
map((displayOrders) => displayOrders.filter((order) => order.features?.orderType === 'Abholung')?.length > 0),
map(
(displayOrders) =>
displayOrders.filter(
(order) => order.features?.orderType === 'Abholung',
)?.length > 0,
),
);
totalItemCount$ = this.displayOrders$.pipe(
map((displayOrders) =>
displayOrders.reduce(
(total, displayOrder) =>
total + displayOrder?.items?.reduce((subTotal, order) => subTotal + order?.quantity, 0),
total +
displayOrder?.items?.reduce(
(subTotal, order) => subTotal + order?.quantity,
0,
),
0,
),
),
@@ -121,7 +164,10 @@ export class CheckoutSummaryComponent implements OnInit, OnDestroy {
totalReadingPoints$ = this.displayOrders$.pipe(
switchMap((displayOrders) => {
const items = displayOrders
.reduce<DisplayOrderItemDTO[]>((items, order) => [...items, ...order.items], [])
.reduce<DisplayOrderItemDTO[]>(
(items, order) => [...items, ...order.items],
[],
)
.map((i) => {
if (i?.product?.catalogProductNumber) {
return {
@@ -135,7 +181,14 @@ export class CheckoutSummaryComponent implements OnInit, OnDestroy {
if (items.length !== 0) {
return this.domainCatalogService
.getPromotionPoints({ items })
.pipe(map((response) => Object.values(response.result).reduce((sum, points) => sum + points, 0)));
.pipe(
map((response) =>
Object.values(response.result).reduce(
(sum, points) => sum + points,
0,
),
),
);
} else {
return NEVER;
}
@@ -147,7 +200,11 @@ export class CheckoutSummaryComponent implements OnInit, OnDestroy {
displayOrders.reduce(
(total, displayOrder) =>
total +
displayOrder?.items?.reduce((subTotal, order) => subTotal + order?.price?.value?.value * order.quantity, 0),
displayOrder?.items?.reduce(
(subTotal, order) =>
subTotal + order?.price?.value?.value * order.quantity,
0,
),
0,
),
),
@@ -162,22 +219,33 @@ export class CheckoutSummaryComponent implements OnInit, OnDestroy {
containsDeliveryOrder$ = this.displayOrders$.pipe(
map(
(displayOrders) =>
displayOrders.filter((o) => ['Versand', 'B2B-Versand', 'DIG-Versand'].indexOf(o.features?.orderType) > -1)
?.length > 0,
displayOrders.filter(
(o) =>
['Versand', 'B2B-Versand', 'DIG-Versand'].indexOf(
o.features?.orderType,
) > -1,
)?.length > 0,
),
);
customer$ = this.displayOrders$.pipe(
switchMap((o) => this.customerService.getCustomers(o[0].buyerNumber, { take: 5 })),
switchMap((o) =>
this.customerService.getCustomers(o[0].buyerNumber, { take: 5 }),
),
map((customers) => customers.result[0]),
shareReplay(),
);
isB2BCustomer$ = this.customer$.pipe(map((customer) => customer?.features?.find((f) => f.key === 'b2b') != null));
isB2BCustomer$ = this.customer$.pipe(
map((customer) => customer?.features?.find((f) => f.key === 'b2b') != null),
);
takeNowOrders$ = this.displayOrders$.pipe(
map((displayOrders) =>
displayOrders.filter((o) => o.items.find((oi) => oi.features?.orderType === 'Rücklage') != null),
displayOrders.filter(
(o) =>
o.items.find((oi) => oi.features?.orderType === 'Rücklage') != null,
),
),
);
@@ -210,7 +278,9 @@ export class CheckoutSummaryComponent implements OnInit, OnDestroy {
private _cdr: ChangeDetectorRef,
) {
this.breadcrumb
.getBreadcrumbsByKeyAndTags$(this.applicationService.activatedProcessId, ['checkout'])
.getBreadcrumbsByKeyAndTags$(this.applicationService.activatedProcessId, [
'checkout',
])
.pipe(first())
.subscribe(async (crumbs) => {
for await (const crumb of crumbs) {
@@ -264,7 +334,8 @@ export class CheckoutSummaryComponent implements OnInit, OnDestroy {
getProductSearchDetailsQueryParams(item: DisplayOrderItemDTO) {
return {
main_qs: item?.product?.ean,
filter_format: item?.features?.orderType === 'Download' ? 'eb;dl' : undefined,
filter_format:
item?.features?.orderType === 'Download' ? 'eb;dl' : undefined,
};
}
@@ -274,9 +345,14 @@ export class CheckoutSummaryComponent implements OnInit, OnDestroy {
try {
const items = item ? [item] : await this.getAllOrderItems();
const subsetItems = items
.filter((item) => ['Rücklage', 'Abholung'].includes(item.features.orderType))
.filter((item) =>
['Rücklage', 'Abholung'].includes(item.features.orderType),
)
// .flatMap((item) => item.subsetItems);
.reduce<DisplayOrderItemSubsetDTO[]>((acc, item) => [...acc, ...item.subsetItems], []);
.reduce<DisplayOrderItemSubsetDTO[]>(
(acc, item) => [...acc, ...item.subsetItems],
[],
);
subsetItems.forEach((item) => (data[`${item.id}`] = date?.toISOString()));
try {
@@ -310,7 +386,10 @@ export class CheckoutSummaryComponent implements OnInit, OnDestroy {
async getAllOrderItems() {
const orders = await this.displayOrders$.pipe(first()).toPromise();
return orders.reduce<DisplayOrderItemDTO[]>((agg, order) => [...agg, ...order.items], []);
return orders.reduce<DisplayOrderItemDTO[]>(
(agg, order) => [...agg, ...order.items],
[],
);
}
async updateDisplayOrderItem(item: DisplayOrderItemDTO) {
@@ -322,9 +401,16 @@ export class CheckoutSummaryComponent implements OnInit, OnDestroy {
if (takeNowOrders.length != 1) return;
try {
await this.router.navigate(this._shelfOutNavigationService.listRoute({ processId: Date.now() }).path, {
queryParams: { main_qs: takeNowOrders[0].orderNumber, filter_supplier_id: '16' },
});
await this.router.navigate(
this._shelfOutNavigationService.listRoute({ processId: Date.now() })
.path,
{
queryParams: {
main_qs: takeNowOrders[0].orderNumber,
filter_supplier_id: '16',
},
},
);
} catch (e) {
console.error(e);
}
@@ -344,7 +430,8 @@ export class CheckoutSummaryComponent implements OnInit, OnDestroy {
.pipe(
first(),
map((printers) => {
if (Array.isArray(printers)) return printers.find((printer) => printer.selected === true);
if (Array.isArray(printers))
return printers.find((printer) => printer.selected === true);
}),
)
.toPromise();
@@ -362,10 +449,16 @@ export class CheckoutSummaryComponent implements OnInit, OnDestroy {
const result = await this.domainPrinterService
.printOrder({ orderIds: orders.map((o) => o.id), printer })
.toPromise();
this._toaster.open({ type: 'success', message: 'Bestellbestätigung wurde gedruckt' });
this._toaster.open({
type: 'success',
message: 'Bestellbestätigung wurde gedruckt',
});
return result;
} catch (error) {
this._toaster.open({ type: 'danger', message: 'Fehler beim Drucken der Bestellbestätigung' });
this._toaster.open({
type: 'danger',
message: 'Fehler beim Drucken der Bestellbestätigung',
});
} finally {
this.isPrinting$.next(false);
}
@@ -381,12 +474,21 @@ export class CheckoutSummaryComponent implements OnInit, OnDestroy {
} else {
try {
const result = await this.domainPrinterService
.printOrder({ orderIds: orders.map((o) => o.id), printer: selectedPrinter.key })
.printOrder({
orderIds: orders.map((o) => o.id),
printer: selectedPrinter.key,
})
.toPromise();
this._toaster.open({ type: 'success', message: 'Bestellbestätigung wurde gedruckt' });
this._toaster.open({
type: 'success',
message: 'Bestellbestätigung wurde gedruckt',
});
return result;
} catch (error) {
this._toaster.open({ type: 'danger', message: 'Fehler beim Drucken der Bestellbestätigung' });
this._toaster.open({
type: 'danger',
message: 'Fehler beim Drucken der Bestellbestätigung',
});
} finally {
this.isPrinting$.next(false);
}

View File

@@ -1,35 +1,35 @@
<ng-container *ngIf="orderItem$ | async; let orderItem">
@if (orderItem$ | async; as orderItem) {
<div #features class="page-customer-order-details-item__features">
<ng-container *ngIf="orderItem?.features?.prebooked">
@if (orderItem?.features?.prebooked) {
<img [uiOverlayTrigger]="prebookedTooltip" src="/assets/images/tag_icon_preorder.svg" [alt]="orderItem?.features?.prebooked" />
<ui-tooltip yPosition="above" xPosition="after" [yOffset]="-11" [xOffset]="-8" #prebookedTooltip [closeable]="true">
Artikel wird für Sie vorgemerkt.
</ui-tooltip>
</ng-container>
<ng-container *ngIf="notificationsSent$ | async; let notificationsSent">
<ng-container *ngIf="notificationsSent?.NOTIFICATION_EMAIL">
}
@if (notificationsSent$ | async; as notificationsSent) {
@if (notificationsSent?.NOTIFICATION_EMAIL) {
<img [uiOverlayTrigger]="emailTooltip" src="/assets/images/email_bookmark.svg" />
<ui-tooltip yPosition="above" xPosition="after" [yOffset]="-11" [xOffset]="-8" #emailTooltip [closeable]="true">
Per E-Mail benachrichtigt
<br />
<ng-container *ngFor="let notification of notificationsSent?.NOTIFICATION_EMAIL">
@for (notification of notificationsSent?.NOTIFICATION_EMAIL; track notification) {
{{ notification | date: 'dd.MM.yyyy | HH:mm' }} Uhr
<br />
</ng-container>
}
</ui-tooltip>
</ng-container>
<ng-container *ngIf="notificationsSent?.NOTIFICATION_SMS">
}
@if (notificationsSent?.NOTIFICATION_SMS) {
<img [uiOverlayTrigger]="smsTooltip" src="/assets/images/sms_bookmark.svg" />
<ui-tooltip yPosition="above" xPosition="after" [yOffset]="-11" [xOffset]="-8" #smsTooltip [closeable]="true">
Per SMS benachrichtigt
<br />
<ng-container *ngFor="let notification of notificationsSent?.NOTIFICATION_SMS">
@for (notification of notificationsSent?.NOTIFICATION_SMS; track notification) {
{{ notification | date: 'dd.MM.yyyy | HH:mm' }} Uhr
<br />
</ng-container>
}
</ui-tooltip>
</ng-container>
</ng-container>
}
}
</div>
<div class="page-customer-order-details-item__item-container">
<div class="page-customer-order-details-item__thumbnail">
@@ -42,169 +42,190 @@
#elementDistance="uiElementDistance"
[style.max-width.px]="elementDistance.distanceChange | async"
class="flex flex-col"
>
>
<div class="font-normal mb-[0.375rem]">{{ orderItem.product?.contributors }}</div>
<div>{{ orderItem.product?.name }}</div>
</h3>
<div class="history-wrapper flex flex-col items-end justify-center">
<button class="cta-history text-p1" (click)="historyClick.emit(orderItem)">Historie</button>
<input
*ngIf="selectable$ | async"
[ngModel]="selected$ | async"
(ngModelChange)="setSelected($event)"
class="isa-select-bullet mt-4"
type="checkbox"
/>
@if (selectable$ | async) {
<input
[ngModel]="selected$ | async"
(ngModelChange)="setSelected($event)"
class="isa-select-bullet mt-4"
type="checkbox"
/>
}
</div>
</div>
<div class="detail">
<div class="label">Menge</div>
<div class="value">
<ng-container *ngIf="!(canChangeQuantity$ | async)">{{ orderItem?.quantity }}x</ng-container>
<ui-quantity-dropdown
*ngIf="canChangeQuantity$ | async"
[showTrash]="false"
[range]="orderItem?.quantity"
[(ngModel)]="quantity"
[showSpinner]="false"
></ui-quantity-dropdown>
@if (!(canChangeQuantity$ | async)) {
{{ orderItem?.quantity }}x
}
@if (canChangeQuantity$ | async) {
<ui-quantity-dropdown
[showTrash]="false"
[range]="orderItem?.quantity"
[(ngModel)]="quantity"
[showSpinner]="false"
></ui-quantity-dropdown>
}
<span class="overall-quantity">(von {{ orderItem?.overallQuantity }})</span>
</div>
</div>
<div class="detail" *ngIf="!!orderItem.product?.formatDetail">
<div class="label">Format</div>
<div class="value">
<img
*ngIf="orderItem?.product?.format && orderItem?.product?.format !== 'UNKNOWN'"
class="format-icon"
[src]="'/assets/images/Icon_' + orderItem.product?.format + '.svg'"
alt="format icon"
/>
<span>{{ orderItem.product?.formatDetail }}</span>
@if (!!orderItem.product?.formatDetail) {
<div class="detail">
<div class="label">Format</div>
<div class="value">
@if (orderItem?.product?.format && orderItem?.product?.format !== 'UNKNOWN') {
<img
class="format-icon"
[src]="'/assets/images/Icon_' + orderItem.product?.format + '.svg'"
alt="format icon"
/>
}
<span>{{ orderItem.product?.formatDetail }}</span>
</div>
</div>
</div>
<div class="detail" *ngIf="!!orderItem.product?.ean">
<div class="label">ISBN/EAN</div>
<div class="value">{{ orderItem.product?.ean }}</div>
</div>
<div class="detail" *ngIf="orderItem.price !== undefined">
<div class="label">Preis</div>
<div class="value">{{ orderItem.price | currency: 'EUR' }}</div>
</div>
<div class="detail" *ngIf="!!orderItem.retailPrice?.vat?.inPercent">
<div class="label">MwSt</div>
<div class="value">{{ orderItem.retailPrice?.vat?.inPercent }}%</div>
</div>
}
@if (!!orderItem.product?.ean) {
<div class="detail">
<div class="label">ISBN/EAN</div>
<div class="value">{{ orderItem.product?.ean }}</div>
</div>
}
@if (orderItem.price !== undefined) {
<div class="detail">
<div class="label">Preis</div>
<div class="value">{{ orderItem.price | currency: 'EUR' }}</div>
</div>
}
@if (!!orderItem.retailPrice?.vat?.inPercent) {
<div class="detail">
<div class="label">MwSt</div>
<div class="value">{{ orderItem.retailPrice?.vat?.inPercent }}%</div>
</div>
}
<hr class="border-[#EDEFF0] border-t-2 my-4" />
<div class="detail" *ngIf="orderItem.supplier">
<div class="label">Lieferant</div>
<div class="value">{{ orderItem.supplier }}</div>
</div>
<div class="detail" *ngIf="!!orderItem.ssc || !!orderItem.sscText">
<div class="label">Meldenummer</div>
<div class="value">{{ orderItem.ssc }} - {{ orderItem.sscText }}</div>
</div>
<div class="detail" *ngIf="!!orderItem.targetBranch">
<div class="label">Zielfiliale</div>
<div class="value">{{ orderItem.targetBranch }}</div>
</div>
@if (orderItem.supplier) {
<div class="detail">
<div class="label">Lieferant</div>
<div class="value">{{ orderItem.supplier }}</div>
</div>
}
@if (!!orderItem.ssc || !!orderItem.sscText) {
<div class="detail">
<div class="label">Meldenummer</div>
<div class="value">{{ orderItem.ssc }} - {{ orderItem.sscText }}</div>
</div>
}
@if (!!orderItem.targetBranch) {
<div class="detail">
<div class="label">Zielfiliale</div>
<div class="value">{{ orderItem.targetBranch }}</div>
</div>
}
<div class="detail">
<div class="label">
<ng-container
*ngIf="
orderItemFeature(orderItem) === 'Versand' ||
orderItemFeature(orderItem) === 'B2B-Versand' ||
orderItemFeature(orderItem) === 'DIG-Versand'
"
>
@if (
orderItemFeature(orderItem) === 'Versand' ||
orderItemFeature(orderItem) === 'B2B-Versand' ||
orderItemFeature(orderItem) === 'DIG-Versand'
) {
{{ orderItem?.estimatedDelivery ? 'Lieferung zwischen' : 'Lieferung ab' }}
</ng-container>
<ng-container *ngIf="orderItemFeature(orderItem) === 'Abholung' || orderItemFeature(orderItem) === 'Rücklage'">
}
@if (orderItemFeature(orderItem) === 'Abholung' || orderItemFeature(orderItem) === 'Rücklage') {
Abholung ab
</ng-container>
}
</div>
<ng-container *ngIf="!!orderItem?.estimatedDelivery || !!orderItem?.estimatedShippingDate">
@if (!!orderItem?.estimatedDelivery || !!orderItem?.estimatedShippingDate) {
<div class="value bg-[#D8DFE5] rounded w-max px-2">
<ng-container *ngIf="!!orderItem?.estimatedDelivery; else estimatedShippingDate">
@if (!!orderItem?.estimatedDelivery) {
{{ orderItem?.estimatedDelivery?.start | date: 'dd.MM.yy' }} und
{{ orderItem?.estimatedDelivery?.stop | date: 'dd.MM.yy' }}
</ng-container>
} @else {
@if (!!orderItem?.estimatedShippingDate) {
{{ orderItem?.estimatedShippingDate | date: 'dd.MM.yy' }}
}
}
</div>
</ng-container>
<ng-template #estimatedShippingDate>
<ng-container *ngIf="!!orderItem?.estimatedShippingDate">
{{ orderItem?.estimatedShippingDate | date: 'dd.MM.yy' }}
</ng-container>
</ng-template>
}
</div>
<div class="page-customer-order-details-item__tracking-details" *ngIf="getOrderItemTrackingData(orderItem); let trackingData">
<div class="label">{{ trackingData.length > 1 ? 'Sendungsnummern' : 'Sendungsnummer' }}</div>
<ng-container *ngFor="let tracking of trackingData">
<ng-container *ngIf="tracking.trackingProvider === 'DHL' && !isNative; else noTrackingLink">
<a class="value text-[#0556B4]" [href]="getTrackingNumberLink(tracking.trackingNumber)" target="_blank">
{{ tracking.trackingProvider }}: {{ tracking.trackingNumber }}
</a>
</ng-container>
<ng-template #noTrackingLink>
<p class="value">{{ tracking.trackingProvider }}: {{ tracking.trackingNumber }}</p>
</ng-template>
</ng-container>
</div>
@if (getOrderItemTrackingData(orderItem); as trackingData) {
<div class="page-customer-order-details-item__tracking-details">
<div class="label">{{ trackingData.length > 1 ? 'Sendungsnummern' : 'Sendungsnummer' }}</div>
@for (tracking of trackingData; track tracking) {
@if (tracking.trackingProvider === 'DHL' && !isNative) {
<a class="value text-[#0556B4]" [href]="getTrackingNumberLink(tracking.trackingNumber)" target="_blank">
{{ tracking.trackingProvider }}: {{ tracking.trackingNumber }}
</a>
} @else {
<p class="value">{{ tracking.trackingProvider }}: {{ tracking.trackingNumber }}</p>
}
}
</div>
}
<hr class="border-[#EDEFF0] border-t-2 my-4" />
<div class="detail" *ngIf="!!orderItem?.compartmentCode">
<div class="label">Abholfachnr.</div>
<div class="value">{{ orderItem?.compartmentCode }}</div>
</div>
@if (!!orderItem?.compartmentCode) {
<div class="detail">
<div class="label">Abholfachnr.</div>
<div class="value">{{ orderItem?.compartmentCode }}</div>
</div>
}
<div class="detail">
<div class="label">Vormerker</div>
<div class="value">{{ orderItem.isPrebooked ? 'Ja' : 'Nein' }}</div>
</div>
<hr class="border-[#EDEFF0] border-t-2 my-4" />
<div class="detail" *ngIf="!!orderItem.paymentProcessing">
<div class="label">Zahlungsweg</div>
<div class="value">{{ orderItem.paymentProcessing || '-' }}</div>
</div>
<div class="detail" *ngIf="!!orderItem.paymentType">
<div class="label">Zahlungsart</div>
<div class="value">{{ orderItem.paymentType | paymentType }}</div>
</div>
<h4 class="receipt-header" *ngIf="receiptCount$ | async; let count">
{{ count > 1 ? 'Belege' : 'Beleg' }}
</h4>
<ng-container *ngFor="let receipt of receipts$ | async">
<div class="detail" *ngIf="!!receipt?.receiptNumber">
<div class="label">Belegnummer</div>
<div class="value">{{ receipt?.receiptNumber }}</div>
@if (!!orderItem.paymentProcessing) {
<div class="detail">
<div class="label">Zahlungsweg</div>
<div class="value">{{ orderItem.paymentProcessing || '-' }}</div>
</div>
<div class="detail" *ngIf="!!receipt?.printedDate">
<div class="label">Erstellt am</div>
<div class="value">{{ receipt?.printedDate | date: 'dd.MM.yy | HH:mm' }} Uhr</div>
}
@if (!!orderItem.paymentType) {
<div class="detail">
<div class="label">Zahlungsart</div>
<div class="value">{{ orderItem.paymentType | paymentType }}</div>
</div>
<div class="detail" *ngIf="!!receipt?.receiptText">
<div class="label">Rechnungstext</div>
<div class="value">{{ receipt?.receiptText || '-' }}</div>
</div>
<div class="detail" *ngIf="!!receipt?.receiptType">
<div class="label">Belegart</div>
<div class="value">
{{ receipt?.receiptType === 1 ? 'Lieferschein' : receipt?.receiptType === 64 ? 'Zahlungsbeleg' : '-' }}
}
@if (receiptCount$ | async; as count) {
<h4 class="receipt-header">
{{ count > 1 ? 'Belege' : 'Beleg' }}
</h4>
}
@for (receipt of receipts$ | async; track receipt) {
@if (!!receipt?.receiptNumber) {
<div class="detail">
<div class="label">Belegnummer</div>
<div class="value">{{ receipt?.receiptNumber }}</div>
</div>
</div>
</ng-container>
}
@if (!!receipt?.printedDate) {
<div class="detail">
<div class="label">Erstellt am</div>
<div class="value">{{ receipt?.printedDate | date: 'dd.MM.yy | HH:mm' }} Uhr</div>
</div>
}
@if (!!receipt?.receiptText) {
<div class="detail">
<div class="label">Rechnungstext</div>
<div class="value">{{ receipt?.receiptText || '-' }}</div>
</div>
}
@if (!!receipt?.receiptType) {
<div class="detail">
<div class="label">Belegart</div>
<div class="value">
{{ receipt?.receiptType === 1 ? 'Lieferschein' : receipt?.receiptType === 64 ? 'Zahlungsbeleg' : '-' }}
</div>
</div>
}
}
<div class="page-customer-order-details-item__comment flex flex-col items-start mt-[1.625rem]">
<div class="label mb-[0.375rem]">Anmerkung</div>
<div class="flex flex-row w-full">
<textarea
matInput
@@ -222,27 +243,28 @@
[formControl]="specialCommentControl"
[class.inactive]="!specialCommentControl.dirty"
></textarea>
<div class="comment-actions">
<button
type="reset"
class="clear"
*ngIf="!!specialCommentControl.value?.length"
(click)="specialCommentControl.setValue(''); saveSpecialComment(); triggerResize()"
>
<shared-icon icon="close" [size]="24"></shared-icon>
</button>
<button
class="cta-save"
type="submit"
*ngIf="specialCommentControl?.enabled && specialCommentControl.dirty"
(click)="saveSpecialComment()"
>
Speichern
</button>
@if (!!specialCommentControl.value?.length) {
<button
type="reset"
class="clear"
(click)="specialCommentControl.setValue(''); saveSpecialComment(); triggerResize()"
>
<shared-icon icon="close" [size]="24"></shared-icon>
</button>
}
@if (specialCommentControl?.enabled && specialCommentControl.dirty) {
<button
class="cta-save"
type="submit"
(click)="saveSpecialComment()"
>
Speichern
</button>
}
</div>
</div>
</div>
</div>
</div>
</ng-container>
}

View File

@@ -1,19 +1,20 @@
<div class="page-customer-order-details-tags__wrapper">
<button
class="page-customer-order-details-tags__tag"
type="button"
[class.selected]="tag === (selected$ | async) && !inputFocus.focused"
*ngFor="let tag of defaultTags"
(click)="setCompartmentInfo(tag)"
>
{{ tag }}
</button>
@for (tag of defaultTags; track tag) {
<button
class="page-customer-order-details-tags__tag"
type="button"
[class.selected]="tag === (selected$ | async) && !inputFocus.focused"
(click)="setCompartmentInfo(tag)"
>
{{ tag }}
</button>
}
<button
(click)="inputFocus.focus()"
type="button"
class="page-customer-order-details-tags__tag"
[class.selected]="(inputValue$ | async) === (selected$ | async) && (inputValue$ | async)"
>
>
<input
#inputFocus="uiFocus"
uiFocus
@@ -23,6 +24,6 @@
placeholder="..."
[size]="controlSize$ | async"
maxlength="15"
/>
/>
</button>
</div>

View File

@@ -1,4 +1,4 @@
import { NgFor, AsyncPipe } from '@angular/common';
import { AsyncPipe } from '@angular/common';
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnDestroy, OnInit, forwardRef } from '@angular/core';
import { ControlValueAccessor, FormsModule, NG_VALUE_ACCESSOR } from '@angular/forms';
import { UiCommonModule } from '@ui/common';
@@ -14,7 +14,7 @@ import { PickUpShelfDetailsTagsComponent } from '../../../pickup-shelf/shared/pi
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'page-pickup-shelf-details-tags' },
standalone: true,
imports: [NgFor, UiCommonModule, FormsModule, AsyncPipe, MatomoModule],
imports: [UiCommonModule, FormsModule, AsyncPipe, MatomoModule],
providers: [
{ provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => PickUpShelfDetailsTagsComponent), multi: true },
],

View File

@@ -4,39 +4,44 @@
(handleAction)="handleAction($event)"
[order]="order$ | async"
></page-customer-order-details-header>
<page-customer-order-details-item
class="mb-px-2"
*ngFor="let item of items$ | async"
[orderItem]="item"
[order]="order$ | async"
[selected]="true"
(historyClick)="navigateToHistoryPage($event)"
(specialCommentChanged)="onSpecialCommentChange()"
></page-customer-order-details-item>
<page-customer-order-details-tags *ngIf="showTagsComponent$ | async"></page-customer-order-details-tags>
@for (item of items$ | async; track item) {
<page-customer-order-details-item
class="mb-px-2"
[orderItem]="item"
[order]="order$ | async"
[selected]="true"
(historyClick)="navigateToHistoryPage($event)"
(specialCommentChanged)="onSpecialCommentChange()"
></page-customer-order-details-item>
}
@if (showTagsComponent$ | async) {
<page-customer-order-details-tags></page-customer-order-details-tags>
}
</div>
<div class="page-customer-order-details__action-wrapper">
<button
[disabled]="addToPreviousCompartmentActionDisabled$ | async"
*ngIf="addToPreviousCompartmentAction$ | async; let action"
class="cta-action shadow-action"
[class.cta-action-primary]="action.selected"
[class.cta-action-secondary]="!action.selected"
(click)="handleAction(action, { compartmentCode: latestCompartmentCode, compartmentInfo: latestCompartmentInfo })"
>
<ui-spinner [show]="(changeActionLoader$ | async) === action.command">
{{ latestCompartmentCode$ | async | addToPreviousCompartmentCodeLabelPipe }} zubuchen
</ui-spinner>
</button>
@if (addToPreviousCompartmentAction$ | async; as action) {
<button
[disabled]="addToPreviousCompartmentActionDisabled$ | async"
class="cta-action shadow-action"
[class.cta-action-primary]="action.selected"
[class.cta-action-secondary]="!action.selected"
(click)="handleAction(action, { compartmentCode: latestCompartmentCode, compartmentInfo: latestCompartmentInfo })"
>
<ui-spinner [show]="(changeActionLoader$ | async) === action.command">
{{ latestCompartmentCode$ | async | addToPreviousCompartmentCodeLabelPipe }} zubuchen
</ui-spinner>
</button>
}
<button
[disabled]="actionsDisabled$ | async"
class="cta-action shadow-action"
[class.cta-action-primary]="action.selected"
[class.cta-action-secondary]="!action.selected"
*ngFor="let action of mainActions$ | async"
(click)="handleAction(action)"
>
<ui-spinner [show]="(changeActionLoader$ | async) === action.command">{{ action.label }}</ui-spinner>
</button>
@for (action of mainActions$ | async; track action) {
<button
[disabled]="actionsDisabled$ | async"
class="cta-action shadow-action"
[class.cta-action-primary]="action.selected"
[class.cta-action-secondary]="!action.selected"
(click)="handleAction(action)"
>
<ui-spinner [show]="(changeActionLoader$ | async) === action.command">{{ action.label }}</ui-spinner>
</button>
}
</div>

View File

@@ -1,3 +1,5 @@
<div *ngIf="items$ | async; let items">
<shared-goods-in-out-order-edit (navigation)="navigateToDetailsPage($event)" [items]="items"></shared-goods-in-out-order-edit>
</div>
@if (items$ | async; as items) {
<div>
<shared-goods-in-out-order-edit (navigation)="navigateToDetailsPage($event)" [items]="items"></shared-goods-in-out-order-edit>
</div>
}

View File

@@ -1,19 +1,19 @@
<div class="hidden desktop-large:block" [class.show-filter]="showFilter">
<ng-container *ngIf="filter$ | async; let filter">
@if (filter$ | async; as filter) {
<div class="customer-orders-search-filter-content">
<div class="w-full flex flex-row justify-end items-center">
<button (click)="clearFilter(filter)" class="text-[#0556B4] p-4">Alle Filter entfernen</button>
<a
*ngIf="showFilterClose$ | async"
class="text-black p-4 outline-none border-none bg-transparent"
[routerLink]="closeFilterRoute"
(click)="showFilter = false"
queryParamsHandling="preserve"
>
<shared-icon icon="close" [size]="25"></shared-icon>
</a>
@if (showFilterClose$ | async) {
<a
class="text-black p-4 outline-none border-none bg-transparent"
[routerLink]="closeFilterRoute"
(click)="showFilter = false"
queryParamsHandling="preserve"
>
<shared-icon icon="close" [size]="25"></shared-icon>
</a>
}
</div>
<div class="customer-orders-search-filter-content-main -mt-14 desktop-small:-mt-8 desktop-large:-mt-12">
<h1 class="text-h3 text-[1.625rem] font-bold text-center pt-6 pb-10">Filter</h1>
<shared-filter
@@ -22,20 +22,18 @@
(search)="applyFilter(filter)"
[hint]="message$ | async"
[scanner]="true"
>
>
<page-order-branch-id-input *sharedFilterCustomInput="'order_branch_id'; let input" [input]="input"></page-order-branch-id-input>
</shared-filter>
</div>
<div class="cta-wrapper">
<button class="cta-reset-filter" (click)="resetFilter()" [disabled]="loading$ | async">Filter zurücksetzen</button>
<button class="cta-apply-filter" (click)="applyFilter(filter)" [disabled]="(loading$ | async) || !hasSelectedOptions(filter)">
<ui-spinner [show]="loading$ | async">Filter anwenden</ui-spinner>
</button>
</div>
</div>
</ng-container>
}
</div>
<div class="desktop-large:hidden" [class.hidden]="showFilter">
<page-customer-order-search-main (showFilter)="showFilter = true"></page-customer-order-search-main>

View File

@@ -11,53 +11,57 @@
<br />
oder scannen Sie die Kundenkarte.
</p>
<ng-container *ngIf="filter$ | async; let filter">
<shared-filter-filter-group-main
class="mb-8 w-full"
*ngIf="!(isDesktop$ | async)"
[inputGroup]="filter?.filter | group: 'main'"
></shared-filter-filter-group-main>
@if (filter$ | async; as filter) {
@if (!(isDesktop$ | async)) {
<shared-filter-filter-group-main
class="mb-8 w-full"
[inputGroup]="filter?.filter | group: 'main'"
></shared-filter-filter-group-main>
}
<div class="flex flex-row px-12 justify-center desktop-large:px-0">
<shared-filter-input-group-main
class="block w-full mr-3 desktop-large:mx-auto"
*ngIf="filter?.input | group: 'main'; let inputGroup"
[inputGroup]="inputGroup"
[loading]="loading$ | async"
(search)="search(filter)"
[hint]="message$ | async"
[scanner]="true"
></shared-filter-input-group-main>
<button
*ngIf="!(isDesktop$ | async)"
(click)="showFilter.emit()"
class="page-search-main__filter w-[6.75rem] h-14 rounded font-bold px-5 mb-4 text-lg bg-[#AEB7C1] flex flex-row flex-nowrap items-center justify-center"
[class.active]="hasFilter$ | async"
type="button"
>
<shared-icon class="mr-2" icon="filter-variant"></shared-icon>
Filter
</button>
@if (filter?.input | group: 'main'; as inputGroup) {
<shared-filter-input-group-main
class="block w-full mr-3 desktop-large:mx-auto"
[inputGroup]="inputGroup"
[loading]="loading$ | async"
(search)="search(filter)"
[hint]="message$ | async"
[scanner]="true"
></shared-filter-input-group-main>
}
@if (!(isDesktop$ | async)) {
<button
(click)="showFilter.emit()"
class="page-search-main__filter w-[6.75rem] h-14 rounded font-bold px-5 mb-4 text-lg bg-[#AEB7C1] flex flex-row flex-nowrap items-center justify-center"
[class.active]="hasFilter$ | async"
type="button"
>
<shared-icon class="mr-2" icon="filter-variant"></shared-icon>
Filter
</button>
}
</div>
<div
class="flex flex-col items-start ml-12 desktop-large:ml-8 py-6 bg-white overflow-hidden h-[calc(100%-21rem)] desktop-large:h-[calc(100%-15rem)]"
>
>
<h3 class="text-p3 font-bold mb-3">Deine letzten Suchanfragen</h3>
<ul class="flex flex-col justify-start overflow-hidden overflow-y-scroll items-start m-0 p-0 bg-white w-full">
<li class="list-none pb-3" *ngFor="let query of history$ | async">
<button
class="flex flex-row items-center outline-none border-none bg-white text-black text-p2 m-0 p-0"
(click)="setQueryHistory(filter, query)"
>
<shared-icon
class="flex w-8 h-8 justify-center items-center mr-3 rounded-full text-black bg-[#edeff0]"
icon="magnify"
[size]="20"
></shared-icon>
<p class="m-0 p-0 whitespace-nowrap overflow-hidden overflow-ellipsis max-w-[25rem]">{{ query }}</p>
</button>
</li>
@for (query of history$ | async; track query) {
<li class="list-none pb-3">
<button
class="flex flex-row items-center outline-none border-none bg-white text-black text-p2 m-0 p-0"
(click)="setQueryHistory(filter, query)"
>
<shared-icon
class="flex w-8 h-8 justify-center items-center mr-3 rounded-full text-black bg-[#edeff0]"
icon="magnify"
[size]="20"
></shared-icon>
<p class="m-0 p-0 whitespace-nowrap overflow-hidden overflow-ellipsis max-w-[25rem]">{{ query }}</p>
</button>
</li>
}
</ul>
</div>
</ng-container>
}
</div>

View File

@@ -5,19 +5,20 @@
[routerLinkActive]="!isTablet && !primaryOutletActive ? 'active' : ''"
[queryParams]="queryParams"
(click)="isDesktopLarge ? scrollIntoView() : ''"
>
>
<div
class="page-customer-order-item__item-grid-container"
[class.page-customer-order-item__item-grid-container-main]="primaryOutletActive"
>
>
<div class="page-customer-order-item__item-thumbnail text-center mr-4 w-[3.125rem] h-[4.9375rem]">
<img
class="page-customer-order-item__item-image w-[3.125rem] max-h-[4.9375rem]"
loading="lazy"
*ngIf="item?.product?.ean | productImage; let productImage"
[src]="productImage"
[alt]="item?.product?.name"
/>
@if (item?.product?.ean | productImage; as productImage) {
<img
class="page-customer-order-item__item-image w-[3.125rem] max-h-[4.9375rem]"
loading="lazy"
[src]="productImage"
[alt]="item?.product?.name"
/>
}
</div>
<div
@@ -27,7 +28,7 @@
[class.text-p2]="item?.product?.name?.length >= 50 && isTablet"
[class.text-p3]="item?.product?.name?.length >= 60 || !isTablet"
[class.text-p4]="item?.product?.name?.length >= 100"
>
>
{{ item?.product?.name }}
</div>
@@ -35,88 +36,111 @@
{{ item?.specialComment }}
</div>
<div *ngIf="primaryOutletActive" class="page-customer-order-item__item-format desktop-small:text-p2">
<div *ngIf="item?.product?.format && item?.product?.formatDetail" class="font-bold flex flex-row">
<img
class="mr-3"
*ngIf="item?.product?.format !== '--'"
loading="lazy"
src="assets/images/Icon_{{ item?.product?.format }}.svg"
[alt]="item?.product?.formatDetail"
/>
{{ item?.product?.formatDetail | substr: 30 }}
@if (primaryOutletActive) {
<div class="page-customer-order-item__item-format desktop-small:text-p2">
@if (item?.product?.format && item?.product?.formatDetail) {
<div class="font-bold flex flex-row">
@if (item?.product?.format !== '--') {
<img
class="mr-3"
loading="lazy"
src="assets/images/Icon_{{ item?.product?.format }}.svg"
[alt]="item?.product?.formatDetail"
/>
}
{{ item?.product?.formatDetail | substr: 30 }}
</div>
}
</div>
</div>
}
<div *ngIf="primaryOutletActive" class="page-customer-order-item__item-ean desktop-small:text-p2">
{{ item?.product?.ean }}
</div>
@if (primaryOutletActive) {
<div class="page-customer-order-item__item-ean desktop-small:text-p2">
{{ item?.product?.ean }}
</div>
}
<div *ngIf="primaryOutletActive" class="page-customer-order-item__item-price desktop-small:text-p2 font-bold">
{{ item.price | currency: 'EUR' : 'code' }}
</div>
@if (primaryOutletActive) {
<div class="page-customer-order-item__item-price desktop-small:text-p2 font-bold">
{{ item.price | currency: 'EUR' : 'code' }}
</div>
}
<div *ngIf="primaryOutletActive" class="page-customer-order-item__item-changed desktop-small:text-p2">
<ng-container [ngSwitch]="showChangeDate$ | async">
<div class="flex flex-row" *ngSwitchCase="true">
<div class="min-w-[7.5rem]">Geändert</div>
<div class="font-bold">{{ item?.processingStatusDate | date: 'dd.MM.yy | HH:mm' }} Uhr</div>
</div>
<div class="flex flex-row" *ngSwitchCase="false">
<div class="min-w-[7.5rem]">Bestelldatum</div>
<div class="font-bold">{{ item?.orderDate | date: 'dd.MM.yy | HH:mm' }} Uhr</div>
</div>
</ng-container>
</div>
@if (primaryOutletActive) {
<div class="page-customer-order-item__item-changed desktop-small:text-p2">
@switch (showChangeDate$ | async) {
@case (true) {
<div class="flex flex-row">
<div class="min-w-[7.5rem]">Geändert</div>
<div class="font-bold">{{ item?.processingStatusDate | date: 'dd.MM.yy | HH:mm' }} Uhr</div>
</div>
}
@case (false) {
<div class="flex flex-row">
<div class="min-w-[7.5rem]">Bestelldatum</div>
<div class="font-bold">{{ item?.orderDate | date: 'dd.MM.yy | HH:mm' }} Uhr</div>
</div>
}
}
</div>
}
<div *ngIf="primaryOutletActive" class="page-customer-order-item__item-quantity flex flex-row desktop-small:text-p2">
<div class="min-w-[7.5rem]">Menge</div>
<div class="font-bold">{{ item.quantity }} x</div>
</div>
@if (primaryOutletActive) {
<div class="page-customer-order-item__item-quantity flex flex-row desktop-small:text-p2">
<div class="min-w-[7.5rem]">Menge</div>
<div class="font-bold">{{ item.quantity }} x</div>
</div>
}
<div *ngIf="primaryOutletActive" class="page-customer-order-item__item-target-branch flex flex-row desktop-small:text-p2">
<ng-container *ngIf="item.orderType === 1; else showDelivery">
<div class="min-w-[7.5rem]">Zielfiliale</div>
<div class="font-bold">{{ item.targetBranch }}</div>
</ng-container>
<ng-template #showDelivery>
<div class="min-w-[7.5rem]">Versanddatum</div>
<div class="font-bold">{{ item?.estimatedShippingDate | date: 'dd.MM.yy | HH:mm' }} Uhr</div>
</ng-template>
</div>
@if (primaryOutletActive) {
<div class="page-customer-order-item__item-target-branch flex flex-row desktop-small:text-p2">
@if (item.orderType === 1) {
<div class="min-w-[7.5rem]">Zielfiliale</div>
<div class="font-bold">{{ item.targetBranch }}</div>
} @else {
<div class="min-w-[7.5rem]">Versanddatum</div>
<div class="font-bold">{{ item?.estimatedShippingDate | date: 'dd.MM.yy | HH:mm' }} Uhr</div>
}
</div>
}
<hr
*ngIf="!primaryOutletActive"
class="page-customer-order-item__separator border-[#EDEFF0] border-solid border-[1px] -mx-[0.875rem]"
/>
@if (!primaryOutletActive) {
<hr
class="page-customer-order-item__separator border-[#EDEFF0] border-solid border-[1px] -mx-[0.875rem]"
/>
}
<div
class="page-customer-order-item__item-order-number desktop-small:text-xl justify-self-end font-bold"
[class.page-customer-order-item__item-order-number-main]="!primaryOutletActive"
>
<ng-container *ngIf="item?.compartmentCode; else orderNumber">
>
@if (item?.compartmentCode) {
{{ item?.compartmentCode }}{{ item?.compartmentInfo && '_' + item?.compartmentInfo }}
</ng-container>
<ng-template #orderNumber>{{ item?.orderNumber }}</ng-template>
} @else {
{{ item?.orderNumber }}
}
</div>
<div
class="page-customer-order-item__item-processing-paid-status flex flex-col font-bold desktop-small:text-p2 justify-self-end self-center"
>
>
<div class="page-customer-order-item__item-processing-status flex flex-row mb-[0.375rem]">
<shared-icon
class="flex items-center justify-center mr-1"
[size]="16"
*ngIf="item.processingStatus | processingStatus: 'icon'; let icon"
[icon]="icon"
></shared-icon>
@if (item.processingStatus | processingStatus: 'icon'; as icon) {
<shared-icon
class="flex items-center justify-center mr-1"
[size]="16"
[icon]="icon"
></shared-icon>
}
{{ item.processingStatus | processingStatus }}
</div>
<div class="page-customer-order-item__item-paid flex flex-row self-end">
<div class="font-bold w-fit desktop-small:text-p2 px-3 py-[0.125rem] rounded text-white bg-[#26830C]" *ngIf="item.features?.paid">
{{ item.features?.paid }}
</div>
@if (item.features?.paid) {
<div class="font-bold w-fit desktop-small:text-p2 px-3 py-[0.125rem] rounded text-white bg-[#26830C]">
{{ item.features?.paid }}
</div>
}
</div>
</div>
</div>

View File

@@ -2,102 +2,111 @@
class="page-customer-order-search-results__header bg-background-liste flex items-end justify-between"
[class.pb-4]="!(primaryOutletActive$ | async)"
[class.flex-col]="!(primaryOutletActive$ | async)"
>
>
<div class="flex flex-row w-full desktop-small:w-min" [class.desktop-large:w-full]="!(primaryOutletActive$ | async)">
<shared-filter-input-group-main
*ngIf="filter$ | async; let filter"
class="block mr-3 w-full desktop-small:w-[23.5rem]"
[class.desktop-large:w-full]="!(primaryOutletActive$ | async)"
[hint]="message$ | async"
[loading]="loading$ | async"
[inputGroup]="filter?.input | group: 'main'"
(search)="search({ filter, clear: true })"
[showDescription]="false"
[scanner]="true"
></shared-filter-input-group-main>
@if (filter$ | async; as filter) {
<shared-filter-input-group-main
class="block mr-3 w-full desktop-small:w-[23.5rem]"
[class.desktop-large:w-full]="!(primaryOutletActive$ | async)"
[hint]="message$ | async"
[loading]="loading$ | async"
[inputGroup]="filter?.input | group: 'main'"
(search)="search({ filter, clear: true })"
[showDescription]="false"
[scanner]="true"
></shared-filter-input-group-main>
}
<a
class="page-customer-orders-results__filter w-[6.75rem] h-14 rounded font-bold px-5 mb-4 text-lg bg-[#AEB7C1] flex flex-row flex-nowrap items-center justify-center"
[class.active]="hasFilter$ | async"
[routerLink]="filterRoute"
queryParamsHandling="preserve"
>
>
<shared-icon class="mr-2" icon="filter-variant"></shared-icon>
Filter
</a>
</div>
<div
*ngIf="hits$ | async; let hits"
class="page-customer-order-search-results__items-count inline-flex flex-row items-center pr-5 text-p3"
[class.mb-4]="primaryOutletActive$ | async"
>
{{ hits ?? 0 }}
Titel
</div>
@if (hits$ | async; as hits) {
<div
class="page-customer-order-search-results__items-count inline-flex flex-row items-center pr-5 text-p3"
[class.mb-4]="primaryOutletActive$ | async"
>
{{ hits ?? 0 }}
Titel
</div>
}
</div>
<ui-scroll-container
*ngIf="!(listEmpty$ | async); else emptyMessage"
class="page-customer-order-results__scroll-container m-0 p-0"
[showScrollbar]="false"
[showScrollArrow]="false"
(reachEnd)="loadMore()"
[deltaEnd]="150"
[itemLength]="itemLength$ | async"
[containerHeight]="25"
[showSpacer]="(primaryOutletActive$ | async) || (isTablet$ | async)"
>
<ng-container *ngIf="processId$ | async; let processId">
<div class="page-customer-order-results__items-list w-full" *ngFor="let bueryNumberGroup of items$ | async | groupBy: byBuyerNumberFn">
<ng-container *ngIf="bueryNumberGroup.items[0]; let firstItem">
<div
class="page-customer-order-search__item-header-group w-full grid grid-flow-col gap-x-4 items-center justify-between bg-white text-xl rounded-t px-4 py-[0.875rem] font-bold mb-px-2"
>
<h3 class="m-0 break-words" [class.w-72]="!(primaryOutletActive$ | async)">
{{ firstItem?.organisation }}
<ng-container *ngIf="!!firstItem?.organisation && (!!firstItem?.firstName || !!firstItem?.lastName)">-</ng-container>
{{ firstItem?.lastName }}
{{ firstItem?.firstName }}
</h3>
<h3 class="m-0 break-words text-right" [class.w-40]="!(primaryOutletActive$ | async)">{{ firstItem?.buyerNumber }}</h3>
@if (!(listEmpty$ | async)) {
<ui-scroll-container
class="page-customer-order-results__scroll-container m-0 p-0"
[showScrollbar]="false"
[showScrollArrow]="false"
(reachEnd)="loadMore()"
[deltaEnd]="150"
[itemLength]="itemLength$ | async"
[containerHeight]="25"
[showSpacer]="(primaryOutletActive$ | async) || (isTablet$ | async)"
>
@if (processId$ | async; as processId) {
@for (bueryNumberGroup of items$ | async | groupBy: byBuyerNumberFn; track bueryNumberGroup) {
<div class="page-customer-order-results__items-list w-full">
@if (bueryNumberGroup.items[0]; as firstItem) {
<div
class="page-customer-order-search__item-header-group w-full grid grid-flow-col gap-x-4 items-center justify-between bg-white text-xl rounded-t px-4 py-[0.875rem] font-bold mb-px-2"
>
<h3 class="m-0 break-words" [class.w-72]="!(primaryOutletActive$ | async)">
{{ firstItem?.organisation }}
@if (!!firstItem?.organisation && (!!firstItem?.firstName || !!firstItem?.lastName)) {
-
}
{{ firstItem?.lastName }}
{{ firstItem?.firstName }}
</h3>
<h3 class="m-0 break-words text-right" [class.w-40]="!(primaryOutletActive$ | async)">{{ firstItem?.buyerNumber }}</h3>
</div>
}
@for (orderNumberGroup of bueryNumberGroup.items | groupBy: byOrderNumberFn; track orderNumberGroup) {
@for (processingStatusGroup of orderNumberGroup.items | groupBy: byProcessingStatusFn; track processingStatusGroup) {
@for (compartmentCodeGroup of processingStatusGroup.items | groupBy: byCompartmentCodeFn; track compartmentCodeGroup) {
@for (item of compartmentCodeGroup.items; track trackByFn($index, item); let firstItem = $first) {
<page-customer-order-item
class="page-customer-orders-results__result-item mb-[0.625rem]"
[class.page-customer-orders-results__result-item-main]="primaryOutletActive$ | async"
[item]="item"
[primaryOutletActive]="primaryOutletActive$ | async"
></page-customer-order-item>
}
}
}
}
</div>
</ng-container>
<ng-container *ngFor="let orderNumberGroup of bueryNumberGroup.items | groupBy: byOrderNumberFn">
<ng-container *ngFor="let processingStatusGroup of orderNumberGroup.items | groupBy: byProcessingStatusFn">
<ng-container *ngFor="let compartmentCodeGroup of processingStatusGroup.items | groupBy: byCompartmentCodeFn">
<page-customer-order-item
*ngFor="let item of compartmentCodeGroup.items; let firstItem = first; trackBy: trackByFn"
class="page-customer-orders-results__result-item mb-[0.625rem]"
[class.page-customer-orders-results__result-item-main]="primaryOutletActive$ | async"
[item]="item"
[primaryOutletActive]="primaryOutletActive$ | async"
></page-customer-order-item>
</ng-container>
</ng-container>
</ng-container>
</div>
</ng-container>
</ui-scroll-container>
<ng-template #emptyMessage>
}
}
</ui-scroll-container>
} @else {
<div class="empty-message">
Es sind im Moment keine Bestellposten vorhanden,
<br />
die bearbeitet werden können.
</div>
</ng-template>
}
<div class="actions z-fixed" *ngIf="actions$ | async; let actions">
<button
[disabled]="(loadingFetchedActionButton$ | async) || (loading$ | async)"
class="cta-action"
*ngFor="let action of actions"
[class.cta-action-primary]="action.selected"
[class.cta-action-secondary]="!action.selected"
(click)="handleAction(action)"
>
<ui-spinner [show]="(loadingFetchedActionButton$ | async) || (loading$ | async)">{{ action.label }}</ui-spinner>
</button>
</div>
@if (actions$ | async; as actions) {
<div class="actions z-fixed">
@for (action of actions; track action) {
<button
[disabled]="(loadingFetchedActionButton$ | async) || (loading$ | async)"
class="cta-action"
[class.cta-action-primary]="action.selected"
[class.cta-action-secondary]="!action.selected"
(click)="handleAction(action)"
>
<ui-spinner [show]="(loadingFetchedActionButton$ | async) || (loading$ | async)">{{ action.label }}</ui-spinner>
</button>
}
</div>
}

View File

@@ -4,7 +4,7 @@
[cdkMenuTriggerFor]="navMenu"
#menuTrigger="cdkMenuTriggerFor"
[class.open]="menuTrigger.isOpen()"
>
>
<shared-icon icon="apps" [size]="24"></shared-icon>
<shared-icon [icon]="menuTrigger.isOpen() ? 'arrow-drop-up' : 'arrow-drop-down'" [size]="24"></shared-icon>
</button>
@@ -12,42 +12,46 @@
<ng-template #navMenu>
<div class="pt-1">
<shared-menu>
<a
sharedMenuItem
*ngIf="customerDetailsRoute$ | async; let customerDetailsRoute"
[routerLink]="customerDetailsRoute.path"
[queryParams]="customerDetailsRoute.queryParams"
[queryParamsHandling]="'merge'"
>
Kundendetails
</a>
<a
sharedMenuItem
*ngIf="ordersRoute$ | async; let ordersRoute"
[routerLink]="ordersRoute.path"
[queryParams]="ordersRoute.queryParams"
[queryParamsHandling]="'merge'"
>
Bestellungen
</a>
<a
sharedMenuItem
*ngIf="kundenkarteRoute$ | async; let kundenkarteRoute"
[routerLink]="kundenkarteRoute.path"
[queryParams]="kundenkarteRoute.queryParams"
[queryParamsHandling]="'merge'"
>
Kundenkarte
</a>
<a
sharedMenuItem
*ngIf="historyRoute$ | async; let historyRoute"
[routerLink]="historyRoute.path"
[queryParams]="historyRoute.queryParams"
[queryParamsHandling]="'merge'"
>
Historie
</a>
@if (customerDetailsRoute$ | async; as customerDetailsRoute) {
<a
sharedMenuItem
[routerLink]="customerDetailsRoute.path"
[queryParams]="customerDetailsRoute.queryParams"
[queryParamsHandling]="'merge'"
>
Kundendetails
</a>
}
@if (ordersRoute$ | async; as ordersRoute) {
<a
sharedMenuItem
[routerLink]="ordersRoute.path"
[queryParams]="ordersRoute.queryParams"
[queryParamsHandling]="'merge'"
>
Bestellungen
</a>
}
@if (kundenkarteRoute$ | async; as kundenkarteRoute) {
<a
sharedMenuItem
[routerLink]="kundenkarteRoute.path"
[queryParams]="kundenkarteRoute.queryParams"
[queryParamsHandling]="'merge'"
>
Kundenkarte
</a>
}
@if (historyRoute$ | async; as historyRoute) {
<a
sharedMenuItem
[routerLink]="historyRoute.path"
[queryParams]="historyRoute.queryParams"
[queryParamsHandling]="'merge'"
>
Historie
</a>
}
</shared-menu>
</div>
</ng-template>

View File

@@ -8,7 +8,7 @@ import { map } from 'rxjs/operators';
import { CustomerSearchNavigation } from '@shared/services/navigation';
import { ComponentStore } from '@ngrx/component-store';
import { RouterLink } from '@angular/router';
import { AsyncPipe, NgIf } from '@angular/common';
import { AsyncPipe } from '@angular/common';
export interface CustomerMenuComponentState {
customerId?: number;
@@ -26,7 +26,7 @@ export interface CustomerMenuComponentState {
styleUrls: ['customer-menu.component.css'],
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'page-customer-menu' },
imports: [CdkMenuModule, SharedMenuModule, IconComponent, RouterLink, NgIf, AsyncPipe],
imports: [CdkMenuModule, SharedMenuModule, IconComponent, RouterLink, AsyncPipe],
})
export class CustomerMenuComponent extends ComponentStore<CustomerMenuComponentState> {
private _navigation = inject(CustomerSearchNavigation);

View File

@@ -1,59 +1,66 @@
<cdk-virtual-scroll-viewport
itemSize="100"
class="h-[calc(100vh-20.125rem)] desktop-small:h-[calc(100vh-18.625rem)]"
*ngIf="!compact"
(scrolledIndexChange)="scrolledIndexChange($event)"
>
<a
*cdkVirtualFor="let customer of customers; trackBy: trackByFn; let index = index"
[routerLink]="customerSearchNavigation.detailsRoute({ processId: processId, customerId: customer.id })?.path"
[queryParams]="customerSearchNavigation.detailsRoute({ processId: processId, customerId: customer.id })?.queryParams"
[queryParamsHandling]="'merge'"
(click)="scrolledIndexChange(index)"
routerLinkActive
#rla="routerLinkActive"
>
<page-customer-result-list-item-full [class.active]="rla.isActive" [customer]="customer"></page-customer-result-list-item-full>
</a>
<div class="h-[6.125rem] bg-white rounded px-4 py-3" *ngIf="hits === customers?.length && !fetching">
<ng-container *ngTemplateOutlet="customerNotFound"></ng-container>
</div>
</cdk-virtual-scroll-viewport>
@if (!compact) {
<cdk-virtual-scroll-viewport
itemSize="100"
class="h-[calc(100vh-20.125rem)] desktop-small:h-[calc(100vh-18.625rem)]"
(scrolledIndexChange)="scrolledIndexChange($event)"
>
<a
*cdkVirtualFor="let customer of customers; trackBy: trackByFn; let index = index"
[routerLink]="customerSearchNavigation.detailsRoute({ processId: processId, customerId: customer.id })?.path"
[queryParams]="customerSearchNavigation.detailsRoute({ processId: processId, customerId: customer.id })?.queryParams"
[queryParamsHandling]="'merge'"
(click)="scrolledIndexChange(index)"
routerLinkActive
#rla="routerLinkActive"
>
<page-customer-result-list-item-full [class.active]="rla.isActive" [customer]="customer"></page-customer-result-list-item-full>
</a>
@if (hits === customers?.length && !fetching) {
<div class="h-[6.125rem] bg-white rounded px-4 py-3">
<ng-container *ngTemplateOutlet="customerNotFound"></ng-container>
</div>
}
</cdk-virtual-scroll-viewport>
}
<cdk-virtual-scroll-viewport
itemSize="191"
class="h-[calc(100vh-20.75rem)]"
*ngIf="compact"
(scrolledIndexChange)="scrolledIndexChange($event)"
>
<a
*cdkVirtualFor="let customer of customers; trackBy: trackByFn; let index = index"
[routerLink]="customerSearchNavigation.detailsRoute({ processId: processId, customerId: customer.id })?.path"
[queryParams]="customerSearchNavigation.detailsRoute({ processId: processId, customerId: customer.id })?.queryParams"
[queryParamsHandling]="'merge'"
(click)="scrolledIndexChange(index)"
routerLinkActive
#rla="routerLinkActive"
>
<page-customer-result-list-item [class.active]="rla.isActive" [customer]="customer"></page-customer-result-list-item>
</a>
<div class="h-[11.3125rem] bg-white rounded px-4 py-3" *ngIf="hits === customers?.length && !fetching">
<ng-container *ngTemplateOutlet="customerNotFound"></ng-container>
</div>
</cdk-virtual-scroll-viewport>
@if (compact) {
<cdk-virtual-scroll-viewport
itemSize="191"
class="h-[calc(100vh-20.75rem)]"
(scrolledIndexChange)="scrolledIndexChange($event)"
>
<a
*cdkVirtualFor="let customer of customers; trackBy: trackByFn; let index = index"
[routerLink]="customerSearchNavigation.detailsRoute({ processId: processId, customerId: customer.id })?.path"
[queryParams]="customerSearchNavigation.detailsRoute({ processId: processId, customerId: customer.id })?.queryParams"
[queryParamsHandling]="'merge'"
(click)="scrolledIndexChange(index)"
routerLinkActive
#rla="routerLinkActive"
>
<page-customer-result-list-item [class.active]="rla.isActive" [customer]="customer"></page-customer-result-list-item>
</a>
@if (hits === customers?.length && !fetching) {
<div class="h-[11.3125rem] bg-white rounded px-4 py-3">
<ng-container *ngTemplateOutlet="customerNotFound"></ng-container>
</div>
}
</cdk-virtual-scroll-viewport>
}
<ng-template #customerNotFound>
<div class="text-sm">
Hinweis: Aus Datenschutzgründen werden nur Teilinformationen dargestellt. Tab auf einen Kunden um mehr zu erfahren.
</div>
<div class="font-bold text-lg mt-3">
<span>Kunden nicht gefunden?</span>
<a
*ngIf="customerCreateNavigation.defaultRoute({ processId: processId }); let route"
[routerLink]="route.path"
[queryParams]="route.queryParams"
class="text-brand"
>
Neue Kundendaten erfassen
</a>
@if (customerCreateNavigation.defaultRoute({ processId: processId }); as route) {
<a
[routerLink]="route.path"
[queryParams]="route.queryParams"
class="text-brand"
>
Neue Kundendaten erfassen
</a>
}
</div>
</ng-template>

View File

@@ -1,21 +1,23 @@
<ng-container *ifRole="'Store'">
<shared-checkbox
*ngIf="customerType !== 'b2b'"
[ngModel]="p4mUser"
(ngModelChange)="setValue({ p4mUser: !p4mUser })"
[disabled]="p4mReadonly || readonly"
>
Kundenkarte
</shared-checkbox>
</ng-container>
<ng-container *ngFor="let option of filteredOptions$ | async">
<shared-checkbox
*ngIf="option?.enabled !== false"
[ngModel]="option.value === customerType"
(ngModelChange)="setValue({ customerType: $event ? option.value : undefined })"
[disabled]="isOptionDisabled(option)"
[name]="option.value"
>
{{ option.label }}
</shared-checkbox>
@if (customerType !== 'b2b') {
<shared-checkbox
[ngModel]="p4mUser"
(ngModelChange)="setValue({ p4mUser: !p4mUser })"
[disabled]="p4mReadonly || readonly"
>
Kundenkarte
</shared-checkbox>
}
</ng-container>
@for (option of filteredOptions$ | async; track option) {
@if (option?.enabled !== false) {
<shared-checkbox
[ngModel]="option.value === customerType"
(ngModelChange)="setValue({ customerType: $event ? option.value : undefined })"
[disabled]="isOptionDisabled(option)"
[name]="option.value"
>
{{ option.label }}
</shared-checkbox>
}
}

View File

@@ -6,57 +6,59 @@
type="text"
formControlName="street"
[tabindex]="tabIndexStart"
[readonly]="readonly"
/>
</shared-form-control>
<shared-form-control label="Hausnummer">
<input
placeholder="Hausnummer"
class="input-control"
type="text"
formControlName="streetNumber"
[tabindex]="tabIndexStart + 1"
[readonly]="readonly"
/>
</shared-form-control>
<shared-form-control label="PLZ">
<input
placeholder="PLZ"
class="input-control"
type="text"
formControlName="zipCode"
[tabindex]="tabIndexStart + 2"
[readonly]="readonly"
/>
</shared-form-control>
<shared-form-control label="Ort">
<input
placeholder="Ort"
class="input-control"
type="text"
formControlName="city"
[tabindex]="tabIndexStart + 3"
[readonly]="readonly"
/>
</shared-form-control>
<shared-form-control class="col-span-2" label="Adresszusatz">
<input
placeholder="Adresszusatz"
class="input-control"
type="text"
formControlName="info"
[tabindex]="tabIndexStart + 4"
[readonly]="readonly"
/>
</shared-form-control>
/>
</shared-form-control>
<shared-form-control label="Hausnummer">
<input
placeholder="Hausnummer"
class="input-control"
type="text"
formControlName="streetNumber"
[tabindex]="tabIndexStart + 1"
[readonly]="readonly"
/>
</shared-form-control>
<shared-form-control label="PLZ">
<input
placeholder="PLZ"
class="input-control"
type="text"
formControlName="zipCode"
[tabindex]="tabIndexStart + 2"
[readonly]="readonly"
/>
</shared-form-control>
<shared-form-control label="Ort">
<input
placeholder="Ort"
class="input-control"
type="text"
formControlName="city"
[tabindex]="tabIndexStart + 3"
[readonly]="readonly"
/>
</shared-form-control>
<shared-form-control class="col-span-2" label="Land">
<shared-select placeholder="Land" formControlName="country" [tabindex]="tabIndexStart + 5" [readonly]="readonly">
<shared-select-option *ngFor="let country of countries || (countries$ | async)" [value]="country.isO3166_A_3">
{{ country.name }}
</shared-select-option>
</shared-select>
</shared-form-control>
</ng-container>
<shared-form-control class="col-span-2" label="Adresszusatz">
<input
placeholder="Adresszusatz"
class="input-control"
type="text"
formControlName="info"
[tabindex]="tabIndexStart + 4"
[readonly]="readonly"
/>
</shared-form-control>
<shared-form-control class="col-span-2" label="Land">
<shared-select placeholder="Land" formControlName="country" [tabindex]="tabIndexStart + 5" [readonly]="readonly">
@for (country of countries || (countries$ | async); track country) {
<shared-select-option [value]="country.isO3166_A_3">
{{ country.name }}
</shared-select-option>
}
</shared-select>
</shared-form-control>
</ng-container>

View File

@@ -4,59 +4,64 @@
[tabindex]="tabIndexStart"
[autofocus]="focusAfterInit"
[readonly]="readonly"
>
>
<ng-content></ng-content>
</shared-checkbox>
<div class="address-block" *ngIf="control.value.deviatingAddress">
<div class="wrapper">
<app-organisation-form-block
*ngIf="organisation"
[tabIndexStart]="tabIndexStart + 1"
#orgaBlock
(onInit)="addOrganisationGroup($event)"
(onDestroy)="removeOrganisationGroup()"
[data]="data?.organisation"
#nameFormBlock
[tabIndexStart]="tabIndexStart + 1"
[requiredMarks]="organisationRequiredMarks"
[validatorFns]="organisationValidatorFns"
[readonly]="readonly"
></app-organisation-form-block>
<app-name-form-block
(onInit)="addNameGroup($event)"
(onDestroy)="removeNameGroup()"
[data]="data?.name"
#nameFormBlock
[requiredMarks]="nameRequiredMarks"
[validatorFns]="nameValidatorFns"
[readonly]="readonly"
></app-name-form-block>
<app-address-form-block
#addressFormBlock
(onInit)="addAddressGroup($event)"
(onDestroy)="removeAddressGroup()"
[data]="data?.address"
[requiredMarks]="addressRequiredMarks"
[validatorFns]="addressValidatorFns"
[readonly]="readonly"
></app-address-form-block>
<app-email-form-block
*ngIf="email"
#emailFormBlock
(onInit)="addEmailGroup($event)"
(onDestroy)="removeEmailGroup()"
[data]="data?.email"
[requiredMark]="emailRequiredMark"
[validatorFns]="emailValidationFns"
[readonly]="readonly"
></app-email-form-block>
<app-phone-numbers-form-block
*ngIf="phoneNumbers"
(onInit)="addPhoneNumbersGroup($event)"
(onDestroy)="removePhoneNumbersGroup()"
[readonly]="readonly"
>
[tabIndexStart]="emailFormBlock?.tabIndexEnd+1" [requiredMarks]="phoneNumbersRequiredMarks" [validatorFns]="phoneNumbersValidatorFns">
</app-phone-numbers-form-block>
@if (control.value.deviatingAddress) {
<div class="address-block">
<div class="wrapper">
@if (organisation) {
<app-organisation-form-block
[tabIndexStart]="tabIndexStart + 1"
#orgaBlock
(onInit)="addOrganisationGroup($event)"
(onDestroy)="removeOrganisationGroup()"
[data]="data?.organisation"
#nameFormBlock
[tabIndexStart]="tabIndexStart + 1"
[requiredMarks]="organisationRequiredMarks"
[validatorFns]="organisationValidatorFns"
[readonly]="readonly"
></app-organisation-form-block>
}
<app-name-form-block
(onInit)="addNameGroup($event)"
(onDestroy)="removeNameGroup()"
[data]="data?.name"
#nameFormBlock
[requiredMarks]="nameRequiredMarks"
[validatorFns]="nameValidatorFns"
[readonly]="readonly"
></app-name-form-block>
<app-address-form-block
#addressFormBlock
(onInit)="addAddressGroup($event)"
(onDestroy)="removeAddressGroup()"
[data]="data?.address"
[requiredMarks]="addressRequiredMarks"
[validatorFns]="addressValidatorFns"
[readonly]="readonly"
></app-address-form-block>
@if (email) {
<app-email-form-block
#emailFormBlock
(onInit)="addEmailGroup($event)"
(onDestroy)="removeEmailGroup()"
[data]="data?.email"
[requiredMark]="emailRequiredMark"
[validatorFns]="emailValidationFns"
[readonly]="readonly"
></app-email-form-block>
}
@if (phoneNumbers) {
<app-phone-numbers-form-block
(onInit)="addPhoneNumbersGroup($event)"
(onDestroy)="removePhoneNumbersGroup()"
[readonly]="readonly"
>
[tabIndexStart]="emailFormBlock?.tabIndexEnd+1" [requiredMarks]="phoneNumbersRequiredMarks" [validatorFns]="phoneNumbersValidatorFns">
</app-phone-numbers-form-block>
}
</div>
</div>
</div>
}

View File

@@ -1,12 +1,13 @@
<div class="interests-description">Geben Sie Interessen an, um Ihre persönlichen Kontoangaben zu verfeinern.</div>
<div class="interests-wrapper" [formGroup]="control">
<shared-checkbox
*ngFor="let pair of interests | keyvalue; let idx = index"
[formControlName]="pair.key"
[tabindex]="tabIndexStart + idx"
[autofocus]="focusAfterInit"
[readonly]="readonly"
>
{{ pair.value }}
</shared-checkbox>
@for (pair of interests | keyvalue; track pair; let idx = $index) {
<shared-checkbox
[formControlName]="pair.key"
[tabindex]="tabIndexStart + idx"
[autofocus]="focusAfterInit"
[readonly]="readonly"
>
{{ pair.value }}
</shared-checkbox>
}
</div>

View File

@@ -6,8 +6,10 @@
[readonly]="readonly"
[tabindex]="tabIndexStart"
[autofocus]="focusAfterInit"
>
<shared-select-option *ngFor="let gender of genderSettings.genders" [value]="gender.value">{{ gender.label }}</shared-select-option>
>
@for (gender of genderSettings.genders; track gender) {
<shared-select-option [value]="gender.value">{{ gender.label }}</shared-select-option>
}
</shared-select>
</shared-form-control>
@@ -30,7 +32,7 @@
formControlName="lastName"
[tabindex]="tabIndexStart + 2"
[readonly]="readonly"
/>
/>
</shared-form-control>
<shared-form-control label="Vorname">
@@ -41,6 +43,6 @@
formControlName="firstName"
[tabindex]="tabIndexStart + 3"
[readonly]="readonly"
/>
/>
</shared-form-control>
</ng-container>

View File

@@ -6,30 +6,30 @@
type="text"
formControlName="name"
[tabindex]="tabIndexStart"
[readonly]="readonly"
/>
</shared-form-control>
<ng-container *ngIf="appearence === 'default'">
<shared-form-control label="Abteilung">
<input
placeholder="Abteilung"
class="input-control"
type="text"
formControlName="department"
[tabindex]="tabIndexStart + 1"
[readonly]="readonly"
/>
</shared-form-control>
<shared-form-control label="USt-ID">
<input
placeholder="USt-ID"
class="input-control"
type="text"
formControlName="vatId"
[tabindex]="tabIndexStart + 2"
[readonly]="readonly"
/>
</shared-form-control>
</ng-container>
</ng-container>
@if (appearence === 'default') {
<shared-form-control label="Abteilung">
<input
placeholder="Abteilung"
class="input-control"
type="text"
formControlName="department"
[tabindex]="tabIndexStart + 1"
[readonly]="readonly"
/>
</shared-form-control>
<shared-form-control label="USt-ID">
<input
placeholder="USt-ID"
class="input-control"
type="text"
formControlName="vatId"
[tabindex]="tabIndexStart + 2"
[readonly]="readonly"
/>
</shared-form-control>
}
</ng-container>

View File

@@ -6,9 +6,11 @@
[formControl]="control"
[tabindex]="tabIndexStart"
[readonly]="readonly"
/>
</shared-form-control>
<button type="button" *ngIf="!readonly && canScan()" (click)="scan()">
<shared-icon icon="barcode-scan" [size]="32"></shared-icon>
</button>
/>
</shared-form-control>
@if (!readonly && canScan()) {
<button type="button" (click)="scan()">
<shared-icon icon="barcode-scan" [size]="32"></shared-icon>
</button>
}

View File

@@ -1,38 +1,62 @@
<div class="wrapper text-center" [@cardFlip]="state" (@cardFlip.done)="flipAnimationDone($event)">
<div *ngIf="cardDetails" class="card-main">
<div class="icons text-brand">
<button *ngIf="isCustomerCard && frontside" class="icon-barcode" (click)="flipCard()">
<shared-icon [size]="35" icon="barcode-scanner"></shared-icon>
</button>
<button *ngIf="isCustomerCard && !frontside" class="icon-back" (click)="flipCard()">
<shared-icon [size]="35" icon="refresh"></shared-icon>
</button>
<!-- <div *ngIf="!isCustomerCard" class="icon-delete"><ui-icon (click)="onDeletePartnerCard()" size="25px" icon="trash"></ui-icon></div> -->
</div>
<div class="headline">
<p *ngIf="isCustomerCard && frontside">Ihre Lesepunkte</p>
<p *ngIf="isCustomerCard && !frontside">Kartennummer</p>
<p *ngIf="!isCustomerCard">Partnerkartennummer</p>
</div>
<div class="mt-2">
<div *ngIf="!isCustomerCard || (isCustomerCard && !frontside)" class="card-number">{{ cardDetails.code }}</div>
<div *ngIf="isCustomerCard && frontside" class="points">{{ cardDetails.totalPoints | number }}</div>
</div>
<div class="barcode-button">
<div *ngIf="!isCustomerCard || (isCustomerCard && !frontside)" class="barcode-field">
<img class="barcode" src="/assets/images/barcode.png" alt="Barcode" />
@if (cardDetails) {
<div class="card-main">
<div class="icons text-brand">
@if (isCustomerCard && frontside) {
<button class="icon-barcode" (click)="flipCard()">
<shared-icon [size]="35" icon="barcode-scanner"></shared-icon>
</button>
}
@if (isCustomerCard && !frontside) {
<button class="icon-back" (click)="flipCard()">
<shared-icon [size]="35" icon="refresh"></shared-icon>
</button>
}
<!-- <div *ngIf="!isCustomerCard" class="icon-delete"><ui-icon (click)="onDeletePartnerCard()" size="25px" icon="trash"></ui-icon></div> -->
</div>
<div *ngIf="isCustomerCard && frontside">
<button class="button" (click)="onRewardShop()">Zum Prämienshop</button>
<div class="headline">
@if (isCustomerCard && frontside) {
<p>Ihre Lesepunkte</p>
}
@if (isCustomerCard && !frontside) {
<p>Kartennummer</p>
}
@if (!isCustomerCard) {
<p>Partnerkartennummer</p>
}
</div>
<div class="mt-2">
@if (!isCustomerCard || (isCustomerCard && !frontside)) {
<div class="card-number">{{ cardDetails.code }}</div>
}
@if (isCustomerCard && frontside) {
<div class="points">{{ cardDetails.totalPoints | number }}</div>
}
</div>
<div class="barcode-button">
@if (!isCustomerCard || (isCustomerCard && !frontside)) {
<div class="barcode-field">
<img class="barcode" src="/assets/images/barcode.png" alt="Barcode" />
</div>
}
@if (isCustomerCard && frontside) {
<div>
<button class="button" (click)="onRewardShop()">Zum Prämienshop</button>
</div>
}
</div>
</div>
</div>
}
<div class="card-bottom">
<div *ngIf="!isCustomerCard || (isCustomerCard && !frontside)" class="customer-name">
<p>{{ cardDetails.firstName }} {{ cardDetails.lastName }}</p>
</div>
<div *ngIf="isCustomerCard && frontside" class="logo ml-2">
<img class="logo-picture" src="/assets/images/Hugendubel_Logo.png" alt="Hugendubel Logo" />
</div>
@if (!isCustomerCard || (isCustomerCard && !frontside)) {
<div class="customer-name">
<p>{{ cardDetails.firstName }} {{ cardDetails.lastName }}</p>
</div>
}
@if (isCustomerCard && frontside) {
<div class="logo ml-2">
<img class="logo-picture" src="/assets/images/Hugendubel_Logo.png" alt="Hugendubel Logo" />
</div>
}
</div>
</div>

View File

@@ -1,5 +1,5 @@
import { animate, state, style, transition, trigger } from '@angular/animations';
import { DecimalPipe, NgIf } from '@angular/common';
import { DecimalPipe } from '@angular/common';
import { Component, Input, OnInit } from '@angular/core';
import { IconComponent } from '@shared/components/icon';
import { BonusCardInfoDTO } from '@generated/swagger/crm-api';
@@ -8,7 +8,7 @@ import { BonusCardInfoDTO } from '@generated/swagger/crm-api';
selector: 'page-customer-kundenkarte',
templateUrl: 'kundenkarte.component.html',
styleUrls: ['kundenkarte.component.scss'],
imports: [IconComponent, NgIf, DecimalPipe],
imports: [IconComponent, DecimalPipe],
animations: [
trigger('cardFlip', [
state(

View File

@@ -1,89 +1,83 @@
<form *ngIf="formData$ | async; let data" (keydown.enter)="$event.preventDefault()">
<h1 class="title">Kundendaten erfassen</h1>
<p class="description">
Um Ihnen den ausgewählten Service zu
<br />
ermöglichen, legen wir Ihnen gerne
<br />
ein Kundenkonto an.
</p>
<app-customer-type-selector
[processId]="processId$ | async"
[p4mUser]="false"
customerType="b2b"
(valueChanges)="customerTypeChanged($event)"
></app-customer-type-selector>
<app-organisation-form-block
#orga
[tabIndexStart]="1"
[data]="data.organisation"
(dataChanges)="patchFormData('organisation', $event)"
(onInit)="addFormBlock('organisation', $event)"
[requiredMarks]="organisationFormBlockRequiredMarks"
[validatorFns]="organisationFormBlockValidators"
></app-organisation-form-block>
<app-name-form-block
#name
[tabIndexStart]="orga.tabIndexEnd + 1"
[data]="data.name"
(dataChanges)="patchFormData('name', $event)"
(onInit)="addFormBlock('name', $event)"
></app-name-form-block>
<app-address-form-block
#address
[tabIndexStart]="name.tabIndexEnd + 1"
[data]="data.address"
(dataChanges)="patchFormData('address', $event)"
(onInit)="addFormBlock('address', $event)"
[requiredMarks]="addressRequiredMarks"
[validatorFns]="addressValidators"
[defaults]="{ country: 'DEU' }"
></app-address-form-block>
<app-email-form-block
#email
[tabIndexStart]="address.tabIndexEnd + 1"
[data]="data.email"
(dataChanges)="patchFormData('email', $event)"
[validatorFns]="emailFormBlockValidators"
(onInit)="addFormBlock('email', $event)"
></app-email-form-block>
<app-phone-numbers-form-block
#phoneNumbers
[tabIndexStart]="email.tabIndexEnd + 1"
[data]="data.phoneNumbers"
(dataChanges)="patchFormData('phoneNumbers', $event)"
(onInit)="addFormBlock('phoneNumbers', $event)"
></app-phone-numbers-form-block>
<app-deviating-address-form-block
[tabIndexStart]="phoneNumbers.tabIndexEnd + 1"
[data]="data.deviatingDeliveryAddress"
(dataChanges)="patchFormData('deviatingDeliveryAddress', $event)"
(onInit)="addFormBlock('deviatingDeliveryAddress', $event)"
[nameRequiredMarks]="deviatingNameRequiredMarks"
[nameValidatorFns]="deviatingNameValidationFns"
[addressRequiredMarks]="addressRequiredMarks"
[addressValidatorFns]="addressValidators"
[organisationRequiredMarks]="organisationFormBlockRequiredMarks"
[organisationValidatorFns]="organisationFormBlockValidators"
[defaults]="{ address: { country: 'DEU' } }"
[organisation]="true"
[email]="true"
[phoneNumbers]="true"
>
Die Lieferadresse weicht von der Rechnungsadresse ab
</app-deviating-address-form-block>
<div class="spacer"></div>
<div class="sticky w-full flex items-center justify-center">
<button class="cta-submit" type="button" (click)="save()" [disabled]="form.invalid || form.pending">
<ui-spinner [show]="busy$ | async">Speichern</ui-spinner>
</button>
</div>
</form>
@if (formData$ | async; as data) {
<form (keydown.enter)="$event.preventDefault()">
<h1 class="title">Kundendaten erfassen</h1>
<p class="description">
Um Ihnen den ausgewählten Service zu
<br />
ermöglichen, legen wir Ihnen gerne
<br />
ein Kundenkonto an.
</p>
<app-customer-type-selector
[processId]="processId$ | async"
[p4mUser]="false"
customerType="b2b"
(valueChanges)="customerTypeChanged($event)"
></app-customer-type-selector>
<app-organisation-form-block
#orga
[tabIndexStart]="1"
[data]="data.organisation"
(dataChanges)="patchFormData('organisation', $event)"
(onInit)="addFormBlock('organisation', $event)"
[requiredMarks]="organisationFormBlockRequiredMarks"
[validatorFns]="organisationFormBlockValidators"
></app-organisation-form-block>
<app-name-form-block
#name
[tabIndexStart]="orga.tabIndexEnd + 1"
[data]="data.name"
(dataChanges)="patchFormData('name', $event)"
(onInit)="addFormBlock('name', $event)"
></app-name-form-block>
<app-address-form-block
#address
[tabIndexStart]="name.tabIndexEnd + 1"
[data]="data.address"
(dataChanges)="patchFormData('address', $event)"
(onInit)="addFormBlock('address', $event)"
[requiredMarks]="addressRequiredMarks"
[validatorFns]="addressValidators"
[defaults]="{ country: 'DEU' }"
></app-address-form-block>
<app-email-form-block
#email
[tabIndexStart]="address.tabIndexEnd + 1"
[data]="data.email"
(dataChanges)="patchFormData('email', $event)"
[validatorFns]="emailFormBlockValidators"
(onInit)="addFormBlock('email', $event)"
></app-email-form-block>
<app-phone-numbers-form-block
#phoneNumbers
[tabIndexStart]="email.tabIndexEnd + 1"
[data]="data.phoneNumbers"
(dataChanges)="patchFormData('phoneNumbers', $event)"
(onInit)="addFormBlock('phoneNumbers', $event)"
></app-phone-numbers-form-block>
<app-deviating-address-form-block
[tabIndexStart]="phoneNumbers.tabIndexEnd + 1"
[data]="data.deviatingDeliveryAddress"
(dataChanges)="patchFormData('deviatingDeliveryAddress', $event)"
(onInit)="addFormBlock('deviatingDeliveryAddress', $event)"
[nameRequiredMarks]="deviatingNameRequiredMarks"
[nameValidatorFns]="deviatingNameValidationFns"
[addressRequiredMarks]="addressRequiredMarks"
[addressValidatorFns]="addressValidators"
[organisationRequiredMarks]="organisationFormBlockRequiredMarks"
[organisationValidatorFns]="organisationFormBlockValidators"
[defaults]="{ address: { country: 'DEU' } }"
[organisation]="true"
[email]="true"
[phoneNumbers]="true"
>
Die Lieferadresse weicht von der Rechnungsadresse ab
</app-deviating-address-form-block>
<div class="spacer"></div>
<div class="sticky w-full flex items-center justify-center">
<button class="cta-submit" type="button" (click)="save()" [disabled]="form.invalid || form.pending">
<ui-spinner [show]="busy$ | async">Speichern</ui-spinner>
</button>
</div>
</form>
}

View File

@@ -1,4 +1,4 @@
import { CommonModule } from '@angular/common';
import { Component, ChangeDetectionStrategy } from '@angular/core';
import { ActivatedRoute, RouterModule } from '@angular/router';
import { CustomerCreateSideViewModule } from './customer-create-side-view';
@@ -10,7 +10,7 @@ import { SharedSplitscreenComponent } from '@shared/components/splitscreen';
templateUrl: 'create-customer.component.html',
styleUrls: ['create-customer.component.css'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule, RouterModule, SharedSplitscreenComponent, CustomerCreateSideViewModule],
imports: [RouterModule, SharedSplitscreenComponent, CustomerCreateSideViewModule],
})
export class CreateCustomerComponent {
processId$ = this._activatedRoute.parent.data.pipe(map((data) => data.processId));

View File

@@ -1,102 +1,94 @@
<form *ngIf="formData$ | async; let data" (keydown.enter)="$event.preventDefault()">
<h1 class="title">Kundendaten erfassen</h1>
<p class="description">
Um Ihnen den ausgewählten Service
<br />
zu ermöglichen, legen wir Ihnen
<br />
gerne ein Kundenkonto an.
<br />
</p>
<app-customer-type-selector
[processId]="processId$ | async"
[p4mUser]="false"
customerType="guest"
(valueChanges)="customerTypeChanged($event)"
></app-customer-type-selector>
<app-name-form-block
#name
[tabIndexStart]="1"
[data]="data.name"
(dataChanges)="patchFormData('name', $event)"
(onInit)="addFormBlock('name', $event)"
[requiredMarks]="nameRequiredMarks"
[validatorFns]="nameValidationFns"
></app-name-form-block>
<p class="info">
Wir werden Ihnen Werbung zu ähnlichen Produkten oder Dienstleistungen aus unserem Sortiment per E-Mail zusenden. Sie können der
Verwendung Ihrer Daten jederzeit z.B. mittels der in den E-Mails enthaltenen Abmeldelinks widersprechen, ohne dass hierfür andere als
die Übermittlungskosten nach den Basistarifen entstehen.
</p>
<app-email-form-block
#email
[tabIndexStart]="name.tabIndexEnd + 1"
[data]="data.email"
[requiredMark]="true"
(dataChanges)="patchFormData('email', $event)"
[validatorFns]="emailFormBlockValidators"
(onInit)="addFormBlock('email', $event)"
></app-email-form-block>
<app-organisation-form-block
#orga
[tabIndexStart]="email.tabIndexStart + 1"
[data]="data.organisation"
(dataChanges)="patchFormData('organisation', $event)"
(onInit)="addFormBlock('organisation', $event)"
appearence="compact"
></app-organisation-form-block>
<app-address-form-block
#address
[tabIndexStart]="orga.tabIndexEnd + 1"
[data]="data.address"
(dataChanges)="patchFormData('address', $event)"
(onInit)="addFormBlock('address', $event)"
[requiredMarks]="addressRequiredMarks"
[validatorFns]="addressValidators"
[defaults]="{ country: 'DEU' }"
></app-address-form-block>
<app-phone-numbers-form-block
#phoneNumbers
[tabIndexStart]="address.tabIndexEnd + 1"
[data]="data.phoneNumbers"
(dataChanges)="patchFormData('phoneNumbers', $event)"
(onInit)="addFormBlock('phoneNumbers', $event)"
></app-phone-numbers-form-block>
<app-birth-date-form-block
#birthDate
[tabIndexStart]="phoneNumbers.tabIndexEnd + 1"
(onInit)="addFormBlock('birthDate', $event)"
[data]="data.birthDate"
(dataChanges)="patchFormData('birthDate', $event)"
></app-birth-date-form-block>
<app-deviating-address-form-block
[tabIndexStart]="birthDate.tabIndexEnd + 1"
[data]="data.deviatingDeliveryAddress"
(dataChanges)="patchFormData('deviatingDeliveryAddress', $event)"
(onInit)="addFormBlock('deviatingDeliveryAddress', $event)"
[nameRequiredMarks]="deviatingNameRequiredMarks"
[nameValidatorFns]="deviatingNameValidationFns"
[addressRequiredMarks]="addressRequiredMarks"
[addressValidatorFns]="addressValidators"
[defaults]="{ address: { country: 'DEU' } }"
[organisation]="true"
>
Die Lieferadresse weicht von der Rechnungsadresse ab
</app-deviating-address-form-block>
<div class="spacer"></div>
<div class="sticky w-full flex items-center justify-center">
<button class="cta-submit" type="button" (click)="save()" [disabled]="form.invalid || form.pending">
<ui-spinner [show]="busy$ | async">Speichern</ui-spinner>
</button>
</div>
</form>
@if (formData$ | async; as data) {
<form (keydown.enter)="$event.preventDefault()">
<h1 class="title">Kundendaten erfassen</h1>
<p class="description">
Um Ihnen den ausgewählten Service
<br />
zu ermöglichen, legen wir Ihnen
<br />
gerne ein Kundenkonto an.
<br />
</p>
<app-customer-type-selector
[processId]="processId$ | async"
[p4mUser]="false"
customerType="guest"
(valueChanges)="customerTypeChanged($event)"
></app-customer-type-selector>
<app-name-form-block
#name
[tabIndexStart]="1"
[data]="data.name"
(dataChanges)="patchFormData('name', $event)"
(onInit)="addFormBlock('name', $event)"
[requiredMarks]="nameRequiredMarks"
[validatorFns]="nameValidationFns"
></app-name-form-block>
<p class="info">
Wir werden Ihnen Werbung zu ähnlichen Produkten oder Dienstleistungen aus unserem Sortiment per E-Mail zusenden. Sie können der
Verwendung Ihrer Daten jederzeit z.B. mittels der in den E-Mails enthaltenen Abmeldelinks widersprechen, ohne dass hierfür andere als
die Übermittlungskosten nach den Basistarifen entstehen.
</p>
<app-email-form-block
#email
[tabIndexStart]="name.tabIndexEnd + 1"
[data]="data.email"
[requiredMark]="true"
(dataChanges)="patchFormData('email', $event)"
[validatorFns]="emailFormBlockValidators"
(onInit)="addFormBlock('email', $event)"
></app-email-form-block>
<app-organisation-form-block
#orga
[tabIndexStart]="email.tabIndexStart + 1"
[data]="data.organisation"
(dataChanges)="patchFormData('organisation', $event)"
(onInit)="addFormBlock('organisation', $event)"
appearence="compact"
></app-organisation-form-block>
<app-address-form-block
#address
[tabIndexStart]="orga.tabIndexEnd + 1"
[data]="data.address"
(dataChanges)="patchFormData('address', $event)"
(onInit)="addFormBlock('address', $event)"
[requiredMarks]="addressRequiredMarks"
[validatorFns]="addressValidators"
[defaults]="{ country: 'DEU' }"
></app-address-form-block>
<app-phone-numbers-form-block
#phoneNumbers
[tabIndexStart]="address.tabIndexEnd + 1"
[data]="data.phoneNumbers"
(dataChanges)="patchFormData('phoneNumbers', $event)"
(onInit)="addFormBlock('phoneNumbers', $event)"
></app-phone-numbers-form-block>
<app-birth-date-form-block
#birthDate
[tabIndexStart]="phoneNumbers.tabIndexEnd + 1"
(onInit)="addFormBlock('birthDate', $event)"
[data]="data.birthDate"
(dataChanges)="patchFormData('birthDate', $event)"
></app-birth-date-form-block>
<app-deviating-address-form-block
[tabIndexStart]="birthDate.tabIndexEnd + 1"
[data]="data.deviatingDeliveryAddress"
(dataChanges)="patchFormData('deviatingDeliveryAddress', $event)"
(onInit)="addFormBlock('deviatingDeliveryAddress', $event)"
[nameRequiredMarks]="deviatingNameRequiredMarks"
[nameValidatorFns]="deviatingNameValidationFns"
[addressRequiredMarks]="addressRequiredMarks"
[addressValidatorFns]="addressValidators"
[defaults]="{ address: { country: 'DEU' } }"
[organisation]="true"
>
Die Lieferadresse weicht von der Rechnungsadresse ab
</app-deviating-address-form-block>
<div class="spacer"></div>
<div class="sticky w-full flex items-center justify-center">
<button class="cta-submit" type="button" (click)="save()" [disabled]="form.invalid || form.pending">
<ui-spinner [show]="busy$ | async">Speichern</ui-spinner>
</button>
</div>
</form>
}

View File

@@ -1,10 +1,10 @@
<form *ngIf="formData$ | async; let data" (keydown.enter)="$event.preventDefault()">
<h1 class="title flex flex-row items-center justify-center">
Kundendaten erfassen
<!-- <span
class="rounded-full ml-4 h-8 w-8 text-xl text-center border-2 border-solid border-brand text-brand">i</span> -->
@if (formData$ | async; as data) {
<form (keydown.enter)="$event.preventDefault()">
<h1 class="title flex flex-row items-center justify-center">
Kundendaten erfassen
<!-- <span
class="rounded-full ml-4 h-8 w-8 text-xl text-center border-2 border-solid border-brand text-brand">i</span> -->
</h1>
<p class="description">
Um Sie als Kunde beim nächsten
<br />
@@ -12,7 +12,6 @@
<br />
wir Ihnen gerne eine Kundenkarte an.
</p>
<app-customer-type-selector
[processId]="processId$ | async"
[p4mUser]="true"
@@ -20,7 +19,6 @@
(valueChanges)="customerTypeChanged($event)"
[p4mReadonly]="data?._meta?.p4mRequired"
></app-customer-type-selector>
<app-p4m-number-form-block
#p4mBlock
[tabIndexStart]="1"
@@ -31,7 +29,6 @@
[focusAfterInit]="!data?._meta?.p4mRequired"
[asyncValidatorFns]="asyncLoyaltyCardValidatorFn"
></app-p4m-number-form-block>
<app-accept-agb-form-block
[tabIndexStart]="inBlock.tabIndexEnd + 1"
(onInit)="addFormBlock('agb', $event)"
@@ -40,7 +37,6 @@
[requiredMark]="true"
[validatorFns]="agbValidatorFns"
></app-accept-agb-form-block>
<app-newsletter-form-block
class="mb-4"
#newsletterBlock
@@ -50,7 +46,6 @@
(dataChanges)="patchFormData('newsletter', $event)"
[focusAfterInit]="data?._meta?.p4mRequired"
></app-newsletter-form-block>
<app-name-form-block
#nameBlock
[tabIndexStart]="newsletterBlock.tabIndexEnd + 1"
@@ -60,13 +55,13 @@
[validatorFns]="nameValidationFns"
(dataChanges)="patchFormData('name', $event)"
></app-name-form-block>
<p class="info" *ngIf="customerType === 'webshop-p4m'">
Wir werden Ihnen Werbung zu ähnlichen Produkten oder Dienstleistungen aus unserem Sortiment per E-Mail zusenden. Sie können der
Verwendung Ihrer Daten jederzeit z.B. mittels der in den E-Mails enthaltenen Abmeldelinks widersprechen, ohne dass hierfür andere als
die Übermittlungskosten nach den Basistarifen entstehen.
</p>
@if (customerType === 'webshop-p4m') {
<p class="info">
Wir werden Ihnen Werbung zu ähnlichen Produkten oder Dienstleistungen aus unserem Sortiment per E-Mail zusenden. Sie können der
Verwendung Ihrer Daten jederzeit z.B. mittels der in den E-Mails enthaltenen Abmeldelinks widersprechen, ohne dass hierfür andere als
die Übermittlungskosten nach den Basistarifen entstehen.
</p>
}
<app-email-form-block
class="flex-grow"
#email
@@ -78,7 +73,6 @@
[validatorFns]="emailValidatorFn"
[asyncValidatorFns]="asyncEmailVlaidtorFn"
></app-email-form-block>
<app-organisation-form-block
#orgBlock
[tabIndexStart]="email.tabIndexEnd + 1"
@@ -87,7 +81,6 @@
[data]="data.organisation"
(dataChanges)="patchFormData('organisation', $event)"
></app-organisation-form-block>
<app-address-form-block
[defaults]="{ country: 'DEU' }"
#addressBlock
@@ -98,7 +91,6 @@
[requiredMarks]="addressRequiredMarks"
[validatorFns]="addressValidatorFns"
></app-address-form-block>
<app-deviating-address-form-block
#ddaBlock
[defaults]="{ address: { country: 'DEU' } }"
@@ -110,10 +102,9 @@
[nameValidatorFns]="nameValidationFns"
[addressRequiredMarks]="shippingAddressRequiredMarks"
[addressValidatorFns]="shippingAddressValidators"
>
>
Die Lieferadresse weicht von der Rechnungsadresse ab
</app-deviating-address-form-block>
<app-phone-numbers-form-block
#phoneNumbers
[tabIndexStart]="ddaBlock.tabIndexEnd + 1"
@@ -121,7 +112,6 @@
[data]="data.phoneNumbers"
(dataChanges)="patchFormData('phoneNumbers', $event)"
></app-phone-numbers-form-block>
<app-birth-date-form-block
#bdBlock
[tabIndexStart]="phoneNumbers.tabIndexEnd + 1"
@@ -131,7 +121,6 @@
[requiredMark]="true"
[validatorFns]="birthDateValidatorFns"
></app-birth-date-form-block>
<app-interests-form-block
#inBlock
[tabIndexStart]="bdBlock.tabIndexEnd + 1"
@@ -139,12 +128,11 @@
[data]="data.interests"
(dataChanges)="patchFormData('interests', $event)"
></app-interests-form-block>
<div class="spacer"></div>
<div class="sticky w-full flex items-center justify-center">
<button class="cta-submit" type="button" [disabled]="form.invalid || form.pending" (click)="save()">
<ui-spinner [show]="busy$ | async">Speichern</ui-spinner>
</button>
</div>
</form>
}

View File

@@ -1,86 +1,78 @@
<form *ngIf="formData$ | async; let data" (keydown.enter)="$event.preventDefault()">
<h1 class="title">Kundendaten erfassen</h1>
<p class="description">Wir legen Ihnen gerne ein Kundenkonto an</p>
<app-customer-type-selector
[processId]="processId$ | async"
[p4mUser]="false"
customerType="store"
(valueChanges)="customerTypeChanged($event)"
></app-customer-type-selector>
<app-name-form-block
#name
[tabIndexStart]="1"
(onInit)="addFormBlock('name', $event)"
[requiredMarks]="nameRequiredMarks"
[validatorFns]="nameValidationFns"
[data]="data.name"
(dataChanges)="patchFormData('name', $event)"
></app-name-form-block>
<app-email-form-block
#email
[tabIndexStart]="name.tabIndexEnd + 1"
[validatorFns]="emailFormBlockValidators"
(onInit)="addFormBlock('email', $event)"
[data]="data.email"
(dataChanges)="patchFormData('email', $event)"
></app-email-form-block>
<app-organisation-form-block
#orga
[tabIndexStart]="email.tabIndexEnd + 1"
appearence="compact"
(onInit)="addFormBlock('organisation', $event)"
[data]="data.organisation"
(dataChanges)="patchFormData('organisation', $event)"
></app-organisation-form-block>
<app-address-form-block
#address
[tabIndexStart]="orga.tabIndexEnd + 1"
(onInit)="addFormBlock('address', $event)"
[data]="data.address"
(dataChanges)="patchFormData('address', $event)"
></app-address-form-block>
<app-phone-numbers-form-block
#phoneNumbers
[tabIndexStart]="address.tabIndexEnd + 1"
(onInit)="addFormBlock('phoneNumbers', $event)"
[data]="data.phoneNumbers"
(dataChanges)="patchFormData('phoneNumbers', $event)"
></app-phone-numbers-form-block>
<app-birth-date-form-block
#birthDate
[tabIndexStart]="phoneNumbers.tabIndexEnd + 1"
(onInit)="addFormBlock('birthDate', $event)"
[data]="data.birthDate"
(dataChanges)="patchFormData('birthDate', $event)"
></app-birth-date-form-block>
<app-deviating-address-form-block
[tabIndexStart]="birthDate.tabIndexEnd + 1"
(onInit)="addFormBlock('deviatingDeliveryAddress', $event)"
[organisation]="true"
[nameRequiredMarks]="nameRequiredMarks"
[nameValidatorFns]="nameValidationFns"
[addressRequiredMarks]="addressRequiredMarks"
[addressValidatorFns]="deviatingAddressValidators"
[defaults]="{ address: { country: 'DEU' } }"
[data]="data.deviatingDeliveryAddress"
(dataChanges)="patchFormData('deviatingDeliveryAddress', $event)"
>
Die Lieferadresse weicht von der Rechnungsadresse ab
</app-deviating-address-form-block>
<div class="spacer"></div>
<div class="sticky w-full flex items-center justify-center">
<button class="cta-submit" type="button" [disabled]="form.invalid || form.pending" (click)="save()">
<ui-spinner [show]="busy$ | async">Speichern</ui-spinner>
</button>
</div>
</form>
@if (formData$ | async; as data) {
<form (keydown.enter)="$event.preventDefault()">
<h1 class="title">Kundendaten erfassen</h1>
<p class="description">Wir legen Ihnen gerne ein Kundenkonto an</p>
<app-customer-type-selector
[processId]="processId$ | async"
[p4mUser]="false"
customerType="store"
(valueChanges)="customerTypeChanged($event)"
></app-customer-type-selector>
<app-name-form-block
#name
[tabIndexStart]="1"
(onInit)="addFormBlock('name', $event)"
[requiredMarks]="nameRequiredMarks"
[validatorFns]="nameValidationFns"
[data]="data.name"
(dataChanges)="patchFormData('name', $event)"
></app-name-form-block>
<app-email-form-block
#email
[tabIndexStart]="name.tabIndexEnd + 1"
[validatorFns]="emailFormBlockValidators"
(onInit)="addFormBlock('email', $event)"
[data]="data.email"
(dataChanges)="patchFormData('email', $event)"
></app-email-form-block>
<app-organisation-form-block
#orga
[tabIndexStart]="email.tabIndexEnd + 1"
appearence="compact"
(onInit)="addFormBlock('organisation', $event)"
[data]="data.organisation"
(dataChanges)="patchFormData('organisation', $event)"
></app-organisation-form-block>
<app-address-form-block
#address
[tabIndexStart]="orga.tabIndexEnd + 1"
(onInit)="addFormBlock('address', $event)"
[data]="data.address"
(dataChanges)="patchFormData('address', $event)"
></app-address-form-block>
<app-phone-numbers-form-block
#phoneNumbers
[tabIndexStart]="address.tabIndexEnd + 1"
(onInit)="addFormBlock('phoneNumbers', $event)"
[data]="data.phoneNumbers"
(dataChanges)="patchFormData('phoneNumbers', $event)"
></app-phone-numbers-form-block>
<app-birth-date-form-block
#birthDate
[tabIndexStart]="phoneNumbers.tabIndexEnd + 1"
(onInit)="addFormBlock('birthDate', $event)"
[data]="data.birthDate"
(dataChanges)="patchFormData('birthDate', $event)"
></app-birth-date-form-block>
<app-deviating-address-form-block
[tabIndexStart]="birthDate.tabIndexEnd + 1"
(onInit)="addFormBlock('deviatingDeliveryAddress', $event)"
[organisation]="true"
[nameRequiredMarks]="nameRequiredMarks"
[nameValidatorFns]="nameValidationFns"
[addressRequiredMarks]="addressRequiredMarks"
[addressValidatorFns]="deviatingAddressValidators"
[defaults]="{ address: { country: 'DEU' } }"
[data]="data.deviatingDeliveryAddress"
(dataChanges)="patchFormData('deviatingDeliveryAddress', $event)"
>
Die Lieferadresse weicht von der Rechnungsadresse ab
</app-deviating-address-form-block>
<div class="spacer"></div>
<div class="sticky w-full flex items-center justify-center">
<button class="cta-submit" type="button" [disabled]="form.invalid || form.pending" (click)="save()">
<ui-spinner [show]="busy$ | async">Speichern</ui-spinner>
</button>
</div>
</form>
}

View File

@@ -1,104 +1,95 @@
<form *ngIf="formData$ | async; let data" (keydown.enter)="$event.preventDefault()">
<h1 class="title">Kundendaten erfassen</h1>
<p class="description">
Wenn Sie möchten legen wir Ihnen
<br />
gerne ein Onlinekonto an. Dort können
<br />
Sie Ihre Bestellungen einsehen.
</p>
<app-customer-type-selector
[processId]="processId$ | async"
[p4mUser]="false"
customerType="webshop"
(valueChanges)="customerTypeChanged($event)"
></app-customer-type-selector>
<app-name-form-block
#name
[tabIndexStart]="1"
(onInit)="addFormBlock('name', $event)"
[requiredMarks]="nameRequiredMarks"
[validatorFns]="nameValidationFns"
[data]="data.name"
(dataChanges)="patchFormData('name', $event)"
></app-name-form-block>
<p class="info">
Wir werden Ihnen Werbung zu ähnlichen Produkten oder Dienstleistungen aus unserem Sortiment per E-Mail zusenden. Sie können der
Verwendung Ihrer Daten jederzeit z.B. mittels der in den E-Mails enthaltenen Abmeldelinks widersprechen, ohne dass hierfür andere als
die Übermittlungskosten nach den Basistarifen entstehen.
</p>
<app-email-form-block
#email
[tabIndexStart]="name.tabIndexEnd + 1"
(onInit)="addFormBlock('email', $event)"
[requiredMark]="true"
[asyncValidatorFns]="asyncEmailValidatorFns"
[validatorFns]="emailValidatorFns"
[data]="data.email"
(dataChanges)="patchFormData('email', $event)"
></app-email-form-block>
<app-organisation-form-block
#org
[tabIndexStart]="email.tabIndexEnd + 1"
appearence="compact"
(onInit)="addFormBlock('organisation', $event)"
[data]="data.organisation"
(dataChanges)="patchFormData('organisation', $event)"
></app-organisation-form-block>
<app-address-form-block
#address
[tabIndexStart]="org.tabIndexEnd + 1"
[requiredMarks]="addressRequiredMarks"
[validatorFns]="addressValidators"
(onInit)="addFormBlock('address', $event)"
[defaults]="{ country: 'DEU' }"
[data]="data.address"
(dataChanges)="patchFormData('address', $event)"
></app-address-form-block>
<p class="info">Das Anlegen geht für Sie noch schneller, wenn wir Ihnen das initiale Passwort per SMS auf Ihr Mobilgerät schicken.</p>
<app-phone-numbers-form-block
#phoneNumbers
[tabIndexStart]="address.tabIndexEnd + 1"
(onInit)="addFormBlock('phoneNumbers', $event)"
[data]="data.phoneNumbers"
(dataChanges)="patchFormData('phoneNumbers', $event)"
></app-phone-numbers-form-block>
<app-birth-date-form-block
#birthDate
[tabIndexStart]="phoneNumbers.tabIndexEnd + 1"
(onInit)="addFormBlock('birthDate', $event)"
[data]="data.birthDate"
(dataChanges)="patchFormData('birthDate', $event)"
></app-birth-date-form-block>
<app-deviating-address-form-block
[tabIndexStart]="birthDate.tabIndexEnd + 1"
(onInit)="addFormBlock('deviatingDeliveryAddress', $event)"
appearence="b2b"
[nameRequiredMarks]="nameRequiredMarks"
[nameValidatorFns]="nameValidationFns"
[addressRequiredMarks]="addressRequiredMarks"
[addressValidatorFns]="addressValidators"
[defaults]="{ address: { country: 'DEU' } }"
[data]="data.deviatingDeliveryAddress"
(dataChanges)="patchFormData('deviatingDeliveryAddress', $event)"
>
Die Lieferadresse weicht von der Rechnungsadresse ab
</app-deviating-address-form-block>
<div class="spacer"></div>
<div class="sticky w-full flex items-center justify-center">
<button class="cta-submit" type="button" [disabled]="form.invalid || form.pending" (click)="save()">
<ui-spinner [show]="busy$ | async">Speichern</ui-spinner>
</button>
</div>
</form>
@if (formData$ | async; as data) {
<form (keydown.enter)="$event.preventDefault()">
<h1 class="title">Kundendaten erfassen</h1>
<p class="description">
Wenn Sie möchten legen wir Ihnen
<br />
gerne ein Onlinekonto an. Dort können
<br />
Sie Ihre Bestellungen einsehen.
</p>
<app-customer-type-selector
[processId]="processId$ | async"
[p4mUser]="false"
customerType="webshop"
(valueChanges)="customerTypeChanged($event)"
></app-customer-type-selector>
<app-name-form-block
#name
[tabIndexStart]="1"
(onInit)="addFormBlock('name', $event)"
[requiredMarks]="nameRequiredMarks"
[validatorFns]="nameValidationFns"
[data]="data.name"
(dataChanges)="patchFormData('name', $event)"
></app-name-form-block>
<p class="info">
Wir werden Ihnen Werbung zu ähnlichen Produkten oder Dienstleistungen aus unserem Sortiment per E-Mail zusenden. Sie können der
Verwendung Ihrer Daten jederzeit z.B. mittels der in den E-Mails enthaltenen Abmeldelinks widersprechen, ohne dass hierfür andere als
die Übermittlungskosten nach den Basistarifen entstehen.
</p>
<app-email-form-block
#email
[tabIndexStart]="name.tabIndexEnd + 1"
(onInit)="addFormBlock('email', $event)"
[requiredMark]="true"
[asyncValidatorFns]="asyncEmailValidatorFns"
[validatorFns]="emailValidatorFns"
[data]="data.email"
(dataChanges)="patchFormData('email', $event)"
></app-email-form-block>
<app-organisation-form-block
#org
[tabIndexStart]="email.tabIndexEnd + 1"
appearence="compact"
(onInit)="addFormBlock('organisation', $event)"
[data]="data.organisation"
(dataChanges)="patchFormData('organisation', $event)"
></app-organisation-form-block>
<app-address-form-block
#address
[tabIndexStart]="org.tabIndexEnd + 1"
[requiredMarks]="addressRequiredMarks"
[validatorFns]="addressValidators"
(onInit)="addFormBlock('address', $event)"
[defaults]="{ country: 'DEU' }"
[data]="data.address"
(dataChanges)="patchFormData('address', $event)"
></app-address-form-block>
<p class="info">Das Anlegen geht für Sie noch schneller, wenn wir Ihnen das initiale Passwort per SMS auf Ihr Mobilgerät schicken.</p>
<app-phone-numbers-form-block
#phoneNumbers
[tabIndexStart]="address.tabIndexEnd + 1"
(onInit)="addFormBlock('phoneNumbers', $event)"
[data]="data.phoneNumbers"
(dataChanges)="patchFormData('phoneNumbers', $event)"
></app-phone-numbers-form-block>
<app-birth-date-form-block
#birthDate
[tabIndexStart]="phoneNumbers.tabIndexEnd + 1"
(onInit)="addFormBlock('birthDate', $event)"
[data]="data.birthDate"
(dataChanges)="patchFormData('birthDate', $event)"
></app-birth-date-form-block>
<app-deviating-address-form-block
[tabIndexStart]="birthDate.tabIndexEnd + 1"
(onInit)="addFormBlock('deviatingDeliveryAddress', $event)"
appearence="b2b"
[nameRequiredMarks]="nameRequiredMarks"
[nameValidatorFns]="nameValidationFns"
[addressRequiredMarks]="addressRequiredMarks"
[addressValidatorFns]="addressValidators"
[defaults]="{ address: { country: 'DEU' } }"
[data]="data.deviatingDeliveryAddress"
(dataChanges)="patchFormData('deviatingDeliveryAddress', $event)"
>
Die Lieferadresse weicht von der Rechnungsadresse ab
</app-deviating-address-form-block>
<div class="spacer"></div>
<div class="sticky w-full flex items-center justify-center">
<button class="cta-submit" type="button" [disabled]="form.invalid || form.pending" (click)="save()">
<ui-spinner [show]="busy$ | async">Speichern</ui-spinner>
</button>
</div>
</form>
}

View File

@@ -1,4 +1,4 @@
import { CommonModule } from '@angular/common';
import { Component, ChangeDetectionStrategy } from '@angular/core';
import { RouterModule } from '@angular/router';
@@ -7,7 +7,7 @@ import { RouterModule } from '@angular/router';
templateUrl: 'customer-create-side-view.component.html',
styleUrls: ['customer-create-side-view.component.css'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule, RouterModule],
imports: [RouterModule],
})
export class CustomerCreateSideViewComponent {
constructor() {}

View File

@@ -1,121 +1,109 @@
<form *ngIf="formData$ | async; let data" (keydown.enter)="$event.preventDefault()">
<h1 class="title flex flex-row items-center justify-center">Kundenkartendaten erfasen</h1>
<p class="description">Bitte erfassen Sie die Kundenkarte</p>
<app-customer-type-selector
[processId]="processId$ | async"
[p4mUser]="true"
customerType="webshop"
[readonly]="true"
></app-customer-type-selector>
<app-p4m-number-form-block
#p4mBlock
[tabIndexStart]="1"
(onInit)="addFormBlock('p4m', $event)"
[data]="data.p4m"
(dataChanges)="patchFormData('p4m', $event)"
[focusAfterInit]="!data?._meta?.p4mRequired"
[asyncValidatorFns]="asyncLoyaltyCardValidatorFn"
></app-p4m-number-form-block>
<app-accept-agb-form-block
[tabIndexStart]="inBlock.tabIndexEnd + 1"
(onInit)="addFormBlock('agb', $event)"
[data]="data.agb"
(dataChanges)="patchFormData('agb', $event)"
[requiredMark]="true"
[validatorFns]="agbValidatorFns"
></app-accept-agb-form-block>
<app-newsletter-form-block
class="mb-4"
#newsletterBlock
[tabIndexStart]="p4mBlock.tabIndexEnd + 1"
(onInit)="addFormBlock('newsletter', $event)"
[data]="data.newsletter"
(dataChanges)="patchFormData('newsletter', $event)"
[focusAfterInit]="data?._meta?.p4mRequired"
></app-newsletter-form-block>
<app-name-form-block (onInit)="addFormBlock('name', $event)" [data]="data.name" readonly></app-name-form-block>
<app-email-form-block (onInit)="addFormBlock('email', $event)" [data]="data.email" readonly></app-email-form-block>
<app-organisation-form-block
(onInit)="addFormBlock('organisation', $event)"
appearence="compact"
[data]="data.organisation"
readonly
></app-organisation-form-block>
<app-address-form-block [data]="data.address" readonly></app-address-form-block>
<div class="mt-8">
<h4 class="-mb-6">Rechnungsadresse</h4>
<ui-form-control class="-mb-5" [showHint]="false">
<input type="text" [value]="(billingAddress | address: true) ?? 'Keine Adresse vorhanden'" [readonly]="true" />
</ui-form-control>
<app-deviating-address-form-block
#dbaBlock
[defaults]="{ address: { country: 'DEU' } }"
[tabIndexStart]="newsletterBlock.tabIndexEnd + 1"
(onInit)="addFormBlock('billingAddress', $event)"
[data]="data.billingAddress"
(dataChanges)="patchFormData('billingAddress', $event)"
[nameRequiredMarks]="nameRequiredMarks"
[nameValidatorFns]="nameValidationFns"
[addressRequiredMarks]="addressRequiredMarks"
[addressValidatorFns]="addressValidatorFns"
>
Abweichende Rechnungsadresse anlegen
</app-deviating-address-form-block>
</div>
<div class="mt-8">
<h4 class="-mb-6">Lieferadresse</h4>
<ui-form-control class="-mb-5" [showHint]="false">
<input type="text" [value]="(shippingAddress | address: true) ?? 'Keine Adresse vorhanden'" [readonly]="true" />
</ui-form-control>
<app-deviating-address-form-block
#ddaBlock
[defaults]="{ address: { country: 'DEU' } }"
[tabIndexStart]="dbaBlock.tabIndexEnd + 1"
(onInit)="addFormBlock('deviatingDeliveryAddress', $event)"
[data]="data.deviatingDeliveryAddress"
(dataChanges)="patchFormData('deviatingDeliveryAddress', $event)"
[nameRequiredMarks]="nameRequiredMarks"
[nameValidatorFns]="nameValidationFns"
[addressRequiredMarks]="addressRequiredMarks"
[addressValidatorFns]="addressValidatorFns"
>
Abweichende Lieferadresse anlegen
</app-deviating-address-form-block>
</div>
<app-birth-date-form-block
#bdBlock
[tabIndexStart]="ddaBlock.tabIndexEnd + 1"
(onInit)="addFormBlock('birthDate', $event)"
[data]="data.birthDate"
(dataChanges)="patchFormData('birthDate', $event)"
[requiredMark]="true"
[validatorFns]="birthDateValidatorFns"
></app-birth-date-form-block>
<app-interests-form-block
#inBlock
[tabIndexStart]="bdBlock.tabIndexEnd + 1"
(onInit)="addFormBlock('interests', $event)"
[data]="data.interests"
(dataChanges)="patchFormData('interests', $event)"
></app-interests-form-block>
<div class="spacer"></div>
<div class="sticky w-full flex items-center justify-center">
<button class="cta-submit" type="button" [disabled]="form.invalid || form.pending" (click)="save()">
<ui-spinner [show]="busy$ | async">Speichern</ui-spinner>
</button>
</div>
</form>
@if (formData$ | async; as data) {
<form (keydown.enter)="$event.preventDefault()">
<h1 class="title flex flex-row items-center justify-center">Kundenkartendaten erfasen</h1>
<p class="description">Bitte erfassen Sie die Kundenkarte</p>
<app-customer-type-selector
[processId]="processId$ | async"
[p4mUser]="true"
customerType="webshop"
[readonly]="true"
></app-customer-type-selector>
<app-p4m-number-form-block
#p4mBlock
[tabIndexStart]="1"
(onInit)="addFormBlock('p4m', $event)"
[data]="data.p4m"
(dataChanges)="patchFormData('p4m', $event)"
[focusAfterInit]="!data?._meta?.p4mRequired"
[asyncValidatorFns]="asyncLoyaltyCardValidatorFn"
></app-p4m-number-form-block>
<app-accept-agb-form-block
[tabIndexStart]="inBlock.tabIndexEnd + 1"
(onInit)="addFormBlock('agb', $event)"
[data]="data.agb"
(dataChanges)="patchFormData('agb', $event)"
[requiredMark]="true"
[validatorFns]="agbValidatorFns"
></app-accept-agb-form-block>
<app-newsletter-form-block
class="mb-4"
#newsletterBlock
[tabIndexStart]="p4mBlock.tabIndexEnd + 1"
(onInit)="addFormBlock('newsletter', $event)"
[data]="data.newsletter"
(dataChanges)="patchFormData('newsletter', $event)"
[focusAfterInit]="data?._meta?.p4mRequired"
></app-newsletter-form-block>
<app-name-form-block (onInit)="addFormBlock('name', $event)" [data]="data.name" readonly></app-name-form-block>
<app-email-form-block (onInit)="addFormBlock('email', $event)" [data]="data.email" readonly></app-email-form-block>
<app-organisation-form-block
(onInit)="addFormBlock('organisation', $event)"
appearence="compact"
[data]="data.organisation"
readonly
></app-organisation-form-block>
<app-address-form-block [data]="data.address" readonly></app-address-form-block>
<div class="mt-8">
<h4 class="-mb-6">Rechnungsadresse</h4>
<ui-form-control class="-mb-5" [showHint]="false">
<input type="text" [value]="(billingAddress | address: true) ?? 'Keine Adresse vorhanden'" [readonly]="true" />
</ui-form-control>
<app-deviating-address-form-block
#dbaBlock
[defaults]="{ address: { country: 'DEU' } }"
[tabIndexStart]="newsletterBlock.tabIndexEnd + 1"
(onInit)="addFormBlock('billingAddress', $event)"
[data]="data.billingAddress"
(dataChanges)="patchFormData('billingAddress', $event)"
[nameRequiredMarks]="nameRequiredMarks"
[nameValidatorFns]="nameValidationFns"
[addressRequiredMarks]="addressRequiredMarks"
[addressValidatorFns]="addressValidatorFns"
>
Abweichende Rechnungsadresse anlegen
</app-deviating-address-form-block>
</div>
<div class="mt-8">
<h4 class="-mb-6">Lieferadresse</h4>
<ui-form-control class="-mb-5" [showHint]="false">
<input type="text" [value]="(shippingAddress | address: true) ?? 'Keine Adresse vorhanden'" [readonly]="true" />
</ui-form-control>
<app-deviating-address-form-block
#ddaBlock
[defaults]="{ address: { country: 'DEU' } }"
[tabIndexStart]="dbaBlock.tabIndexEnd + 1"
(onInit)="addFormBlock('deviatingDeliveryAddress', $event)"
[data]="data.deviatingDeliveryAddress"
(dataChanges)="patchFormData('deviatingDeliveryAddress', $event)"
[nameRequiredMarks]="nameRequiredMarks"
[nameValidatorFns]="nameValidationFns"
[addressRequiredMarks]="addressRequiredMarks"
[addressValidatorFns]="addressValidatorFns"
>
Abweichende Lieferadresse anlegen
</app-deviating-address-form-block>
</div>
<app-birth-date-form-block
#bdBlock
[tabIndexStart]="ddaBlock.tabIndexEnd + 1"
(onInit)="addFormBlock('birthDate', $event)"
[data]="data.birthDate"
(dataChanges)="patchFormData('birthDate', $event)"
[requiredMark]="true"
[validatorFns]="birthDateValidatorFns"
></app-birth-date-form-block>
<app-interests-form-block
#inBlock
[tabIndexStart]="bdBlock.tabIndexEnd + 1"
(onInit)="addFormBlock('interests', $event)"
[data]="data.interests"
(dataChanges)="patchFormData('interests', $event)"
></app-interests-form-block>
<div class="spacer"></div>
<div class="sticky w-full flex items-center justify-center">
<button class="cta-submit" type="button" [disabled]="form.invalid || form.pending" (click)="save()">
<ui-spinner [show]="busy$ | async">Speichern</ui-spinner>
</button>
</div>
</form>
}

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