Compare commits

...

29 Commits

Author SHA1 Message Date
Nino
819a7bbe53 hotfix(catalog): correct regex pattern matching for Reihe line detection
Centralized the Reihe prefix pattern into a shared constant to ensure
consistent matching across LineTypePipe and ReiheRoutePipe. The pattern
now correctly matches "Reihe:", "Reihe/Set:", and "Set/Reihe:" prefixes.

Additionally enhanced the ReiheRoutePipe to strip trailing numbers from
series names (e.g., "Harry Potter 1" -> "Harry Potter") to improve
search query accuracy when navigating to article search results.

Changes:
- Created REIHE_PREFIX_PATTERN constant for shared regex pattern
- Updated LineTypePipe to use centralized pattern (fixed capture group index)
- Updated ReiheRoutePipe to use centralized pattern and strip trailing numbers

Ref: #5421
2025-10-30 16:04:44 +01:00
Nino
353864e2f0 hotfix(customer-card-deactivate): Hotfix due to Keycard ISA Login Error 2025-10-24 11:18:25 +02:00
Nino Righi
1b6b726036 Merged PR 1975: hotfix(remission-list): prioritize reload trigger over exact search
hotfix(remission-list): prioritize reload trigger over exact search

Fix navigation issue where reload searches were incorrectly applying
exact search logic, causing filters to be cleared when they should
be preserved during navigation.

Changes:
- Update remission-list.resource.ts to check reload trigger before
  exact search conditions
- Ensure reload trigger always clears input but preserves other query
  parameters
- Prevent exact search from overriding reload behavior
- Add explanatory comment for reload priority logic

This ensures proper filter state management when users navigate
between remission lists, maintaining expected behavior for both
reload and exact search scenarios.

Ref: #5387
2025-10-21 12:08:06 +00:00
Nino Righi
4c56f394c5 Merged PR 1972: hotfix(remission-list-item, remission-list-empty-state): improve empty state...
hotfix(remission-list-item, remission-list-empty-state): improve empty state logic and cleanup selected items on destroy

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

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

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

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

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

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

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

Unit Tests updated also

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Ref: #5292
2025-09-02 15:20:14 +00:00
Nino
2cb1f9ec99 chore(azure-pipelines): Version bump 4.1 2025-08-14 17:09:13 +02:00
124 changed files with 3720 additions and 1854 deletions

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,5 @@
import { Pipe, PipeTransform } from '@angular/core';
import { REIHE_PREFIX_PATTERN } from './reihe.constants';
@Pipe({
name: 'lineType',
@@ -7,8 +8,8 @@ import { Pipe, PipeTransform } from '@angular/core';
})
export class LineTypePipe implements PipeTransform {
transform(value: string, ...args: any[]): 'text' | 'reihe' {
const REIHE_REGEX = /^Reihe:\s*"(.+)\"$/g;
const reihe = REIHE_REGEX.exec(value)?.[1];
const REIHE_REGEX = new RegExp(`^${REIHE_PREFIX_PATTERN}:\\s*"(.+)"$`, 'g');
const reihe = REIHE_REGEX.exec(value)?.[2];
return reihe ? 'reihe' : 'text';
}
}

View File

@@ -1,9 +1,15 @@
import { ChangeDetectorRef, OnDestroy, Pipe, PipeTransform } from '@angular/core';
import {
ChangeDetectorRef,
OnDestroy,
Pipe,
PipeTransform,
} from '@angular/core';
import { ApplicationService } from '@core/application';
import { ProductCatalogNavigationService } from '@shared/services/navigation';
import { isEqual } from 'lodash';
import { Subscription, combineLatest, BehaviorSubject } from 'rxjs';
import { distinctUntilChanged } from 'rxjs/operators';
import { REIHE_PREFIX_PATTERN } from './reihe.constants';
@Pipe({
name: 'reiheRoute',
@@ -22,10 +28,13 @@ export class ReiheRoutePipe implements PipeTransform, OnDestroy {
private application: ApplicationService,
private cdr: ChangeDetectorRef,
) {
this.subscription = combineLatest([this.application.activatedProcessId$, this.value$])
this.subscription = combineLatest([
this.application.activatedProcessId$,
this.value$,
])
.pipe(distinctUntilChanged(isEqual))
.subscribe(([processId, value]) => {
const REIHE_REGEX = /[";]|Reihe:/g; // Entferne jedes Semikolon, Anführungszeichen und den String Reihe:
const REIHE_REGEX = new RegExp(`[";]|${REIHE_PREFIX_PATTERN}:`, 'g'); // Entferne jedes Semikolon, Anführungszeichen und den String Reihe:/Reihe/Set:/Set/Reihe:
const reihe = value?.replace(REIHE_REGEX, '')?.trim();
if (!reihe) {
@@ -33,9 +42,15 @@ export class ReiheRoutePipe implements PipeTransform, OnDestroy {
return;
}
const main_qs = reihe.split('/')[0];
// Entferne Zahlen am Ende, die mit Leerzeichen, Komma, Slash oder Semikolon getrennt sind
// Beispiele: "Harry Potter 1" -> "Harry Potter", "Harry Potter,1" -> "Harry Potter", "Harry Potter/2" -> "Harry Potter"
const main_qs = reihe
.split('/')[0]
.replace(/[\s,;]+\d+$/g, '')
.trim();
const path = this.navigation.getArticleSearchResultsPath(processId).path;
const path =
this.navigation.getArticleSearchResultsPath(processId).path;
this.result = {
path,

View File

@@ -0,0 +1,5 @@
/**
* Shared regex pattern for matching Reihe line prefixes.
* Matches: "Reihe:", "Reihe/Set:", or "Set/Reihe:"
*/
export const REIHE_PREFIX_PATTERN = '(Reihe|Reihe\\/Set|Set\\/Reihe)';

View File

@@ -1,4 +1,4 @@
<ng-container *ifRole="'Store'">
<!-- <ng-container *ifRole="'Store'">
@if (customerType !== 'b2b') {
<shared-checkbox
[ngModel]="p4mUser"
@@ -8,15 +8,17 @@
Kundenkarte
</shared-checkbox>
}
</ng-container>
</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 })"
(ngModelChange)="
setValue({ customerType: $event ? option.value : undefined })
"
[disabled]="isOptionDisabled(option)"
[name]="option.value"
>
>
{{ option.label }}
</shared-checkbox>
}

View File

@@ -21,7 +21,13 @@ import { OptionDTO } from '@generated/swagger/checkout-api';
import { UiCheckboxComponent } from '@ui/checkbox';
import { first, isBoolean, isString } from 'lodash';
import { combineLatest, Observable, Subject } from 'rxjs';
import { distinctUntilChanged, filter, map, shareReplay, switchMap } from 'rxjs/operators';
import {
distinctUntilChanged,
filter,
map,
shareReplay,
switchMap,
} from 'rxjs/operators';
export interface CustomerTypeSelectorState {
processId: number;
@@ -58,18 +64,18 @@ export class CustomerTypeSelectorComponent
@Input()
get value() {
if (this.p4mUser) {
return `${this.customerType}-p4m`;
}
// if (this.p4mUser) {
// return `${this.customerType}-p4m`;
// }
return this.customerType;
}
set value(value: string) {
if (value.includes('-p4m')) {
this.p4mUser = true;
this.customerType = value.replace('-p4m', '');
} else {
this.customerType = value;
}
// if (value.includes('-p4m')) {
// this.p4mUser = true;
// this.customerType = value.replace('-p4m', '');
// } else {
this.customerType = value;
// }
}
@Output()
@@ -111,30 +117,35 @@ export class CustomerTypeSelectorComponent
get filteredOptions$() {
const options$ = this.select((s) => s.options).pipe(distinctUntilChanged());
const p4mUser$ = this.select((s) => s.p4mUser).pipe(distinctUntilChanged());
const customerType$ = this.select((s) => s.customerType).pipe(distinctUntilChanged());
const customerType$ = this.select((s) => s.customerType).pipe(
distinctUntilChanged(),
);
return combineLatest([options$, p4mUser$, customerType$]).pipe(
filter(([options]) => options?.length > 0),
map(([options, p4mUser, customerType]) => {
const initial = { p4mUser: this.p4mUser, customerType: this.customerType };
const initial = {
p4mUser: this.p4mUser,
customerType: this.customerType,
};
let result: OptionDTO[] = options;
if (p4mUser) {
result = result.filter((o) => o.value === 'store' || (o.value === 'webshop' && o.enabled !== false));
// if (p4mUser) {
// result = result.filter((o) => o.value === 'store' || (o.value === 'webshop' && o.enabled !== false));
result = result.map((o) => {
if (o.value === 'store') {
return { ...o, enabled: false };
}
return o;
});
}
// result = result.map((o) => {
// if (o.value === 'store') {
// return { ...o, enabled: false };
// }
// return o;
// });
// }
if (customerType === 'b2b' && this.p4mUser) {
this.p4mUser = false;
}
if (initial.p4mUser !== this.p4mUser || initial.customerType !== this.customerType) {
this.setValue({ customerType: this.customerType, p4mUser: this.p4mUser });
}
// if (initial.p4mUser !== this.p4mUser || initial.customerType !== this.customerType) {
// this.setValue({ customerType: this.customerType, p4mUser: this.p4mUser });
// }
return result;
}),
@@ -224,42 +235,53 @@ export class CustomerTypeSelectorComponent
if (typeof value === 'string') {
this.value = value;
} else {
if (isBoolean(value.p4mUser)) {
this.p4mUser = value.p4mUser;
}
// if (isBoolean(value.p4mUser)) {
// this.p4mUser = value.p4mUser;
// }
if (isString(value.customerType)) {
this.customerType = value.customerType;
} else if (this.p4mUser) {
// Implementierung wie im PBI #3467 beschrieben
// wenn customerType nicht gesetzt wird und p4mUser true ist,
// dann customerType auf store setzen.
// wenn dies nicht möglich ist da der Warenkob keinen store Kunden zulässt,
// dann customerType auf webshop setzen.
// wenn dies nicht möglich ist da der Warenkob keinen webshop Kunden zulässt,
// dann customerType auf den ersten verfügbaren setzen und p4mUser auf false setzen.
if (this.enabledOptions.some((o) => o.value === 'store')) {
this.customerType = 'store';
} else if (this.enabledOptions.some((o) => o.value === 'webshop')) {
this.customerType = 'webshop';
} else {
this.p4mUser = false;
const includesGuest = this.enabledOptions.some((o) => o.value === 'guest');
this.customerType = includesGuest ? 'guest' : first(this.enabledOptions)?.value;
}
// } else if (this.p4mUser) {
// // Implementierung wie im PBI #3467 beschrieben
// // wenn customerType nicht gesetzt wird und p4mUser true ist,
// // dann customerType auf store setzen.
// // wenn dies nicht möglich ist da der Warenkob keinen store Kunden zulässt,
// // dann customerType auf webshop setzen.
// // wenn dies nicht möglich ist da der Warenkob keinen webshop Kunden zulässt,
// // dann customerType auf den ersten verfügbaren setzen und p4mUser auf false setzen.
// if (this.enabledOptions.some((o) => o.value === 'store')) {
// this.customerType = 'store';
// } else if (this.enabledOptions.some((o) => o.value === 'webshop')) {
// this.customerType = 'webshop';
// } else {
// this.p4mUser = false;
// const includesGuest = this.enabledOptions.some(
// (o) => o.value === 'guest',
// );
// this.customerType = includesGuest
// ? 'guest'
// : first(this.enabledOptions)?.value;
// }
} else {
// wenn customerType nicht gesetzt wird und p4mUser false ist,
// dann customerType auf den ersten verfügbaren setzen der nicht mit dem aktuellen customerType übereinstimmt.
this.customerType =
first(this.enabledOptions.filter((o) => o.value === this.customerType))?.value ?? this.customerType;
first(
this.enabledOptions.filter((o) => o.value === this.customerType),
)?.value ?? this.customerType;
}
}
if (this.customerType !== initial.customerType || this.p4mUser !== initial.p4mUser) {
if (
this.customerType !== initial.customerType ||
this.p4mUser !== initial.p4mUser
) {
this.onChange(this.value);
this.onTouched();
this.valueChanges.emit(this.value);
}
this.checkboxes?.find((c) => c.name === this.customerType)?.writeValue(true);
this.checkboxes
?.find((c) => c.name === this.customerType)
?.writeValue(true);
}
}

View File

@@ -7,6 +7,6 @@ export * from './interests';
export * from './name';
export * from './newsletter';
export * from './organisation';
export * from './p4m-number';
// export * from './p4m-number';
export * from './phone-numbers';
export * from './form-block';

View File

@@ -1,4 +1,4 @@
// start:ng42.barrel
export * from './p4m-number-form-block.component';
export * from './p4m-number-form-block.module';
// end:ng42.barrel
// // start:ng42.barrel
// export * from './p4m-number-form-block.component';
// export * from './p4m-number-form-block.module';
// // end:ng42.barrel

View File

@@ -1,4 +1,4 @@
<shared-form-control label="Kundenkartencode" class="flex-grow">
<!-- <shared-form-control label="Kundenkartencode" class="flex-grow">
<input
placeholder="Kundenkartencode"
class="input-control"
@@ -13,4 +13,4 @@
<button type="button" (click)="scan()">
<shared-icon icon="barcode-scan" [size]="32"></shared-icon>
</button>
}
} -->

View File

@@ -1,49 +1,49 @@
import { Component, ChangeDetectionStrategy, ChangeDetectorRef } from '@angular/core';
import { UntypedFormControl, Validators } from '@angular/forms';
import { FormBlockControl } from '../form-block';
import { ScanAdapterService } from '@adapter/scan';
// import { Component, ChangeDetectionStrategy, ChangeDetectorRef } from '@angular/core';
// import { UntypedFormControl, Validators } from '@angular/forms';
// import { FormBlockControl } from '../form-block';
// import { ScanAdapterService } from '@adapter/scan';
@Component({
selector: 'app-p4m-number-form-block',
templateUrl: 'p4m-number-form-block.component.html',
styleUrls: ['p4m-number-form-block.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: false,
})
export class P4mNumberFormBlockComponent extends FormBlockControl<string> {
get tabIndexEnd() {
return this.tabIndexStart;
}
// @Component({
// selector: 'app-p4m-number-form-block',
// templateUrl: 'p4m-number-form-block.component.html',
// styleUrls: ['p4m-number-form-block.component.scss'],
// changeDetection: ChangeDetectionStrategy.OnPush,
// standalone: false,
// })
// export class P4mNumberFormBlockComponent extends FormBlockControl<string> {
// get tabIndexEnd() {
// return this.tabIndexStart;
// }
constructor(
private scanAdapter: ScanAdapterService,
private changeDetectorRef: ChangeDetectorRef,
) {
super();
}
// constructor(
// private scanAdapter: ScanAdapterService,
// private changeDetectorRef: ChangeDetectorRef,
// ) {
// super();
// }
updateValidators(): void {
this.control.setValidators([...this.getValidatorFn()]);
this.control.setAsyncValidators(this.getAsyncValidatorFn());
this.control.updateValueAndValidity();
}
// updateValidators(): void {
// this.control.setValidators([...this.getValidatorFn()]);
// this.control.setAsyncValidators(this.getAsyncValidatorFn());
// this.control.updateValueAndValidity();
// }
initializeControl(data?: string): void {
this.control = new UntypedFormControl(data ?? '', [Validators.required], this.getAsyncValidatorFn());
}
// initializeControl(data?: string): void {
// this.control = new UntypedFormControl(data ?? '', [Validators.required], this.getAsyncValidatorFn());
// }
_patchValue(update: { previous: string; current: string }): void {
this.control.patchValue(update.current);
}
// _patchValue(update: { previous: string; current: string }): void {
// this.control.patchValue(update.current);
// }
scan() {
this.scanAdapter.scan().subscribe((result) => {
this.control.patchValue(result);
this.changeDetectorRef.markForCheck();
});
}
// scan() {
// this.scanAdapter.scan().subscribe((result) => {
// this.control.patchValue(result);
// this.changeDetectorRef.markForCheck();
// });
// }
canScan() {
return this.scanAdapter.isReady();
}
}
// canScan() {
// return this.scanAdapter.isReady();
// }
// }

View File

@@ -1,14 +1,14 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
// import { NgModule } from '@angular/core';
// import { CommonModule } from '@angular/common';
import { P4mNumberFormBlockComponent } from './p4m-number-form-block.component';
import { ReactiveFormsModule } from '@angular/forms';
import { IconComponent } from '@shared/components/icon';
import { FormControlComponent } from '@shared/components/form-control';
// import { P4mNumberFormBlockComponent } from './p4m-number-form-block.component';
// import { ReactiveFormsModule } from '@angular/forms';
// import { IconComponent } from '@shared/components/icon';
// import { FormControlComponent } from '@shared/components/form-control';
@NgModule({
imports: [CommonModule, ReactiveFormsModule, FormControlComponent, IconComponent],
exports: [P4mNumberFormBlockComponent],
declarations: [P4mNumberFormBlockComponent],
})
export class P4mNumberFormBlockModule {}
// @NgModule({
// imports: [CommonModule, ReactiveFormsModule, FormControlComponent, IconComponent],
// exports: [P4mNumberFormBlockComponent],
// declarations: [P4mNumberFormBlockComponent],
// })
// export class P4mNumberFormBlockModule {}

View File

@@ -1,5 +1,12 @@
import { HttpErrorResponse } from '@angular/common/http';
import { ChangeDetectorRef, Directive, OnDestroy, OnInit, ViewChild, inject } from '@angular/core';
import {
ChangeDetectorRef,
Directive,
OnDestroy,
OnInit,
ViewChild,
inject,
} from '@angular/core';
import {
AbstractControl,
AsyncValidatorFn,
@@ -11,7 +18,12 @@ import {
import { ActivatedRoute, Router } from '@angular/router';
import { BreadcrumbService } from '@core/breadcrumb';
import { CrmCustomerService } from '@domain/crm';
import { AddressDTO, CustomerDTO, PayerDTO, ShippingAddressDTO } from '@generated/swagger/crm-api';
import {
AddressDTO,
CustomerDTO,
PayerDTO,
ShippingAddressDTO,
} from '@generated/swagger/crm-api';
import { UiErrorModalComponent, UiModalService } from '@ui/modal';
import { UiValidators } from '@ui/validators';
import { isNull } from 'lodash';
@@ -42,7 +54,10 @@ import {
mapCustomerInfoDtoToCustomerCreateFormData,
} from './customer-create-form-data';
import { AddressSelectionModalService } from '../modals';
import { CustomerCreateNavigation, CustomerSearchNavigation } from '@shared/services/navigation';
import {
CustomerCreateNavigation,
CustomerSearchNavigation,
} from '@shared/services/navigation';
@Directive()
export abstract class AbstractCreateCustomer implements OnInit, OnDestroy {
@@ -104,7 +119,12 @@ export abstract class AbstractCreateCustomer implements OnInit, OnDestroy {
);
this.processId$
.pipe(startWith(undefined), bufferCount(2, 1), takeUntil(this.onDestroy$), delay(100))
.pipe(
startWith(undefined),
bufferCount(2, 1),
takeUntil(this.onDestroy$),
delay(100),
)
.subscribe(async ([previous, current]) => {
if (previous === undefined) {
await this._initFormData();
@@ -155,7 +175,10 @@ export abstract class AbstractCreateCustomer implements OnInit, OnDestroy {
}
}
async addOrUpdateBreadcrumb(processId: number, formData: CustomerCreateFormData) {
async addOrUpdateBreadcrumb(
processId: number,
formData: CustomerCreateFormData,
) {
await this.breadcrumb.addOrUpdateBreadcrumbIfNotExists({
key: processId,
name: 'Kundendaten erfassen',
@@ -195,7 +218,10 @@ export abstract class AbstractCreateCustomer implements OnInit, OnDestroy {
console.log('customerTypeChanged', customerType);
}
addFormBlock(key: keyof CustomerCreateFormData, block: FormBlock<any, AbstractControl>) {
addFormBlock(
key: keyof CustomerCreateFormData,
block: FormBlock<any, AbstractControl>,
) {
this.form.addControl(key, block.control);
this.cdr.markForCheck();
}
@@ -232,7 +258,10 @@ export abstract class AbstractCreateCustomer implements OnInit, OnDestroy {
return true;
}
// Check Year + Month
else if (inputDate.getFullYear() === minBirthDate.getFullYear() && inputDate.getMonth() < minBirthDate.getMonth()) {
else if (
inputDate.getFullYear() === minBirthDate.getFullYear() &&
inputDate.getMonth() < minBirthDate.getMonth()
) {
return true;
}
// Check Year + Month + Day
@@ -279,70 +308,80 @@ export abstract class AbstractCreateCustomer implements OnInit, OnDestroy {
);
};
checkLoyalityCardValidator: AsyncValidatorFn = (control) => {
return of(control.value).pipe(
delay(500),
mergeMap((value) => {
const customerId = this.formData?._meta?.customerDto?.id ?? this.formData?._meta?.customerInfoDto?.id;
return this.customerService.checkLoyaltyCard({ loyaltyCardNumber: value, customerId }).pipe(
map((response) => {
if (response.error) {
throw response.message;
}
// checkLoyalityCardValidator: AsyncValidatorFn = (control) => {
// return of(control.value).pipe(
// delay(500),
// mergeMap((value) => {
// const customerId = this.formData?._meta?.customerDto?.id ?? this.formData?._meta?.customerInfoDto?.id;
// return this.customerService.checkLoyaltyCard({ loyaltyCardNumber: value, customerId }).pipe(
// map((response) => {
// if (response.error) {
// throw response.message;
// }
/**
* #4485 Kubi // Verhalten mit angelegte aber nicht verknüpfte Kundenkartencode in Kundensuche und Kundendaten erfassen ist nicht gleich
* Fall1: Kundenkarte hat Daten in point4more:
* Sobald Kundenkartencode in Feld "Kundenkartencode" reingegeben wird- werden die Daten von point4more in Formular "Kundendaten Erfassen" eingefügt und ersetzen (im Ganzen, nicht inkremental) die Daten in Felder, falls welche schon reingetippt werden.
* Fall2: Kundenkarte hat keine Daten in point4more:
* Sobald Kundenkartencode in Feld "Kundenkartencode" reingegeben wird- bleiben die Daten in Formular "Kundendaten Erfassen" in Felder, falls welche schon reingetippt werden.
*/
if (response.result && response.result.customer) {
const customer = response.result.customer;
const data = mapCustomerInfoDtoToCustomerCreateFormData(customer);
// /**
// * #4485 Kubi // Verhalten mit angelegte aber nicht verknüpfte Kundenkartencode in Kundensuche und Kundendaten erfassen ist nicht gleich
// * Fall1: Kundenkarte hat Daten in point4more:
// * Sobald Kundenkartencode in Feld "Kundenkartencode" reingegeben wird- werden die Daten von point4more in Formular "Kundendaten Erfassen" eingefügt und ersetzen (im Ganzen, nicht inkremental) die Daten in Felder, falls welche schon reingetippt werden.
// * Fall2: Kundenkarte hat keine Daten in point4more:
// * Sobald Kundenkartencode in Feld "Kundenkartencode" reingegeben wird- bleiben die Daten in Formular "Kundendaten Erfassen" in Felder, falls welche schon reingetippt werden.
// */
// if (response.result && response.result.customer) {
// const customer = response.result.customer;
// const data = mapCustomerInfoDtoToCustomerCreateFormData(customer);
if (data.name.firstName && data.name.lastName) {
// Fall1
this._formData.next(data);
} else {
// Fall2 Hier müssen die Metadaten gesetzt werden um eine verknüfung zur kundenkarte zu ermöglichen.
const current = this.formData;
current._meta = data._meta;
current.p4m = data.p4m;
}
}
// if (data.name.firstName && data.name.lastName) {
// // Fall1
// this._formData.next(data);
// } else {
// // Fall2 Hier müssen die Metadaten gesetzt werden um eine verknüfung zur kundenkarte zu ermöglichen.
// const current = this.formData;
// current._meta = data._meta;
// current.p4m = data.p4m;
// }
// }
return null;
}),
catchError((error) => {
if (error instanceof HttpErrorResponse) {
if (error?.error?.invalidProperties?.loyaltyCardNumber) {
return of({ invalid: error.error.invalidProperties.loyaltyCardNumber });
} else {
return of({ invalid: 'Kundenkartencode ist ungültig' });
}
}
}),
);
}),
tap(() => {
control.markAsTouched();
this.cdr.markForCheck();
}),
);
};
// return null;
// }),
// catchError((error) => {
// if (error instanceof HttpErrorResponse) {
// if (error?.error?.invalidProperties?.loyaltyCardNumber) {
// return of({ invalid: error.error.invalidProperties.loyaltyCardNumber });
// } else {
// return of({ invalid: 'Kundenkartencode ist ungültig' });
// }
// }
// }),
// );
// }),
// tap(() => {
// control.markAsTouched();
// this.cdr.markForCheck();
// }),
// );
// };
async navigateToCustomerDetails(customer: CustomerDTO) {
const processId = await this.processId$.pipe(first()).toPromise();
const route = this.customerSearchNavigation.detailsRoute({ processId, customerId: customer.id, customer });
const route = this.customerSearchNavigation.detailsRoute({
processId,
customerId: customer.id,
customer,
});
return this.router.navigate(route.path, { queryParams: route.urlTree.queryParams });
return this.router.navigate(route.path, {
queryParams: route.urlTree.queryParams,
});
}
async validateAddressData(address: AddressDTO): Promise<AddressDTO> {
const addressValidationResult = await this.addressVlidationModal.validateAddress(address);
const addressValidationResult =
await this.addressVlidationModal.validateAddress(address);
if (addressValidationResult !== undefined && (addressValidationResult as any) !== 'continue') {
if (
addressValidationResult !== undefined &&
(addressValidationResult as any) !== 'continue'
) {
address = addressValidationResult;
}
@@ -389,7 +428,9 @@ export abstract class AbstractCreateCustomer implements OnInit, OnDestroy {
} catch (error) {
this.form.enable();
setTimeout(() => {
this.addressFormBlock.setAddressValidationError(error.error.invalidProperties);
this.addressFormBlock.setAddressValidationError(
error.error.invalidProperties,
);
}, 10);
return;
@@ -397,7 +438,10 @@ export abstract class AbstractCreateCustomer implements OnInit, OnDestroy {
}
}
if (data.birthDate && isNull(UiValidators.date(new UntypedFormControl(data.birthDate)))) {
if (
data.birthDate &&
isNull(UiValidators.date(new UntypedFormControl(data.birthDate)))
) {
customer.dateOfBirth = data.birthDate;
}
@@ -406,11 +450,15 @@ export abstract class AbstractCreateCustomer implements OnInit, OnDestroy {
if (this.validateShippingAddress) {
try {
billingAddress.address = await this.validateAddressData(billingAddress.address);
billingAddress.address = await this.validateAddressData(
billingAddress.address,
);
} catch (error) {
this.form.enable();
setTimeout(() => {
this.addressFormBlock.setAddressValidationError(error.error.invalidProperties);
this.addressFormBlock.setAddressValidationError(
error.error.invalidProperties,
);
}, 10);
return;
@@ -426,15 +474,21 @@ export abstract class AbstractCreateCustomer implements OnInit, OnDestroy {
}
if (data.deviatingDeliveryAddress?.deviatingAddress) {
const shippingAddress = this.mapToShippingAddress(data.deviatingDeliveryAddress);
const shippingAddress = this.mapToShippingAddress(
data.deviatingDeliveryAddress,
);
if (this.validateShippingAddress) {
try {
shippingAddress.address = await this.validateAddressData(shippingAddress.address);
shippingAddress.address = await this.validateAddressData(
shippingAddress.address,
);
} catch (error) {
this.form.enable();
setTimeout(() => {
this.deviatingDeliveryAddressFormBlock.setAddressValidationError(error.error.invalidProperties);
this.deviatingDeliveryAddressFormBlock.setAddressValidationError(
error.error.invalidProperties,
);
}, 10);
return;
@@ -474,7 +528,13 @@ export abstract class AbstractCreateCustomer implements OnInit, OnDestroy {
};
}
mapToBillingAddress({ name, address, email, organisation, phoneNumbers }: DeviatingAddressFormBlockData): PayerDTO {
mapToBillingAddress({
name,
address,
email,
organisation,
phoneNumbers,
}: DeviatingAddressFormBlockData): PayerDTO {
return {
gender: name?.gender,
title: name?.title,

View File

@@ -1,10 +1,10 @@
import { NgModule } from '@angular/core';
import { CreateB2BCustomerModule } from './create-b2b-customer/create-b2b-customer.module';
import { CreateGuestCustomerModule } from './create-guest-customer';
import { CreateP4MCustomerModule } from './create-p4m-customer';
// import { CreateP4MCustomerModule } from './create-p4m-customer';
import { CreateStoreCustomerModule } from './create-store-customer/create-store-customer.module';
import { CreateWebshopCustomerModule } from './create-webshop-customer/create-webshop-customer.module';
import { UpdateP4MWebshopCustomerModule } from './update-p4m-webshop-customer';
// import { UpdateP4MWebshopCustomerModule } from './update-p4m-webshop-customer';
import { CreateCustomerComponent } from './create-customer.component';
@NgModule({
@@ -13,8 +13,8 @@ import { CreateCustomerComponent } from './create-customer.component';
CreateGuestCustomerModule,
CreateStoreCustomerModule,
CreateWebshopCustomerModule,
CreateP4MCustomerModule,
UpdateP4MWebshopCustomerModule,
// CreateP4MCustomerModule,
// UpdateP4MWebshopCustomerModule,
CreateCustomerComponent,
],
exports: [
@@ -22,8 +22,8 @@ import { CreateCustomerComponent } from './create-customer.component';
CreateGuestCustomerModule,
CreateStoreCustomerModule,
CreateWebshopCustomerModule,
CreateP4MCustomerModule,
UpdateP4MWebshopCustomerModule,
// CreateP4MCustomerModule,
// UpdateP4MWebshopCustomerModule,
CreateCustomerComponent,
],
})

View File

@@ -1,9 +1,9 @@
@if (formData$ | async; as data) {
<!-- @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> -->
<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
@@ -135,4 +135,4 @@
</button>
</div>
</form>
}
} -->

View File

@@ -1,292 +1,292 @@
import { Component, ChangeDetectionStrategy, ViewChild, OnInit } from '@angular/core';
import { AsyncValidatorFn, ValidatorFn, Validators } from '@angular/forms';
import { Result } from '@domain/defs';
import { CustomerDTO, CustomerInfoDTO, KeyValueDTOOfStringAndString } from '@generated/swagger/crm-api';
import { UiErrorModalComponent, UiModalResult } from '@ui/modal';
import { NEVER, Observable, of } from 'rxjs';
import { catchError, distinctUntilChanged, first, map, switchMap, takeUntil, withLatestFrom } from 'rxjs/operators';
import {
AddressFormBlockComponent,
AddressFormBlockData,
DeviatingAddressFormBlockComponent,
} from '../../components/form-blocks';
import { NameFormBlockData } from '../../components/form-blocks/name/name-form-block-data';
import { WebshopCustomnerAlreadyExistsModalComponent, WebshopCustomnerAlreadyExistsModalData } from '../../modals';
import { validateEmail } from '../../validators/email-validator';
import { AbstractCreateCustomer } from '../abstract-create-customer';
import { encodeFormData, mapCustomerDtoToCustomerCreateFormData } from '../customer-create-form-data';
import { zipCodeValidator } from '../../validators/zip-code-validator';
// import { Component, ChangeDetectionStrategy, ViewChild, OnInit } from '@angular/core';
// import { AsyncValidatorFn, ValidatorFn, Validators } from '@angular/forms';
// import { Result } from '@domain/defs';
// import { CustomerDTO, CustomerInfoDTO, KeyValueDTOOfStringAndString } from '@generated/swagger/crm-api';
// import { UiErrorModalComponent, UiModalResult } from '@ui/modal';
// import { NEVER, Observable, of } from 'rxjs';
// import { catchError, distinctUntilChanged, first, map, switchMap, takeUntil, withLatestFrom } from 'rxjs/operators';
// import {
// AddressFormBlockComponent,
// AddressFormBlockData,
// DeviatingAddressFormBlockComponent,
// } from '../../components/form-blocks';
// import { NameFormBlockData } from '../../components/form-blocks/name/name-form-block-data';
// import { WebshopCustomnerAlreadyExistsModalComponent, WebshopCustomnerAlreadyExistsModalData } from '../../modals';
// import { validateEmail } from '../../validators/email-validator';
// import { AbstractCreateCustomer } from '../abstract-create-customer';
// import { encodeFormData, mapCustomerDtoToCustomerCreateFormData } from '../customer-create-form-data';
// import { zipCodeValidator } from '../../validators/zip-code-validator';
@Component({
selector: 'app-create-p4m-customer',
templateUrl: 'create-p4m-customer.component.html',
styleUrls: ['../create-customer.scss', 'create-p4m-customer.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: false,
})
export class CreateP4MCustomerComponent extends AbstractCreateCustomer implements OnInit {
validateAddress = true;
// @Component({
// selector: 'app-create-p4m-customer',
// templateUrl: 'create-p4m-customer.component.html',
// styleUrls: ['../create-customer.scss', 'create-p4m-customer.component.scss'],
// changeDetection: ChangeDetectionStrategy.OnPush,
// standalone: false,
// })
// export class CreateP4MCustomerComponent extends AbstractCreateCustomer implements OnInit {
// validateAddress = true;
validateShippingAddress = true;
// validateShippingAddress = true;
get _customerType() {
return this.activatedRoute.snapshot.data.customerType;
}
// get _customerType() {
// return this.activatedRoute.snapshot.data.customerType;
// }
get customerType() {
return `${this._customerType}-p4m`;
}
// get customerType() {
// return `${this._customerType}-p4m`;
// }
nameRequiredMarks: (keyof NameFormBlockData)[] = ['gender', 'firstName', 'lastName'];
// nameRequiredMarks: (keyof NameFormBlockData)[] = ['gender', 'firstName', 'lastName'];
nameValidationFns: Record<keyof NameFormBlockData, ValidatorFn[]> = {
firstName: [Validators.required],
lastName: [Validators.required],
gender: [Validators.required],
title: [],
};
// nameValidationFns: Record<keyof NameFormBlockData, ValidatorFn[]> = {
// firstName: [Validators.required],
// lastName: [Validators.required],
// gender: [Validators.required],
// title: [],
// };
emailRequiredMark: boolean;
// emailRequiredMark: boolean;
emailValidatorFn: ValidatorFn[];
// emailValidatorFn: ValidatorFn[];
asyncEmailVlaidtorFn: AsyncValidatorFn[];
// asyncEmailVlaidtorFn: AsyncValidatorFn[];
asyncLoyaltyCardValidatorFn: AsyncValidatorFn[];
// asyncLoyaltyCardValidatorFn: AsyncValidatorFn[];
shippingAddressRequiredMarks: (keyof AddressFormBlockData)[] = [
'street',
'streetNumber',
'zipCode',
'city',
'country',
];
// shippingAddressRequiredMarks: (keyof AddressFormBlockData)[] = [
// 'street',
// 'streetNumber',
// 'zipCode',
// 'city',
// 'country',
// ];
shippingAddressValidators: Record<string, ValidatorFn[]> = {
street: [Validators.required],
streetNumber: [Validators.required],
zipCode: [Validators.required, zipCodeValidator()],
city: [Validators.required],
country: [Validators.required],
};
// shippingAddressValidators: Record<string, ValidatorFn[]> = {
// street: [Validators.required],
// streetNumber: [Validators.required],
// zipCode: [Validators.required, zipCodeValidator()],
// city: [Validators.required],
// country: [Validators.required],
// };
addressRequiredMarks: (keyof AddressFormBlockData)[];
// addressRequiredMarks: (keyof AddressFormBlockData)[];
addressValidatorFns: Record<string, ValidatorFn[]>;
// addressValidatorFns: Record<string, ValidatorFn[]>;
@ViewChild(AddressFormBlockComponent, { static: false })
addressFormBlock: AddressFormBlockComponent;
// @ViewChild(AddressFormBlockComponent, { static: false })
// addressFormBlock: AddressFormBlockComponent;
@ViewChild(DeviatingAddressFormBlockComponent, { static: false })
deviatingDeliveryAddressFormBlock: DeviatingAddressFormBlockComponent;
// @ViewChild(DeviatingAddressFormBlockComponent, { static: false })
// deviatingDeliveryAddressFormBlock: DeviatingAddressFormBlockComponent;
agbValidatorFns = [Validators.requiredTrue];
// agbValidatorFns = [Validators.requiredTrue];
birthDateValidatorFns = [];
// birthDateValidatorFns = [];
existingCustomer$: Observable<CustomerInfoDTO | CustomerDTO | null>;
// existingCustomer$: Observable<CustomerInfoDTO | CustomerDTO | null>;
ngOnInit(): void {
super.ngOnInit();
this.initMarksAndValidators();
this.existingCustomer$ = this.customerExists$.pipe(
distinctUntilChanged(),
switchMap((exists) => {
if (exists) {
return this.fetchCustomerInfo();
}
return of(null);
}),
);
// ngOnInit(): void {
// super.ngOnInit();
// this.initMarksAndValidators();
// this.existingCustomer$ = this.customerExists$.pipe(
// distinctUntilChanged(),
// switchMap((exists) => {
// if (exists) {
// return this.fetchCustomerInfo();
// }
// return of(null);
// }),
// );
this.existingCustomer$
.pipe(
takeUntil(this.onDestroy$),
switchMap((info) => {
if (info) {
return this.customerService.getCustomer(info.id, 2).pipe(
map((res) => res.result),
catchError((err) => NEVER),
);
}
return NEVER;
}),
withLatestFrom(this.processId$),
)
.subscribe(([customer, processId]) => {
if (customer) {
this.modal
.open({
content: WebshopCustomnerAlreadyExistsModalComponent,
data: {
customer,
processId,
} as WebshopCustomnerAlreadyExistsModalData,
title: 'Es existiert bereits ein Onlinekonto mit dieser E-Mail-Adresse',
})
.afterClosed$.subscribe(async (result: UiModalResult<boolean>) => {
if (result.data) {
this.navigateToUpdatePage(customer);
} else {
this.formData.email = '';
this.cdr.markForCheck();
}
});
}
});
}
// this.existingCustomer$
// .pipe(
// takeUntil(this.onDestroy$),
// switchMap((info) => {
// if (info) {
// return this.customerService.getCustomer(info.id, 2).pipe(
// map((res) => res.result),
// catchError((err) => NEVER),
// );
// }
// return NEVER;
// }),
// withLatestFrom(this.processId$),
// )
// .subscribe(([customer, processId]) => {
// if (customer) {
// this.modal
// .open({
// content: WebshopCustomnerAlreadyExistsModalComponent,
// data: {
// customer,
// processId,
// } as WebshopCustomnerAlreadyExistsModalData,
// title: 'Es existiert bereits ein Onlinekonto mit dieser E-Mail-Adresse',
// })
// .afterClosed$.subscribe(async (result: UiModalResult<boolean>) => {
// if (result.data) {
// this.navigateToUpdatePage(customer);
// } else {
// this.formData.email = '';
// this.cdr.markForCheck();
// }
// });
// }
// });
// }
async navigateToUpdatePage(customer: CustomerDTO) {
const processId = await this.processId$.pipe(first()).toPromise();
this.router.navigate(['/kunde', processId, 'customer', 'create', 'webshop-p4m', 'update'], {
queryParams: {
formData: encodeFormData({
...mapCustomerDtoToCustomerCreateFormData(customer),
p4m: this.formData.p4m,
}),
},
});
}
// async navigateToUpdatePage(customer: CustomerDTO) {
// const processId = await this.processId$.pipe(first()).toPromise();
// this.router.navigate(['/kunde', processId, 'customer', 'create', 'webshop-p4m', 'update'], {
// queryParams: {
// formData: encodeFormData({
// ...mapCustomerDtoToCustomerCreateFormData(customer),
// p4m: this.formData.p4m,
// }),
// },
// });
// }
initMarksAndValidators() {
this.asyncLoyaltyCardValidatorFn = [this.checkLoyalityCardValidator];
this.birthDateValidatorFns = [Validators.required, this.minBirthDateValidator()];
if (this._customerType === 'webshop') {
this.emailRequiredMark = true;
this.emailValidatorFn = [Validators.required, Validators.email, validateEmail];
this.asyncEmailVlaidtorFn = [this.emailExistsValidator];
this.addressRequiredMarks = this.shippingAddressRequiredMarks;
this.addressValidatorFns = this.shippingAddressValidators;
} else {
this.emailRequiredMark = false;
this.emailValidatorFn = [Validators.email, validateEmail];
}
}
// initMarksAndValidators() {
// this.asyncLoyaltyCardValidatorFn = [this.checkLoyalityCardValidator];
// this.birthDateValidatorFns = [Validators.required, this.minBirthDateValidator()];
// if (this._customerType === 'webshop') {
// this.emailRequiredMark = true;
// this.emailValidatorFn = [Validators.required, Validators.email, validateEmail];
// this.asyncEmailVlaidtorFn = [this.emailExistsValidator];
// this.addressRequiredMarks = this.shippingAddressRequiredMarks;
// this.addressValidatorFns = this.shippingAddressValidators;
// } else {
// this.emailRequiredMark = false;
// this.emailValidatorFn = [Validators.email, validateEmail];
// }
// }
fetchCustomerInfo(): Observable<CustomerDTO | null> {
const email = this.formData.email;
return this.customerService.getOnlineCustomerByEmail(email).pipe(
map((result) => {
if (result) {
return result;
}
return null;
}),
catchError((err) => {
this.modal.open({
content: UiErrorModalComponent,
data: err,
});
return [null];
}),
);
}
// fetchCustomerInfo(): Observable<CustomerDTO | null> {
// const email = this.formData.email;
// return this.customerService.getOnlineCustomerByEmail(email).pipe(
// map((result) => {
// if (result) {
// return result;
// }
// return null;
// }),
// catchError((err) => {
// this.modal.open({
// content: UiErrorModalComponent,
// data: err,
// });
// return [null];
// }),
// );
// }
getInterests(): KeyValueDTOOfStringAndString[] {
const interests: KeyValueDTOOfStringAndString[] = [];
// getInterests(): KeyValueDTOOfStringAndString[] {
// const interests: KeyValueDTOOfStringAndString[] = [];
for (const key in this.formData.interests) {
if (this.formData.interests[key]) {
interests.push({ key, group: 'KUBI_INTERESSEN' });
}
}
// for (const key in this.formData.interests) {
// if (this.formData.interests[key]) {
// interests.push({ key, group: 'KUBI_INTERESSEN' });
// }
// }
return interests;
}
// return interests;
// }
getNewsletter(): KeyValueDTOOfStringAndString | undefined {
if (this.formData.newsletter) {
return { key: 'kubi_newsletter', group: 'KUBI_NEWSLETTER' };
}
}
// getNewsletter(): KeyValueDTOOfStringAndString | undefined {
// if (this.formData.newsletter) {
// return { key: 'kubi_newsletter', group: 'KUBI_NEWSLETTER' };
// }
// }
static MapCustomerInfoDtoToCustomerDto(customerInfoDto: CustomerInfoDTO): CustomerDTO {
return {
address: customerInfoDto.address,
agentComment: customerInfoDto.agentComment,
bonusCard: customerInfoDto.bonusCard,
campaignCode: customerInfoDto.campaignCode,
communicationDetails: customerInfoDto.communicationDetails,
createdInBranch: customerInfoDto.createdInBranch,
customerGroup: customerInfoDto.customerGroup,
customerNumber: customerInfoDto.customerNumber,
customerStatus: customerInfoDto.customerStatus,
customerType: customerInfoDto.customerType,
dateOfBirth: customerInfoDto.dateOfBirth,
features: customerInfoDto.features,
firstName: customerInfoDto.firstName,
lastName: customerInfoDto.lastName,
gender: customerInfoDto.gender,
hasOnlineAccount: customerInfoDto.hasOnlineAccount,
isGuestAccount: customerInfoDto.isGuestAccount,
label: customerInfoDto.label,
notificationChannels: customerInfoDto.notificationChannels,
organisation: customerInfoDto.organisation,
title: customerInfoDto.title,
id: customerInfoDto.id,
pId: customerInfoDto.pId,
};
}
// static MapCustomerInfoDtoToCustomerDto(customerInfoDto: CustomerInfoDTO): CustomerDTO {
// return {
// address: customerInfoDto.address,
// agentComment: customerInfoDto.agentComment,
// bonusCard: customerInfoDto.bonusCard,
// campaignCode: customerInfoDto.campaignCode,
// communicationDetails: customerInfoDto.communicationDetails,
// createdInBranch: customerInfoDto.createdInBranch,
// customerGroup: customerInfoDto.customerGroup,
// customerNumber: customerInfoDto.customerNumber,
// customerStatus: customerInfoDto.customerStatus,
// customerType: customerInfoDto.customerType,
// dateOfBirth: customerInfoDto.dateOfBirth,
// features: customerInfoDto.features,
// firstName: customerInfoDto.firstName,
// lastName: customerInfoDto.lastName,
// gender: customerInfoDto.gender,
// hasOnlineAccount: customerInfoDto.hasOnlineAccount,
// isGuestAccount: customerInfoDto.isGuestAccount,
// label: customerInfoDto.label,
// notificationChannels: customerInfoDto.notificationChannels,
// organisation: customerInfoDto.organisation,
// title: customerInfoDto.title,
// id: customerInfoDto.id,
// pId: customerInfoDto.pId,
// };
// }
async saveCustomer(customer: CustomerDTO): Promise<CustomerDTO> {
const isWebshop = this._customerType === 'webshop';
let res: Result<CustomerDTO>;
// async saveCustomer(customer: CustomerDTO): Promise<CustomerDTO> {
// const isWebshop = this._customerType === 'webshop';
// let res: Result<CustomerDTO>;
const { customerDto, customerInfoDto } = this.formData?._meta ?? {};
// const { customerDto, customerInfoDto } = this.formData?._meta ?? {};
if (customerDto) {
customer = { ...customerDto, ...customer };
} else if (customerInfoDto) {
customer = { ...CreateP4MCustomerComponent.MapCustomerInfoDtoToCustomerDto(customerInfoDto), ...customer };
}
// if (customerDto) {
// customer = { ...customerDto, ...customer };
// } else if (customerInfoDto) {
// customer = { ...CreateP4MCustomerComponent.MapCustomerInfoDtoToCustomerDto(customerInfoDto), ...customer };
// }
const p4mFeature = customer.features?.find((attr) => attr.key === 'p4mUser');
if (p4mFeature) {
p4mFeature.value = this.formData.p4m;
} else {
customer.features.push({
key: 'p4mUser',
value: this.formData.p4m,
});
}
// const p4mFeature = customer.features?.find((attr) => attr.key === 'p4mUser');
// if (p4mFeature) {
// p4mFeature.value = this.formData.p4m;
// } else {
// customer.features.push({
// key: 'p4mUser',
// value: this.formData.p4m,
// });
// }
const interests = this.getInterests();
// const interests = this.getInterests();
if (interests.length > 0) {
customer.features?.push(...interests);
// TODO: Klärung wie Interessen zukünftig gespeichert werden
// await this._loyaltyCardService
// .LoyaltyCardSaveInteressen({
// customerId: res.result.id,
// interessen: this.getInterests(),
// })
// .toPromise();
}
// if (interests.length > 0) {
// customer.features?.push(...interests);
// // TODO: Klärung wie Interessen zukünftig gespeichert werden
// // await this._loyaltyCardService
// // .LoyaltyCardSaveInteressen({
// // customerId: res.result.id,
// // interessen: this.getInterests(),
// // })
// // .toPromise();
// }
const newsletter = this.getNewsletter();
// const newsletter = this.getNewsletter();
if (newsletter) {
customer.features.push(newsletter);
} else {
customer.features = customer.features.filter(
(feature) => feature.key !== 'kubi_newsletter' && feature.group !== 'KUBI_NEWSLETTER',
);
}
// if (newsletter) {
// customer.features.push(newsletter);
// } else {
// customer.features = customer.features.filter(
// (feature) => feature.key !== 'kubi_newsletter' && feature.group !== 'KUBI_NEWSLETTER',
// );
// }
if (isWebshop) {
if (customer.id > 0) {
if (this.formData?._meta?.hasLocalityCard) {
res = await this.customerService.updateStoreP4MToWebshopP4M(customer);
} else {
res = await this.customerService.updateToP4MOnlineCustomer(customer);
}
} else {
res = await this.customerService.createOnlineCustomer(customer).toPromise();
}
} else {
res = await this.customerService.createStoreCustomer(customer).toPromise();
}
// if (isWebshop) {
// if (customer.id > 0) {
// if (this.formData?._meta?.hasLocalityCard) {
// res = await this.customerService.updateStoreP4MToWebshopP4M(customer);
// } else {
// res = await this.customerService.updateToP4MOnlineCustomer(customer);
// }
// } else {
// res = await this.customerService.createOnlineCustomer(customer).toPromise();
// }
// } else {
// res = await this.customerService.createStoreCustomer(customer).toPromise();
// }
return res.result;
}
}
// return res.result;
// }
// }

View File

@@ -1,45 +1,45 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
// import { NgModule } from '@angular/core';
// import { CommonModule } from '@angular/common';
import { CreateP4MCustomerComponent } from './create-p4m-customer.component';
import {
AddressFormBlockModule,
BirthDateFormBlockModule,
InterestsFormBlockModule,
NameFormBlockModule,
OrganisationFormBlockModule,
P4mNumberFormBlockModule,
NewsletterFormBlockModule,
DeviatingAddressFormBlockComponentModule,
AcceptAGBFormBlockModule,
EmailFormBlockModule,
PhoneNumbersFormBlockModule,
} from '../../components/form-blocks';
import { CustomerTypeSelectorModule } from '../../components/customer-type-selector';
import { UiSpinnerModule } from '@ui/spinner';
import { UiIconModule } from '@ui/icon';
import { RouterModule } from '@angular/router';
// import { CreateP4MCustomerComponent } from './create-p4m-customer.component';
// import {
// AddressFormBlockModule,
// BirthDateFormBlockModule,
// InterestsFormBlockModule,
// NameFormBlockModule,
// OrganisationFormBlockModule,
// P4mNumberFormBlockModule,
// NewsletterFormBlockModule,
// DeviatingAddressFormBlockComponentModule,
// AcceptAGBFormBlockModule,
// EmailFormBlockModule,
// PhoneNumbersFormBlockModule,
// } from '../../components/form-blocks';
// import { CustomerTypeSelectorModule } from '../../components/customer-type-selector';
// import { UiSpinnerModule } from '@ui/spinner';
// import { UiIconModule } from '@ui/icon';
// import { RouterModule } from '@angular/router';
@NgModule({
imports: [
CommonModule,
CustomerTypeSelectorModule,
AddressFormBlockModule,
BirthDateFormBlockModule,
InterestsFormBlockModule,
NameFormBlockModule,
OrganisationFormBlockModule,
P4mNumberFormBlockModule,
NewsletterFormBlockModule,
DeviatingAddressFormBlockComponentModule,
AcceptAGBFormBlockModule,
EmailFormBlockModule,
PhoneNumbersFormBlockModule,
UiSpinnerModule,
UiIconModule,
RouterModule,
],
exports: [CreateP4MCustomerComponent],
declarations: [CreateP4MCustomerComponent],
})
export class CreateP4MCustomerModule {}
// @NgModule({
// imports: [
// CommonModule,
// CustomerTypeSelectorModule,
// AddressFormBlockModule,
// BirthDateFormBlockModule,
// InterestsFormBlockModule,
// NameFormBlockModule,
// OrganisationFormBlockModule,
// P4mNumberFormBlockModule,
// NewsletterFormBlockModule,
// DeviatingAddressFormBlockComponentModule,
// AcceptAGBFormBlockModule,
// EmailFormBlockModule,
// PhoneNumbersFormBlockModule,
// UiSpinnerModule,
// UiIconModule,
// RouterModule,
// ],
// exports: [CreateP4MCustomerComponent],
// declarations: [CreateP4MCustomerComponent],
// })
// export class CreateP4MCustomerModule {}

View File

@@ -1,2 +1,2 @@
export * from './create-p4m-customer.component';
export * from './create-p4m-customer.module';
// export * from './create-p4m-customer.component';
// export * from './create-p4m-customer.module';

View File

@@ -1,6 +1,6 @@
import { Component, ChangeDetectionStrategy, ViewChild } from '@angular/core';
import { ValidatorFn, Validators } from '@angular/forms';
import { CustomerDTO } from '@generated/swagger/crm-api';
import { CustomerDTO, CustomerInfoDTO } from '@generated/swagger/crm-api';
import { map } from 'rxjs/operators';
import {
AddressFormBlockComponent,
@@ -10,13 +10,16 @@ import {
import { NameFormBlockData } from '../../components/form-blocks/name/name-form-block-data';
import { validateEmail } from '../../validators/email-validator';
import { AbstractCreateCustomer } from '../abstract-create-customer';
import { CreateP4MCustomerComponent } from '../create-p4m-customer';
// import { CreateP4MCustomerComponent } from '../create-p4m-customer';
import { zipCodeValidator } from '../../validators/zip-code-validator';
@Component({
selector: 'app-create-webshop-customer',
templateUrl: 'create-webshop-customer.component.html',
styleUrls: ['../create-customer.scss', 'create-webshop-customer.component.scss'],
styleUrls: [
'../create-customer.scss',
'create-webshop-customer.component.scss',
],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: false,
})
@@ -26,7 +29,11 @@ export class CreateWebshopCustomerComponent extends AbstractCreateCustomer {
validateAddress = true;
validateShippingAddress = true;
nameRequiredMarks: (keyof NameFormBlockData)[] = ['gender', 'firstName', 'lastName'];
nameRequiredMarks: (keyof NameFormBlockData)[] = [
'gender',
'firstName',
'lastName',
];
nameValidationFns: Record<keyof NameFormBlockData, ValidatorFn[]> = {
firstName: [Validators.required],
@@ -35,7 +42,13 @@ export class CreateWebshopCustomerComponent extends AbstractCreateCustomer {
title: [],
};
addressRequiredMarks: (keyof AddressFormBlockData)[] = ['street', 'streetNumber', 'zipCode', 'city', 'country'];
addressRequiredMarks: (keyof AddressFormBlockData)[] = [
'street',
'streetNumber',
'zipCode',
'city',
'country',
];
addressValidators: Record<string, ValidatorFn[]> = {
street: [Validators.required],
@@ -68,7 +81,11 @@ export class CreateWebshopCustomerComponent extends AbstractCreateCustomer {
if (customerDto) {
customer = { ...customerDto, ...customer };
} else if (customerInfoDto) {
customer = { ...CreateP4MCustomerComponent.MapCustomerInfoDtoToCustomerDto(customerInfoDto), ...customer };
customer = {
// ...CreateP4MCustomerComponent.MapCustomerInfoDtoToCustomerDto(customerInfoDto),
...this.mapCustomerInfoDtoToCustomerDto(customerInfoDto),
...customer,
};
}
const res = await this.customerService.updateToOnlineCustomer(customer);
@@ -80,4 +97,34 @@ export class CreateWebshopCustomerComponent extends AbstractCreateCustomer {
.toPromise();
}
}
mapCustomerInfoDtoToCustomerDto(
customerInfoDto: CustomerInfoDTO,
): CustomerDTO {
return {
address: customerInfoDto.address,
agentComment: customerInfoDto.agentComment,
bonusCard: customerInfoDto.bonusCard,
campaignCode: customerInfoDto.campaignCode,
communicationDetails: customerInfoDto.communicationDetails,
createdInBranch: customerInfoDto.createdInBranch,
customerGroup: customerInfoDto.customerGroup,
customerNumber: customerInfoDto.customerNumber,
customerStatus: customerInfoDto.customerStatus,
customerType: customerInfoDto.customerType,
dateOfBirth: customerInfoDto.dateOfBirth,
features: customerInfoDto.features,
firstName: customerInfoDto.firstName,
lastName: customerInfoDto.lastName,
gender: customerInfoDto.gender,
hasOnlineAccount: customerInfoDto.hasOnlineAccount,
isGuestAccount: customerInfoDto.isGuestAccount,
label: customerInfoDto.label,
notificationChannels: customerInfoDto.notificationChannels,
organisation: customerInfoDto.organisation,
title: customerInfoDto.title,
id: customerInfoDto.id,
pId: customerInfoDto.pId,
};
}
}

View File

@@ -1,7 +1,7 @@
import { CustomerDTO, Gender } from '@generated/swagger/crm-api';
export interface CreateCustomerQueryParams {
p4mNumber?: string;
// p4mNumber?: string;
customerId?: number;
gender?: Gender;
title?: string;

View File

@@ -1,6 +1,6 @@
export * from './create-b2b-customer';
export * from './create-guest-customer';
export * from './create-p4m-customer';
// export * from './create-p4m-customer';
export * from './create-store-customer';
export * from './create-webshop-customer';
export * from './defs';

View File

@@ -1,4 +1,4 @@
@if (formData$ | async; as data) {
<!-- @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>
@@ -106,4 +106,4 @@
</button>
</div>
</form>
}
} -->

View File

@@ -1,156 +1,156 @@
import { Component, ChangeDetectionStrategy, OnInit } from '@angular/core';
import { AsyncValidatorFn, ValidatorFn, Validators } from '@angular/forms';
import { Result } from '@domain/defs';
import { CustomerDTO, KeyValueDTOOfStringAndString, PayerDTO } from '@generated/swagger/crm-api';
import { AddressFormBlockData } from '../../components/form-blocks';
import { NameFormBlockData } from '../../components/form-blocks/name/name-form-block-data';
import { AbstractCreateCustomer } from '../abstract-create-customer';
import { CreateP4MCustomerComponent } from '../create-p4m-customer';
// import { Component, ChangeDetectionStrategy, OnInit } from '@angular/core';
// import { AsyncValidatorFn, ValidatorFn, Validators } from '@angular/forms';
// import { Result } from '@domain/defs';
// import { CustomerDTO, KeyValueDTOOfStringAndString, PayerDTO } from '@generated/swagger/crm-api';
// import { AddressFormBlockData } from '../../components/form-blocks';
// import { NameFormBlockData } from '../../components/form-blocks/name/name-form-block-data';
// import { AbstractCreateCustomer } from '../abstract-create-customer';
// import { CreateP4MCustomerComponent } from '../create-p4m-customer';
@Component({
selector: 'page-update-p4m-webshop-customer',
templateUrl: 'update-p4m-webshop-customer.component.html',
styleUrls: ['../create-customer.scss', 'update-p4m-webshop-customer.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: false,
})
export class UpdateP4MWebshopCustomerComponent extends AbstractCreateCustomer implements OnInit {
customerType = 'webshop-p4m/update';
// @Component({
// selector: 'page-update-p4m-webshop-customer',
// templateUrl: 'update-p4m-webshop-customer.component.html',
// styleUrls: ['../create-customer.scss', 'update-p4m-webshop-customer.component.scss'],
// changeDetection: ChangeDetectionStrategy.OnPush,
// standalone: false,
// })
// export class UpdateP4MWebshopCustomerComponent extends AbstractCreateCustomer implements OnInit {
// customerType = 'webshop-p4m/update';
validateAddress = true;
// validateAddress = true;
validateShippingAddress = true;
// validateShippingAddress = true;
agbValidatorFns = [Validators.requiredTrue];
// agbValidatorFns = [Validators.requiredTrue];
birthDateValidatorFns = [Validators.required, this.minBirthDateValidator()];
// birthDateValidatorFns = [Validators.required, this.minBirthDateValidator()];
nameRequiredMarks: (keyof NameFormBlockData)[] = ['gender', 'firstName', 'lastName'];
// nameRequiredMarks: (keyof NameFormBlockData)[] = ['gender', 'firstName', 'lastName'];
nameValidationFns: Record<keyof NameFormBlockData, ValidatorFn[]> = {
firstName: [Validators.required],
lastName: [Validators.required],
gender: [Validators.required],
title: [],
};
// nameValidationFns: Record<keyof NameFormBlockData, ValidatorFn[]> = {
// firstName: [Validators.required],
// lastName: [Validators.required],
// gender: [Validators.required],
// title: [],
// };
addressRequiredMarks: (keyof AddressFormBlockData)[] = ['street', 'streetNumber', 'zipCode', 'city', 'country'];
// addressRequiredMarks: (keyof AddressFormBlockData)[] = ['street', 'streetNumber', 'zipCode', 'city', 'country'];
addressValidatorFns: Record<string, ValidatorFn[]> = {
street: [Validators.required],
streetNumber: [Validators.required],
zipCode: [Validators.required],
city: [Validators.required],
country: [Validators.required],
};
// addressValidatorFns: Record<string, ValidatorFn[]> = {
// street: [Validators.required],
// streetNumber: [Validators.required],
// zipCode: [Validators.required],
// city: [Validators.required],
// country: [Validators.required],
// };
asyncLoyaltyCardValidatorFn: AsyncValidatorFn[] = [this.checkLoyalityCardValidator];
// asyncLoyaltyCardValidatorFn: AsyncValidatorFn[] = [this.checkLoyalityCardValidator];
get billingAddress(): PayerDTO | undefined {
const payers = this.formData?._meta?.customerDto?.payers;
// get billingAddress(): PayerDTO | undefined {
// const payers = this.formData?._meta?.customerDto?.payers;
if (!payers || payers.length === 0) {
return undefined;
}
// if (!payers || payers.length === 0) {
// return undefined;
// }
// the default payer is the payer with the latest isDefault(Date) value
const defaultPayer = payers.reduce((prev, curr) =>
new Date(prev.isDefault) > new Date(curr.isDefault) ? prev : curr,
);
// // the default payer is the payer with the latest isDefault(Date) value
// const defaultPayer = payers.reduce((prev, curr) =>
// new Date(prev.isDefault) > new Date(curr.isDefault) ? prev : curr,
// );
return defaultPayer.payer.data;
}
// return defaultPayer.payer.data;
// }
get shippingAddress() {
const shippingAddresses = this.formData?._meta?.customerDto?.shippingAddresses;
// get shippingAddress() {
// const shippingAddresses = this.formData?._meta?.customerDto?.shippingAddresses;
if (!shippingAddresses || shippingAddresses.length === 0) {
return undefined;
}
// if (!shippingAddresses || shippingAddresses.length === 0) {
// return undefined;
// }
// the default shipping address is the shipping address with the latest isDefault(Date) value
const defaultShippingAddress = shippingAddresses.reduce((prev, curr) =>
new Date(prev.data.isDefault) > new Date(curr.data.isDefault) ? prev : curr,
);
// // the default shipping address is the shipping address with the latest isDefault(Date) value
// const defaultShippingAddress = shippingAddresses.reduce((prev, curr) =>
// new Date(prev.data.isDefault) > new Date(curr.data.isDefault) ? prev : curr,
// );
return defaultShippingAddress.data;
}
// return defaultShippingAddress.data;
// }
ngOnInit() {
super.ngOnInit();
}
// ngOnInit() {
// super.ngOnInit();
// }
getInterests(): KeyValueDTOOfStringAndString[] {
const interests: KeyValueDTOOfStringAndString[] = [];
// getInterests(): KeyValueDTOOfStringAndString[] {
// const interests: KeyValueDTOOfStringAndString[] = [];
for (const key in this.formData.interests) {
if (this.formData.interests[key]) {
interests.push({ key, group: 'KUBI_INTERESSEN' });
}
}
// for (const key in this.formData.interests) {
// if (this.formData.interests[key]) {
// interests.push({ key, group: 'KUBI_INTERESSEN' });
// }
// }
return interests;
}
// return interests;
// }
getNewsletter(): KeyValueDTOOfStringAndString | undefined {
if (this.formData.newsletter) {
return { key: 'kubi_newsletter', group: 'KUBI_NEWSLETTER' };
}
}
// getNewsletter(): KeyValueDTOOfStringAndString | undefined {
// if (this.formData.newsletter) {
// return { key: 'kubi_newsletter', group: 'KUBI_NEWSLETTER' };
// }
// }
async saveCustomer(customer: CustomerDTO): Promise<CustomerDTO> {
let res: Result<CustomerDTO>;
// async saveCustomer(customer: CustomerDTO): Promise<CustomerDTO> {
// let res: Result<CustomerDTO>;
const { customerDto, customerInfoDto } = this.formData?._meta ?? {};
// const { customerDto, customerInfoDto } = this.formData?._meta ?? {};
if (customerDto) {
customer = { ...customerDto, shippingAddresses: [], payers: [], ...customer };
// if (customerDto) {
// customer = { ...customerDto, shippingAddresses: [], payers: [], ...customer };
if (customerDto.shippingAddresses?.length) {
customer.shippingAddresses.unshift(...customerDto.shippingAddresses);
}
if (customerDto.payers?.length) {
customer.payers.unshift(...customerDto.payers);
}
} else if (customerInfoDto) {
customer = { ...CreateP4MCustomerComponent.MapCustomerInfoDtoToCustomerDto(customerInfoDto), ...customer };
}
// if (customerDto.shippingAddresses?.length) {
// customer.shippingAddresses.unshift(...customerDto.shippingAddresses);
// }
// if (customerDto.payers?.length) {
// customer.payers.unshift(...customerDto.payers);
// }
// } else if (customerInfoDto) {
// customer = { ...CreateP4MCustomerComponent.MapCustomerInfoDtoToCustomerDto(customerInfoDto), ...customer };
// }
const p4mFeature = customer.features?.find((attr) => attr.key === 'p4mUser');
if (p4mFeature) {
p4mFeature.value = this.formData.p4m;
} else {
customer.features.push({
key: 'p4mUser',
value: this.formData.p4m,
});
}
// const p4mFeature = customer.features?.find((attr) => attr.key === 'p4mUser');
// if (p4mFeature) {
// p4mFeature.value = this.formData.p4m;
// } else {
// customer.features.push({
// key: 'p4mUser',
// value: this.formData.p4m,
// });
// }
const interests = this.getInterests();
// const interests = this.getInterests();
if (interests.length > 0) {
customer.features?.push(...interests);
// TODO: Klärung wie Interessen zukünftig gespeichert werden
// await this._loyaltyCardService
// .LoyaltyCardSaveInteressen({
// customerId: res.result.id,
// interessen: this.getInterests(),
// })
// .toPromise();
}
// if (interests.length > 0) {
// customer.features?.push(...interests);
// // TODO: Klärung wie Interessen zukünftig gespeichert werden
// // await this._loyaltyCardService
// // .LoyaltyCardSaveInteressen({
// // customerId: res.result.id,
// // interessen: this.getInterests(),
// // })
// // .toPromise();
// }
const newsletter = this.getNewsletter();
// const newsletter = this.getNewsletter();
if (newsletter) {
customer.features.push(newsletter);
} else {
customer.features = customer.features.filter(
(feature) => feature.key !== 'kubi_newsletter' && feature.group !== 'KUBI_NEWSLETTER',
);
}
// if (newsletter) {
// customer.features.push(newsletter);
// } else {
// customer.features = customer.features.filter(
// (feature) => feature.key !== 'kubi_newsletter' && feature.group !== 'KUBI_NEWSLETTER',
// );
// }
res = await this.customerService.updateToP4MOnlineCustomer(customer);
// res = await this.customerService.updateToP4MOnlineCustomer(customer);
return res.result;
}
}
// return res.result;
// }
// }

View File

@@ -1,48 +1,48 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
// import { NgModule } from '@angular/core';
// import { CommonModule } from '@angular/common';
import { UpdateP4MWebshopCustomerComponent } from './update-p4m-webshop-customer.component';
// import { UpdateP4MWebshopCustomerComponent } from './update-p4m-webshop-customer.component';
import {
AddressFormBlockModule,
BirthDateFormBlockModule,
InterestsFormBlockModule,
NameFormBlockModule,
OrganisationFormBlockModule,
P4mNumberFormBlockModule,
NewsletterFormBlockModule,
DeviatingAddressFormBlockComponentModule,
AcceptAGBFormBlockModule,
EmailFormBlockModule,
PhoneNumbersFormBlockModule,
} from '../../components/form-blocks';
import { UiFormControlModule } from '@ui/form-control';
import { UiInputModule } from '@ui/input';
import { CustomerPipesModule } from '@shared/pipes/customer';
import { CustomerTypeSelectorModule } from '../../components/customer-type-selector';
import { UiSpinnerModule } from '@ui/spinner';
// import {
// AddressFormBlockModule,
// BirthDateFormBlockModule,
// InterestsFormBlockModule,
// NameFormBlockModule,
// OrganisationFormBlockModule,
// P4mNumberFormBlockModule,
// NewsletterFormBlockModule,
// DeviatingAddressFormBlockComponentModule,
// AcceptAGBFormBlockModule,
// EmailFormBlockModule,
// PhoneNumbersFormBlockModule,
// } from '../../components/form-blocks';
// import { UiFormControlModule } from '@ui/form-control';
// import { UiInputModule } from '@ui/input';
// import { CustomerPipesModule } from '@shared/pipes/customer';
// import { CustomerTypeSelectorModule } from '../../components/customer-type-selector';
// import { UiSpinnerModule } from '@ui/spinner';
@NgModule({
imports: [
CommonModule,
CustomerTypeSelectorModule,
AddressFormBlockModule,
BirthDateFormBlockModule,
InterestsFormBlockModule,
NameFormBlockModule,
OrganisationFormBlockModule,
P4mNumberFormBlockModule,
NewsletterFormBlockModule,
DeviatingAddressFormBlockComponentModule,
AcceptAGBFormBlockModule,
EmailFormBlockModule,
PhoneNumbersFormBlockModule,
UiFormControlModule,
UiInputModule,
CustomerPipesModule,
UiSpinnerModule,
],
exports: [UpdateP4MWebshopCustomerComponent],
declarations: [UpdateP4MWebshopCustomerComponent],
})
export class UpdateP4MWebshopCustomerModule {}
// @NgModule({
// imports: [
// CommonModule,
// CustomerTypeSelectorModule,
// AddressFormBlockModule,
// BirthDateFormBlockModule,
// InterestsFormBlockModule,
// NameFormBlockModule,
// OrganisationFormBlockModule,
// P4mNumberFormBlockModule,
// NewsletterFormBlockModule,
// DeviatingAddressFormBlockComponentModule,
// AcceptAGBFormBlockModule,
// EmailFormBlockModule,
// PhoneNumbersFormBlockModule,
// UiFormControlModule,
// UiInputModule,
// CustomerPipesModule,
// UiSpinnerModule,
// ],
// exports: [UpdateP4MWebshopCustomerComponent],
// declarations: [UpdateP4MWebshopCustomerComponent],
// })
// export class UpdateP4MWebshopCustomerModule {}

View File

@@ -1,15 +1,33 @@
import { Component, ChangeDetectionStrategy, OnInit, OnDestroy, inject, effect, untracked } from '@angular/core';
import {
Component,
ChangeDetectionStrategy,
OnInit,
OnDestroy,
inject,
effect,
untracked,
} from '@angular/core';
import { ActivatedRoute, NavigationEnd, Router } from '@angular/router';
import { BehaviorSubject, Subject, Subscription, fromEvent } from 'rxjs';
import {
BehaviorSubject,
Subject,
Subscription,
firstValueFrom,
fromEvent,
} from 'rxjs';
import { CustomerSearchStore } from './store/customer-search.store';
import { provideComponentStore } from '@ngrx/component-store';
import { Breadcrumb, BreadcrumbService } from '@core/breadcrumb';
import { delay, filter, first, switchMap, takeUntil } from 'rxjs/operators';
import { CustomerCreateNavigation, CustomerSearchNavigation } from '@shared/services/navigation';
import {
CustomerCreateNavigation,
CustomerSearchNavigation,
} from '@shared/services/navigation';
import { CustomerSearchMainAutocompleteProvider } from './providers/customer-search-main-autocomplete.provider';
import { FilterAutocompleteProvider } from '@shared/components/filter';
import { toSignal } from '@angular/core/rxjs-interop';
import { provideCancelSearchSubject } from '@shared/services/cancel-subject';
import { injectFeedbackErrorDialog } from '@isa/ui/dialog';
@Component({
selector: 'page-customer-search',
@@ -28,6 +46,7 @@ import { provideCancelSearchSubject } from '@shared/services/cancel-subject';
standalone: false,
})
export class CustomerSearchComponent implements OnInit, OnDestroy {
#errorFeedbackDialog = injectFeedbackErrorDialog();
private _store = inject(CustomerSearchStore);
private _activatedRoute = inject(ActivatedRoute);
private _router = inject(Router);
@@ -37,7 +56,11 @@ export class CustomerSearchComponent implements OnInit, OnDestroy {
private searchStore = inject(CustomerSearchStore);
keyEscPressed = toSignal(fromEvent(document, 'keydown').pipe(filter((e: KeyboardEvent) => e.key === 'Escape')));
keyEscPressed = toSignal(
fromEvent(document, 'keydown').pipe(
filter((e: KeyboardEvent) => e.key === 'Escape'),
),
);
get breadcrumb() {
let breadcrumb: string;
@@ -53,7 +76,9 @@ export class CustomerSearchComponent implements OnInit, OnDestroy {
private _breadcrumbs$ = this._store.processId$.pipe(
filter((id) => !!id),
switchMap((id) => this._breadcrumbService.getBreadcrumbsByKeyAndTag$(id, 'customer')),
switchMap((id) =>
this._breadcrumbService.getBreadcrumbsByKeyAndTag$(id, 'customer'),
),
);
side$ = new BehaviorSubject<string | undefined>(undefined);
@@ -97,53 +122,77 @@ export class CustomerSearchComponent implements OnInit, OnDestroy {
this.checkDetailsBreadcrumb();
});
this._eventsSubscription = this._router.events.pipe(takeUntil(this._onDestroy$)).subscribe((event) => {
if (event instanceof NavigationEnd) {
this.checkAndUpdateProcessId();
this.checkAndUpdateSide();
this.checkAndUpdateCustomerId();
this.checkBreadcrumbs();
}
});
this._store.customerListResponse$
this._eventsSubscription = this._router.events
.pipe(takeUntil(this._onDestroy$))
.subscribe(async ([response, filter, processId, restored, skipNavigation]) => {
if (this._store.processId === processId) {
if (skipNavigation) {
return;
}
if (response.hits === 1) {
// Navigate to details page
const customer = response.result[0];
if (customer.id < 0) {
// navigate to create customer
const route = this._createNavigation.upgradeCustomerRoute({ processId, customerInfo: customer });
await this._router.navigate(route.path, { queryParams: route.queryParams });
return;
} else {
const route = this._navigation.detailsRoute({ processId, customerId: customer.id });
await this._router.navigate(route.path, { queryParams: filter.getQueryParams() });
}
} else if (response.hits > 1) {
const route = this._navigation.listRoute({ processId, filter });
if (
(['details'].includes(this.breadcrumb) &&
response?.result?.some((c) => c.id === this._store.customerId)) ||
restored
) {
await this._router.navigate([], { queryParams: route.queryParams });
} else {
await this._router.navigate(route.path, { queryParams: route.queryParams });
}
}
.subscribe((event) => {
if (event instanceof NavigationEnd) {
this.checkAndUpdateProcessId();
this.checkAndUpdateSide();
this.checkAndUpdateCustomerId();
this.checkBreadcrumbs();
}
});
this._store.customerListResponse$
.pipe(takeUntil(this._onDestroy$))
.subscribe(
async ([response, filter, processId, restored, skipNavigation]) => {
if (this._store.processId === processId) {
if (skipNavigation) {
return;
}
if (response.hits === 1) {
// Navigate to details page
const customer = response.result[0];
if (customer.id < 0) {
// #5375 - Zusätzlich soll bei Kunden bei denen ein Upgrade möglich ist ein Dialog angezeigt werden, dass Kundenneuanlage mit Kundenkarte nicht möglich ist
await firstValueFrom(
this.#errorFeedbackDialog({
data: {
errorMessage:
'Kundenneuanlage mit Kundenkarte nicht möglich',
},
}).closed,
);
// navigate to create customer
// const route = this._createNavigation.upgradeCustomerRoute({ processId, customerInfo: customer });
// await this._router.navigate(route.path, { queryParams: route.queryParams });
return;
} else {
const route = this._navigation.detailsRoute({
processId,
customerId: customer.id,
});
await this._router.navigate(route.path, {
queryParams: filter.getQueryParams(),
});
}
} else if (response.hits > 1) {
const route = this._navigation.listRoute({ processId, filter });
if (
(['details'].includes(this.breadcrumb) &&
response?.result?.some(
(c) => c.id === this._store.customerId,
)) ||
restored
) {
await this._router.navigate([], {
queryParams: route.queryParams,
});
} else {
await this._router.navigate(route.path, {
queryParams: route.queryParams,
});
}
}
this.checkBreadcrumbs();
}
},
);
}
ngOnDestroy(): void {
@@ -169,7 +218,11 @@ export class CustomerSearchComponent implements OnInit, OnDestroy {
this._store.setProcessId(processId);
this._store.reset(this._activatedRoute.snapshot.queryParams);
if (!['main', 'filter'].some((s) => s === this.breadcrumb)) {
const skipNavigation = ['orders', 'order-details', 'order-details-history'].includes(this.breadcrumb);
const skipNavigation = [
'orders',
'order-details',
'order-details-history',
].includes(this.breadcrumb);
this._store.search({ skipNavigation });
}
}
@@ -229,7 +282,9 @@ export class CustomerSearchComponent implements OnInit, OnDestroy {
const mainBreadcrumb = await this.getMainBreadcrumb();
if (!mainBreadcrumb) {
const navigation = this._navigation.defaultRoute({ processId: this._store.processId });
const navigation = this._navigation.defaultRoute({
processId: this._store.processId,
});
const breadcrumb: Breadcrumb = {
key: this._store.processId,
tags: ['customer', 'search', 'main'],
@@ -242,14 +297,19 @@ export class CustomerSearchComponent implements OnInit, OnDestroy {
this._breadcrumbService.addBreadcrumb(breadcrumb);
} else {
this._breadcrumbService.patchBreadcrumb(mainBreadcrumb.id, {
params: { ...this.snapshot.queryParams, ...(mainBreadcrumb.params ?? {}) },
params: {
...this.snapshot.queryParams,
...(mainBreadcrumb.params ?? {}),
},
});
}
}
async getCreateCustomerBreadcrumb(): Promise<Breadcrumb | undefined> {
const breadcrumbs = await this.getBreadcrumbs();
return breadcrumbs.find((b) => b.tags.includes('create') && b.tags.includes('customer'));
return breadcrumbs.find(
(b) => b.tags.includes('create') && b.tags.includes('customer'),
);
}
async checkCreateCustomerBreadcrumb() {
@@ -262,7 +322,9 @@ export class CustomerSearchComponent implements OnInit, OnDestroy {
async getSearchBreadcrumb(): Promise<Breadcrumb | undefined> {
const breadcrumbs = await this.getBreadcrumbs();
return breadcrumbs.find((b) => b.tags.includes('list') && b.tags.includes('search'));
return breadcrumbs.find(
(b) => b.tags.includes('list') && b.tags.includes('search'),
);
}
async checkSearchBreadcrumb() {
@@ -288,7 +350,9 @@ export class CustomerSearchComponent implements OnInit, OnDestroy {
const name = this._store.queryParams?.main_qs || 'Suche';
if (!searchBreadcrumb) {
const navigation = this._navigation.listRoute({ processId: this._store.processId });
const navigation = this._navigation.listRoute({
processId: this._store.processId,
});
const breadcrumb: Breadcrumb = {
key: this._store.processId,
tags: ['customer', 'search', 'list'],
@@ -300,7 +364,10 @@ export class CustomerSearchComponent implements OnInit, OnDestroy {
this._breadcrumbService.addBreadcrumb(breadcrumb);
} else {
this._breadcrumbService.patchBreadcrumb(searchBreadcrumb.id, { params: this.snapshot.queryParams, name });
this._breadcrumbService.patchBreadcrumb(searchBreadcrumb.id, {
params: this.snapshot.queryParams,
name,
});
}
} else {
if (searchBreadcrumb) {
@@ -311,7 +378,9 @@ export class CustomerSearchComponent implements OnInit, OnDestroy {
async getDetailsBreadcrumb(): Promise<Breadcrumb | undefined> {
const breadcrumbs = await this.getBreadcrumbs();
return breadcrumbs.find((b) => b.tags.includes('details') && b.tags.includes('search'));
return breadcrumbs.find(
(b) => b.tags.includes('details') && b.tags.includes('search'),
);
}
async checkDetailsBreadcrumb() {
@@ -333,7 +402,8 @@ export class CustomerSearchComponent implements OnInit, OnDestroy {
].includes(this.breadcrumb)
) {
const customer = this._store.customer;
const fullName = `${customer?.firstName ?? ''} ${customer?.lastName ?? ''}`.trim();
const fullName =
`${customer?.firstName ?? ''} ${customer?.lastName ?? ''}`.trim();
if (!detailsBreadcrumb) {
const navigation = this._navigation.detailsRoute({
@@ -515,7 +585,10 @@ export class CustomerSearchComponent implements OnInit, OnDestroy {
async checkOrderDetailsBreadcrumb() {
const orderDetailsBreadcrumb = await this.getOrderDetailsBreadcrumb();
if (this.breadcrumb === 'order-details' || this.breadcrumb === 'order-details-history') {
if (
this.breadcrumb === 'order-details' ||
this.breadcrumb === 'order-details-history'
) {
if (!orderDetailsBreadcrumb) {
const navigation = this._navigation.orderDetialsRoute({
processId: this._store.processId,
@@ -546,7 +619,8 @@ export class CustomerSearchComponent implements OnInit, OnDestroy {
}
async checkOrderDetailsHistoryBreadcrumb() {
const orderDetailsHistoryBreadcrumb = await this.getOrderDetailsHistoryBreadcrumb();
const orderDetailsHistoryBreadcrumb =
await this.getOrderDetailsHistoryBreadcrumb();
if (this.breadcrumb === 'order-details-history') {
if (!orderDetailsHistoryBreadcrumb) {
@@ -569,7 +643,9 @@ export class CustomerSearchComponent implements OnInit, OnDestroy {
this._breadcrumbService.addBreadcrumb(breadcrumb);
}
} else if (orderDetailsHistoryBreadcrumb) {
this._breadcrumbService.removeBreadcrumb(orderDetailsHistoryBreadcrumb.id);
this._breadcrumbService.removeBreadcrumb(
orderDetailsHistoryBreadcrumb.id,
);
}
}

View File

@@ -1,5 +1,10 @@
import { inject, Injectable } from '@angular/core';
import { ActivatedRouteSnapshot, Params, Router, RouterStateSnapshot } from '@angular/router';
import {
ActivatedRouteSnapshot,
Params,
Router,
RouterStateSnapshot,
} from '@angular/router';
import { DomainCheckoutService } from '@domain/checkout';
import { CustomerCreateFormData, decodeFormData } from '../create-customer';
import { CustomerCreateNavigation } from '@shared/services/navigation';
@@ -9,7 +14,10 @@ export class CustomerCreateGuard {
private checkoutService = inject(DomainCheckoutService);
private customerCreateNavigation = inject(CustomerCreateNavigation);
async canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Promise<boolean> {
async canActivate(
route: ActivatedRouteSnapshot,
state: RouterStateSnapshot,
): Promise<boolean> {
// exit with true if canActivateChild will be called
if (route.firstChild) {
return true;
@@ -19,10 +27,15 @@ export class CustomerCreateGuard {
const processId = this.getProcessId(route);
const formData = this.getFormData(route);
const canActivateCustomerType = await this.setableCustomerTypes(processId, formData);
const canActivateCustomerType = await this.setableCustomerTypes(
processId,
formData,
);
if (canActivateCustomerType[customerType] !== true) {
customerType = Object.keys(canActivateCustomerType).find((key) => canActivateCustomerType[key]);
customerType = Object.keys(canActivateCustomerType).find(
(key) => canActivateCustomerType[key],
);
}
await this.navigate(processId, customerType, route.queryParams);
@@ -30,9 +43,14 @@ export class CustomerCreateGuard {
return true;
}
async canActivateChild(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Promise<boolean> {
async canActivateChild(
route: ActivatedRouteSnapshot,
state: RouterStateSnapshot,
): Promise<boolean> {
const processId = this.getProcessId(route);
const customerType = route.routeConfig.path?.replace('create/', '')?.replace('/update', '');
const customerType = route.routeConfig.path
?.replace('create/', '')
?.replace('/update', '');
if (customerType === 'create-customer-main') {
return true;
@@ -40,29 +58,39 @@ export class CustomerCreateGuard {
const formData = this.getFormData(route);
const canActivateCustomerType = await this.setableCustomerTypes(processId, formData);
const canActivateCustomerType = await this.setableCustomerTypes(
processId,
formData,
);
if (canActivateCustomerType[customerType]) {
return true;
}
const activatableCustomerType = Object.keys(canActivateCustomerType)?.find((key) => canActivateCustomerType[key]);
const activatableCustomerType = Object.keys(canActivateCustomerType)?.find(
(key) => canActivateCustomerType[key],
);
await this.navigate(processId, activatableCustomerType, route.queryParams);
return false;
}
async setableCustomerTypes(processId: number, formData: CustomerCreateFormData): Promise<Record<string, boolean>> {
const res = await this.checkoutService.getSetableCustomerTypes(processId).toPromise();
async setableCustomerTypes(
processId: number,
formData: CustomerCreateFormData,
): Promise<Record<string, boolean>> {
const res = await this.checkoutService
.getSetableCustomerTypes(processId)
.toPromise();
if (res.store) {
res['store-p4m'] = true;
}
// if (res.store) {
// res['store-p4m'] = true;
// }
if (res.webshop) {
res['webshop-p4m'] = true;
}
// if (res.webshop) {
// res['webshop-p4m'] = true;
// }
if (formData?._meta) {
const customerType = formData._meta.customerType;
@@ -107,7 +135,11 @@ export class CustomerCreateGuard {
return {};
}
navigate(processId: number, customerType: string, queryParams: Params): Promise<boolean> {
navigate(
processId: number,
customerType: string,
queryParams: Params,
): Promise<boolean> {
const path = this.customerCreateNavigation.createCustomerRoute({
customerType,
processId,

View File

@@ -31,7 +31,9 @@ export class CantAddCustomerToCartModalComponent {
get option() {
return (
this.ref.data.upgradeableTo?.options.values.find((upgradeOption) =>
this.ref.data.required.options.values.some((requiredOption) => upgradeOption.key === requiredOption.key),
this.ref.data.required.options.values.some(
(requiredOption) => upgradeOption.key === requiredOption.key,
),
) || { value: this.queryParams }
);
}
@@ -39,7 +41,9 @@ export class CantAddCustomerToCartModalComponent {
get queryParams() {
let option = this.ref.data.required?.options.values.find((f) => f.selected);
if (!option) {
option = this.ref.data.required?.options.values.find((f) => (isBoolean(f.enabled) ? f.enabled : true));
option = this.ref.data.required?.options.values.find((f) =>
isBoolean(f.enabled) ? f.enabled : true,
);
}
return option ? { customertype: option.value } : {};
}
@@ -57,27 +61,29 @@ export class CantAddCustomerToCartModalComponent {
const queryParams: Record<string, string> = {};
if (customer) {
queryParams['formData'] = encodeFormData(mapCustomerDtoToCustomerCreateFormData(customer));
queryParams['formData'] = encodeFormData(
mapCustomerDtoToCustomerCreateFormData(customer),
);
}
if (option === 'webshop' && attributes.some((a) => a.key === 'p4mUser')) {
const nav = this.customerCreateNavigation.createCustomerRoute({
processId: this.applicationService.activatedProcessId,
customerType: 'webshop-p4m',
});
this.router.navigate(nav.path, {
queryParams: { ...nav.queryParams, ...queryParams },
});
} else {
const nav = this.customerCreateNavigation.createCustomerRoute({
processId: this.applicationService.activatedProcessId,
customerType: option as any,
});
// if (option === 'webshop' && attributes.some((a) => a.key === 'p4mUser')) {
// const nav = this.customerCreateNavigation.createCustomerRoute({
// processId: this.applicationService.activatedProcessId,
// customerType: 'webshop-p4m',
// });
// this.router.navigate(nav.path, {
// queryParams: { ...nav.queryParams, ...queryParams },
// });
// } else {
const nav = this.customerCreateNavigation.createCustomerRoute({
processId: this.applicationService.activatedProcessId,
customerType: option as any,
});
this.router.navigate(nav.path, {
queryParams: { ...nav.queryParams, ...queryParams },
});
}
this.router.navigate(nav.path, {
queryParams: { ...nav.queryParams, ...queryParams },
});
// }
this.ref.close();
}

View File

@@ -1,7 +1,11 @@
<div class="font-bold text-center border-t border-b border-solid border-disabled-customer -mx-4 py-4">
<div
class="font-bold text-center border-t border-b border-solid border-disabled-customer -mx-4 py-4"
>
{{ customer?.communicationDetails?.email }}
</div>
<div class="grid grid-flow-row gap-1 text-sm font-bold border-b border-solid border-disabled-customer -mx-4 py-4 px-14">
<div
class="grid grid-flow-row gap-1 text-sm font-bold border-b border-solid border-disabled-customer -mx-4 py-4 px-14"
>
@if (customer?.organisation?.name) {
<span>{{ customer?.organisation?.name }}</span>
}
@@ -16,23 +20,26 @@
</div>
<div class="grid grid-flow-col gap-4 justify-around mt-12">
<button class="border-2 border-solid border-brand rounded-full font-bold text-brand px-6 py-3 text-lg" (click)="close(false)">
<button
class="border-2 border-solid border-brand rounded-full font-bold text-brand px-6 py-3 text-lg"
(click)="close(false)"
>
neues Onlinekonto anlegen
</button>
@if (!isWebshopWithP4M) {
<button
class="border-2 border-solid border-brand rounded-full font-bold text-white px-6 py-3 text-lg bg-brand"
(click)="close(true)"
>
>
Daten übernehmen
</button>
}
@if (isWebshopWithP4M) {
<!-- @if (isWebshopWithP4M) {
<button
class="border-2 border-solid border-brand rounded-full font-bold text-white px-6 py-3 text-lg bg-brand"
(click)="selectCustomer()"
>
Datensatz auswählen
</button>
}
} -->
</div>

View File

@@ -9,11 +9,11 @@ import { CustomerCreateGuard } from './guards/customer-create.guard';
import {
CreateB2BCustomerComponent,
CreateGuestCustomerComponent,
CreateP4MCustomerComponent,
// CreateP4MCustomerComponent,
CreateStoreCustomerComponent,
CreateWebshopCustomerComponent,
} from './create-customer';
import { UpdateP4MWebshopCustomerComponent } from './create-customer/update-p4m-webshop-customer';
// import { UpdateP4MWebshopCustomerComponent } from './create-customer/update-p4m-webshop-customer';
import { CreateCustomerComponent } from './create-customer/create-customer.component';
import { CustomerDataEditB2BComponent } from './customer-search/edit-main-view/customer-data-edit-b2b.component';
import { CustomerDataEditB2CComponent } from './customer-search/edit-main-view/customer-data-edit-b2c.component';
@@ -40,8 +40,16 @@ export const routes: Routes = [
path: '',
component: CustomerSearchComponent,
children: [
{ path: 'search', component: CustomerMainViewComponent, data: { side: 'main', breadcrumb: 'main' } },
{ path: 'search/list', component: CustomerResultsMainViewComponent, data: { breadcrumb: 'search' } },
{
path: 'search',
component: CustomerMainViewComponent,
data: { side: 'main', breadcrumb: 'main' },
},
{
path: 'search/list',
component: CustomerResultsMainViewComponent,
data: { breadcrumb: 'search' },
},
{
path: 'search/filter',
component: CustomerFilterMainViewComponent,
@@ -80,7 +88,10 @@ export const routes: Routes = [
{
path: 'search/:customerId/orders/:orderId/:orderItemId/history',
component: CustomerOrderDetailsHistoryMainViewComponent,
data: { side: 'order-details', breadcrumb: 'order-details-history' },
data: {
side: 'order-details',
breadcrumb: 'order-details-history',
},
},
{
path: 'search/:customerId/edit/b2b',
@@ -140,13 +151,13 @@ export const routes: Routes = [
{ path: 'create/webshop', component: CreateWebshopCustomerComponent },
{ path: 'create/b2b', component: CreateB2BCustomerComponent },
{ path: 'create/guest', component: CreateGuestCustomerComponent },
{ path: 'create/webshop-p4m', component: CreateP4MCustomerComponent, data: { customerType: 'webshop' } },
{ path: 'create/store-p4m', component: CreateP4MCustomerComponent, data: { customerType: 'store' } },
{
path: 'create/webshop-p4m/update',
component: UpdateP4MWebshopCustomerComponent,
data: { customerType: 'webshop' },
},
// { path: 'create/webshop-p4m', component: CreateP4MCustomerComponent, data: { customerType: 'webshop' } },
// { path: 'create/store-p4m', component: CreateP4MCustomerComponent, data: { customerType: 'store' } },
// {
// path: 'create/webshop-p4m/update',
// component: UpdateP4MWebshopCustomerComponent,
// data: { customerType: 'webshop' },
// },
{
path: 'create-customer-main',
outlet: 'side',

View File

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

View File

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

View File

@@ -3,7 +3,10 @@ import { Injectable } from '@angular/core';
import { Router } from '@angular/router';
import { CustomerInfoDTO } from '@generated/swagger/crm-api';
import { NavigationRoute } from './defs/navigation-route';
import { encodeFormData, mapCustomerInfoDtoToCustomerCreateFormData } from 'apps/isa-app/src/page/customer';
import {
encodeFormData,
mapCustomerInfoDtoToCustomerCreateFormData,
} from 'apps/isa-app/src/page/customer';
@Injectable({ providedIn: 'root' })
export class CustomerCreateNavigation {
@@ -33,7 +36,9 @@ export class CustomerCreateNavigation {
navigateToDefault(params: { processId: NumberInput }): Promise<boolean> {
const route = this.defaultRoute(params);
return this._router.navigate(route.path, { queryParams: route.queryParams });
return this._router.navigate(route.path, {
queryParams: route.queryParams,
});
}
createCustomerRoute(params: {
@@ -54,7 +59,9 @@ export class CustomerCreateNavigation {
];
let formData = params?.customerInfo
? encodeFormData(mapCustomerInfoDtoToCustomerCreateFormData(params.customerInfo))
? encodeFormData(
mapCustomerInfoDtoToCustomerCreateFormData(params.customerInfo),
)
: undefined;
const urlTree = this._router.createUrlTree(path, {
@@ -79,7 +86,9 @@ export class CustomerCreateNavigation {
processId: NumberInput;
customerInfo: CustomerInfoDTO;
}): NavigationRoute {
const formData = encodeFormData(mapCustomerInfoDtoToCustomerCreateFormData(customerInfo));
const formData = encodeFormData(
mapCustomerInfoDtoToCustomerCreateFormData(customerInfo),
);
const path = [
'/kunde',
coerceNumberProperty(processId),
@@ -88,14 +97,16 @@ export class CustomerCreateNavigation {
outlets: {
primary: [
'create',
customerInfo?.features?.find((feature) => feature.key === 'webshop') ? 'webshop-p4m' : 'store-p4m',
// customerInfo?.features?.find((feature) => feature.key === 'webshop') ? 'webshop-p4m' : 'store-p4m',
],
side: 'create-customer-main',
},
},
];
const urlTree = this._router.createUrlTree(path, { queryParams: { formData } });
const urlTree = this._router.createUrlTree(path, {
queryParams: { formData },
});
return {
path,

View File

@@ -254,35 +254,6 @@
</div>
</div>
@if (remissionNavigation$ | async; as remissionNavigation) {
<a
class="side-menu-group-item"
(click)="closeSideMenu()"
[routerLink]="remissionNavigation.path"
[queryParams]="remissionNavigation.queryParams"
routerLinkActive="active"
>
<span class="side-menu-group-item-icon">
<shared-icon icon="assignment-return"></shared-icon>
</span>
<span class="side-menu-group-item-label">Remission</span>
</a>
}
@if (packageInspectionNavigation$ | async; as packageInspectionNavigation) {
<a
class="side-menu-group-item"
(click)="closeSideMenu(); fetchAndOpenPackages()"
[routerLink]="packageInspectionNavigation.path"
[queryParams]="packageInspectionNavigation.queryParams"
routerLinkActive="active"
>
<span class="side-menu-group-item-icon">
<shared-icon icon="clipboard-check-outline"></shared-icon>
</span>
<span class="side-menu-group-item-label">Wareneingang</span>
</a>
}
<div class="side-menu-group-sub-item-wrapper">
<a
class="side-menu-group-item"
@@ -348,5 +319,20 @@
</div>
}
</div>
@if (packageInspectionNavigation$ | async; as packageInspectionNavigation) {
<a
class="side-menu-group-item"
(click)="closeSideMenu(); fetchAndOpenPackages()"
[routerLink]="packageInspectionNavigation.path"
[queryParams]="packageInspectionNavigation.queryParams"
routerLinkActive="active"
>
<span class="side-menu-group-item-icon">
<shared-icon icon="clipboard-check-outline"></shared-icon>
</span>
<span class="side-menu-group-item-label">Wareneingang</span>
</a>
}
</nav>
</div>

View File

@@ -191,14 +191,6 @@ export class ShellSideMenuComponent {
// this._pickUpShelfInNavigation.listRoute()
// );
remissionNavigation$ = this.getLastNavigationByProcessId(
this.#config.get('process.ids.remission'),
{
path: ['/filiale', 'remission'],
queryParams: {},
},
);
packageInspectionNavigation$ = this.getLastNavigationByProcessId(
this.#config.get('process.ids.packageInspection'),
{

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,3 +3,4 @@ export * from './lib/models';
export * from './lib/stores';
export * from './lib/schemas';
export * from './lib/helpers';
export * from './lib/guards';

View File

@@ -0,0 +1,2 @@
export { isReturnItem } from './is-return-item';
export { isReturnSuggestion } from './is-return-suggestion';

View File

@@ -0,0 +1,42 @@
import { ReturnItem } from '../models/return-item';
/**
* Type guard to check if an object is a valid ReturnItem
* @param value - The value to check
* @returns True if the value is a ReturnItem, false otherwise
*/
export const isReturnItem = (value: unknown): value is ReturnItem => {
if (!value || typeof value !== 'object') {
return false;
}
const item = value as Partial<ReturnItem>;
// Check required properties from ReturnItem
return (
// Check product exists and has required properties
typeof item.product === 'object' &&
item.product !== null &&
(typeof item.product.name === 'string' ||
typeof item.product.ean === 'string') &&
// Check retailPrice exists and has required nested structure
typeof item.retailPrice === 'object' &&
item.retailPrice !== null &&
typeof item.retailPrice.value === 'object' &&
item.retailPrice.value !== null &&
typeof item.retailPrice.value.value === 'number' &&
typeof item.retailPrice.value.currency === 'string' &&
// Check source exists and is a valid string
typeof item.source === 'string' &&
item.source.length > 0 &&
// Check inherited ReturnItemDTO properties (id is a number)
typeof item.id === 'number' &&
// ReturnItem-specific: Must NOT have ReturnSuggestion-specific fields
!('accepted' in item) &&
!('rejected' in item) &&
!('sort' in item) &&
// ReturnItem can have predefinedReturnQuantity, quantityReturned, or neither
// If it has none of the ReturnSuggestion-specific fields, it's a ReturnItem
true
);
};

View File

@@ -0,0 +1,44 @@
import { ReturnSuggestion } from '../models/return-suggestion';
/**
* Type guard to check if an object is a valid ReturnSuggestion
* @param value - The value to check
* @returns True if the value is a ReturnSuggestion, false otherwise
*/
export const isReturnSuggestion = (
value: unknown,
): value is ReturnSuggestion => {
if (!value || typeof value !== 'object') {
return false;
}
const suggestion = value as Partial<ReturnSuggestion>;
// Check required properties from ReturnSuggestion
return (
// Check product exists and has required properties
typeof suggestion.product === 'object' &&
suggestion.product !== null &&
(typeof suggestion.product.name === 'string' ||
typeof suggestion.product.ean === 'string') &&
// Check retailPrice exists and has required nested structure
typeof suggestion.retailPrice === 'object' &&
suggestion.retailPrice !== null &&
typeof suggestion.retailPrice.value === 'object' &&
suggestion.retailPrice.value !== null &&
typeof suggestion.retailPrice.value.value === 'number' &&
typeof suggestion.retailPrice.value.currency === 'string' &&
// Check source exists and is a valid string
typeof suggestion.source === 'string' &&
suggestion.source.length > 0 &&
// Check inherited ReturnSuggestionDTO properties (id is a number)
typeof suggestion.id === 'number' &&
// ReturnSuggestion-specific: Must have at least one distinguishing property
('accepted' in suggestion ||
'rejected' in suggestion ||
'sort' in suggestion) &&
// Additionally, must NOT have ReturnItem-specific fields
!('predefinedReturnQuantity' in suggestion) &&
!('quantityReturned' in suggestion)
);
};

View File

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

View File

@@ -0,0 +1,191 @@
import { getItemType } from './get-item-type.helper';
import { RemissionItemType } from '../models';
import { RemissionItem } from '../stores';
describe('getItemType', () => {
describe('Happy Path - ReturnItem', () => {
it('should return ReturnItem when item has predefinedReturnQuantity', () => {
// Arrange
const item = {
id: 123,
product: {
name: 'Test Product',
ean: '1234567890123',
},
retailPrice: {
value: {
value: 10.99,
currency: 'EUR',
},
},
source: 'manually-added',
predefinedReturnQuantity: 1,
quantity: 1,
} as RemissionItem;
// Act
const result = getItemType(item);
// Assert
expect(result).toBe(RemissionItemType.ReturnItem);
});
it('should return ReturnItem when item has no suggestion-specific fields', () => {
// Arrange
const item = {
id: 456,
product: {
name: 'Another Product',
ean: '9876543210987',
},
retailPrice: {
value: {
value: 15.5,
currency: 'EUR',
},
},
source: 'DisposalListModule',
returnReason: 'Herstellerfehler',
} as RemissionItem;
// Act
const result = getItemType(item);
// Assert
expect(result).toBe(RemissionItemType.ReturnItem);
});
});
describe('Happy Path - ReturnSuggestion', () => {
it('should return ReturnSuggestion when item has sort field', () => {
// Arrange
const item = {
id: 789,
product: {
name: 'Suggestion Product',
ean: '5555555555555',
contributors: 'Test Author',
format: 'TB',
formatDetail: 'Taschenbuch',
},
retailPrice: {
value: {
value: 20.0,
currency: 'EUR',
},
},
source: 'manually-added',
quantity: 3,
sort: 1,
} as RemissionItem;
// Act
const result = getItemType(item);
// Assert
expect(result).toBe(RemissionItemType.ReturnSuggestion);
});
it('should return ReturnSuggestion when item has accepted field', () => {
// Arrange
const item = {
id: 101,
product: {
name: 'Accepted Product',
ean: '1111111111111',
contributors: 'Test Author',
format: 'TB',
formatDetail: 'Taschenbuch',
},
retailPrice: {
value: {
value: 8.99,
currency: 'EUR',
},
},
source: 'DisposalListModule',
quantity: 2,
accepted: '2025-10-20T10:00:00Z',
} as RemissionItem;
// Act
const result = getItemType(item);
// Assert
expect(result).toBe(RemissionItemType.ReturnSuggestion);
});
});
describe('Fallback with RemissionListType', () => {
it('should return ReturnItem when neither guard matches and remissionListType is Pflichtremission', () => {
// Arrange - Item that doesn't match either guard (missing required fields)
const ambiguousItem = {
id: 202,
product: {
name: 'Ambiguous Product',
ean: '1234567890',
contributors: 'Test',
format: 'TB',
formatDetail: 'Taschenbuch',
},
retailPrice: {
value: {
value: 12.5,
currency: 'EUR',
},
},
// Missing 'source' field - will fail both guards
quantity: 1,
} as unknown as RemissionItem;
// Act
const result = getItemType(ambiguousItem, 'Pflichtremission');
// Assert
expect(result).toBe(RemissionItemType.ReturnItem);
});
it('should return ReturnSuggestion when neither guard matches and remissionListType is Abteilungsremission', () => {
// Arrange - Item that doesn't match either guard (missing required fields)
const ambiguousItem = {
id: 303,
product: {
name: 'Another Ambiguous Product',
ean: '9876543210',
contributors: 'Test',
format: 'TB',
formatDetail: 'Taschenbuch',
},
retailPrice: {
value: {
value: 7.5,
currency: 'EUR',
},
},
// Missing 'source' field - will fail both guards
quantity: 1,
} as unknown as RemissionItem;
// Act
const result = getItemType(ambiguousItem, 'Abteilungsremission');
// Assert
expect(result).toBe(RemissionItemType.ReturnSuggestion);
});
});
describe('Unknown Type', () => {
it('should return Unknown when no guards match and no remissionListType provided', () => {
// Arrange
const invalidItem = {
id: 404,
} as RemissionItem;
// Act
const result = getItemType(invalidItem);
// Assert
expect(result).toBe(RemissionItemType.Unknown);
});
});
});

View File

@@ -0,0 +1,42 @@
import { RemissionItemType, RemissionListType } from '../models';
import { RemissionItem } from '../stores';
import { isReturnItem, isReturnSuggestion } from '../guards';
/**
* Determines the concrete type of a RemissionItem
* @param item - The item to check
* @param remissionListType - Optional fallback to determine type when guards are ambiguous
* @returns The type as a RemissionItemType enum value
*/
export const getItemType = (
item: RemissionItem,
remissionListType?: RemissionListType,
): RemissionItemType => {
const isReturnItemType = isReturnItem(item);
const isReturnSuggestionItemType = isReturnSuggestion(item);
// If exactly one guard matches, use it directly (without remissionListType)
if (isReturnItemType && !isReturnSuggestionItemType) {
return RemissionItemType.ReturnItem;
}
if (isReturnSuggestionItemType && !isReturnItemType) {
return RemissionItemType.ReturnSuggestion;
}
// If both are true or both are false, use remissionListType as fallback
if (remissionListType) {
// Pflichtremission typically contains ReturnItems
// Abteilungsremission typically contains ReturnSuggestions
if (remissionListType === 'Pflichtremission') {
return RemissionItemType.ReturnItem;
}
if (remissionListType === 'Abteilungsremission') {
return RemissionItemType.ReturnSuggestion;
}
}
// If no remissionListType provided or unrecognized, return Unknown
return RemissionItemType.Unknown;
};

View File

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

View File

@@ -7,3 +7,7 @@ export * from './get-receipt-item-quantity-from-return.helper';
export * from './get-receipt-number-from-return.helper';
export * from './get-receipt-items-from-return.helper';
export * from './get-package-numbers-from-return.helper';
export * from './get-retail-price-from-item.helper';
export * from './get-assortment-from-item.helper';
export * from './order-by-list-items.helper';
export * from './get-item-type.helper';

View File

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

View File

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

View File

@@ -18,3 +18,7 @@ export * from './value-tuple-sting-and-integer';
export * from './create-remission';
export * from './remission-item-source';
export * from './receipt-complete-status';
export * from './remission-response-args-error-message';
export * from './impediment';
export * from './update-item';
export * from './remission-item-type';

View File

@@ -0,0 +1,9 @@
export const RemissionItemType = {
ReturnItem: 'ReturnItem',
ReturnSuggestion: 'ReturnSuggestion',
Unknown: 'Unknown',
} as const;
export type RemissionItemType = keyof typeof RemissionItemType;
export type RemissionItemTypeValue =
(typeof RemissionItemType)[RemissionItemType];

View File

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

View File

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

View File

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

View File

@@ -8,11 +8,11 @@ import {
Stock,
Receipt,
ReturnItem,
RemissionListType,
ReceiptReturnTuple,
ReceiptReturnSuggestionTuple,
ReturnSuggestion,
CreateRemission,
RemissionItemType,
} from '../models';
import { subDays } from 'date-fns';
import { of, throwError } from 'rxjs';
@@ -1559,12 +1559,12 @@ describe('RemissionReturnReceiptService', () => {
jest.restoreAllMocks();
});
it('should call addReturnSuggestionItem for Abteilung type', async () => {
it('should call addReturnSuggestionItem for ReturnSuggestion type', async () => {
// Arrange
const params = {
itemId: 3,
addItem: baseAddItem,
type: RemissionListType.Abteilung,
type: RemissionItemType.ReturnSuggestion,
};
// Act
@@ -1578,16 +1578,18 @@ describe('RemissionReturnReceiptService', () => {
returnSuggestionId: 3,
quantity: 4,
inStock: 5,
impedimentComment: undefined,
remainingQuantity: undefined,
});
expect(service.addReturnItem).not.toHaveBeenCalled();
});
it('should call addReturnItem for Pflicht type', async () => {
it('should call addReturnItem for ReturnItem type', async () => {
// Arrange
const params = {
itemId: 3,
addItem: baseAddItem,
type: RemissionListType.Pflicht,
type: RemissionItemType.ReturnItem,
};
// Act
@@ -1605,12 +1607,12 @@ describe('RemissionReturnReceiptService', () => {
expect(service.addReturnSuggestionItem).not.toHaveBeenCalled();
});
it('should return undefined for unknown type', async () => {
it('should return undefined for Unknown type', async () => {
// Arrange
const params = {
itemId: 3,
addItem: baseAddItem,
type: 'Unknown' as RemissionListType,
type: RemissionItemType.Unknown,
};
// Act
@@ -1630,7 +1632,7 @@ describe('RemissionReturnReceiptService', () => {
const params = {
itemId: 3,
addItem: baseAddItem,
type: RemissionListType.Abteilung,
type: RemissionItemType.ReturnSuggestion,
};
// Act & Assert
@@ -1641,6 +1643,8 @@ describe('RemissionReturnReceiptService', () => {
returnSuggestionId: 3,
quantity: 4,
inStock: 5,
impedimentComment: undefined,
remainingQuantity: undefined,
});
});
@@ -1652,7 +1656,7 @@ describe('RemissionReturnReceiptService', () => {
const params = {
itemId: 3,
addItem: baseAddItem,
type: RemissionListType.Pflicht,
type: RemissionItemType.ReturnItem,
};
// Act & Assert
@@ -1675,7 +1679,7 @@ describe('RemissionReturnReceiptService', () => {
const params = {
itemId: 3,
addItem: baseAddItem,
type: RemissionListType.Abteilung,
type: RemissionItemType.ReturnSuggestion,
};
// Act
@@ -1689,6 +1693,8 @@ describe('RemissionReturnReceiptService', () => {
returnSuggestionId: 3,
quantity: 4,
inStock: 5,
impedimentComment: undefined,
remainingQuantity: undefined,
});
});
@@ -1699,7 +1705,7 @@ describe('RemissionReturnReceiptService', () => {
const params = {
itemId: 3,
addItem: baseAddItem,
type: RemissionListType.Pflicht,
type: RemissionItemType.ReturnItem,
};
// Act

View File

@@ -5,7 +5,6 @@ import {
ResponseArgsError,
takeUntilAborted,
} from '@isa/common/data-access';
import { subDays } from 'date-fns';
import { firstValueFrom } from 'rxjs';
import { RemissionStockService } from './remission-stock.service';
import { Return } from '../models/return';
@@ -30,7 +29,7 @@ import {
Receipt,
ReceiptReturnSuggestionTuple,
ReceiptReturnTuple,
RemissionListType,
RemissionItemType,
ReturnItem,
ReturnSuggestion,
} from '../models';
@@ -770,6 +769,8 @@ export class RemissionReturnReceiptService {
* returnSuggestionId: 789,
* quantity: 10,
* inStock: 5,
* impedimentComment: 'Restmenge',
* remainingQuantity: 5
* });
*/
async addReturnSuggestionItem(
@@ -778,8 +779,15 @@ export class RemissionReturnReceiptService {
): Promise<ReceiptReturnSuggestionTuple | undefined> {
this.#logger.debug('Adding return suggestion item', () => ({ params }));
const { returnId, receiptId, returnSuggestionId, quantity, inStock } =
AddReturnSuggestionItemSchema.parse(params);
const {
returnId,
receiptId,
returnSuggestionId,
quantity,
inStock,
impedimentComment,
remainingQuantity,
} = AddReturnSuggestionItemSchema.parse(params);
this.#logger.info('Add return suggestion item from API', () => ({
returnId,
@@ -787,6 +795,8 @@ export class RemissionReturnReceiptService {
returnSuggestionId,
quantity,
inStock,
impedimentComment,
remainingQuantity,
}));
let req$ = this.#returnService.ReturnAddReturnSuggestion({
@@ -796,6 +806,8 @@ export class RemissionReturnReceiptService {
returnSuggestionId,
quantity,
inStock,
impedimentComment,
remainingQuantity,
},
});
@@ -893,14 +905,42 @@ export class RemissionReturnReceiptService {
/**
* Remits an item to the return receipt based on its type.
* Determines whether to add a return item or return suggestion item based on the remission list type.
* Determines whether to add a return item or return suggestion item based on the remission item type.
*
* @async
* @param {Object} params - The parameters for remitting the item
* @param {number} params.itemId - The ID of the item to remit
* @param {AddReturnItem | AddReturnSuggestionItem} params.addItem - The item data to add
* @param {RemissionListType} params.type - The type of remission list (Abteilung or Pflicht)
* @param {Omit<AddReturnItem, 'returnItemId'> | Omit<AddReturnSuggestionItem, 'returnSuggestionId'>} params.addItem - The item data to add (without the item ID field)
* @param {RemissionItemType} params.type - The type of remission item (ReturnItem, ReturnSuggestion, or Unknown)
* @returns {Promise<ReceiptReturnSuggestionTuple | ReceiptReturnTuple | undefined>} The updated receipt and return tuple if successful, undefined otherwise
*
* @example
* // Remit a ReturnItem
* const result = await service.remitItem({
* itemId: 123,
* addItem: {
* returnId: 1,
* receiptId: 2,
* quantity: 10,
* inStock: 5,
* },
* type: RemissionItemType.ReturnItem,
* });
*
* @example
* // Remit a ReturnSuggestion
* const result = await service.remitItem({
* itemId: 456,
* addItem: {
* returnId: 1,
* receiptId: 2,
* quantity: 10,
* inStock: 5,
* impedimentComment: 'Restmenge',
* remainingQuantity: 5,
* },
* type: RemissionItemType.ReturnSuggestion,
* });
*/
async remitItem({
itemId,
@@ -911,21 +951,32 @@ export class RemissionReturnReceiptService {
addItem:
| Omit<AddReturnItem, 'returnItemId'>
| Omit<AddReturnSuggestionItem, 'returnSuggestionId'>;
type: RemissionListType;
type: RemissionItemType;
}): Promise<ReceiptReturnSuggestionTuple | ReceiptReturnTuple | undefined> {
if (type === RemissionItemType.Unknown) {
this.#logger.error(
'Invalid remission item type: None. Cannot remit item.',
);
return;
}
// ReturnSuggestion
if (type === RemissionListType.Abteilung) {
if (type === RemissionItemType.ReturnSuggestion) {
return await this.addReturnSuggestionItem({
returnId: addItem.returnId,
receiptId: addItem.receiptId,
returnSuggestionId: itemId,
quantity: addItem.quantity,
inStock: addItem.inStock,
impedimentComment: (addItem as AddReturnSuggestionItem)
.impedimentComment,
remainingQuantity: (addItem as AddReturnSuggestionItem)
.remainingQuantity,
});
}
// ReturnItem
if (type === RemissionListType.Pflicht) {
if (type === RemissionItemType.ReturnItem) {
return await this.addReturnItem({
returnId: addItem.returnId,
receiptId: addItem.receiptId,

View File

@@ -26,6 +26,7 @@ import {
import { logger } from '@isa/core/logging';
import { Item } from '@isa/catalogue/data-access';
import { RemissionStockService } from './remission-stock.service';
import { getAssortmentFromItem, getRetailPriceFromItem } from '../helpers';
/**
* Service responsible for remission search operations.
@@ -387,9 +388,9 @@ export class RemissionSearchService {
let req = this.#remiService.RemiCanAddReturnItem({
data: items.map((i) => ({
product: i.item.product,
assortment: 'Basissortiment|B',
assortment: getAssortmentFromItem(i.item),
predefinedReturnQuantity: i.quantity,
retailPrice: i.item.catalogAvailability.price,
retailPrice: getRetailPriceFromItem(i.item),
source: 'manually-added',
returnReason: i.reason,
stock: { id: stock.id },
@@ -427,9 +428,9 @@ export class RemissionSearchService {
...i.item.product,
catalogProductNumber: String(i.item.id),
},
assortment: 'Basissortiment|B',
assortment: getAssortmentFromItem(i.item),
predefinedReturnQuantity: i.quantity,
retailPrice: i.item.catalogAvailability.price,
retailPrice: getRetailPriceFromItem(i.item),
source: 'manually-added',
returnReason: i.reason,
stock: { id: stock.id },

View File

@@ -1,11 +1,12 @@
<filter-input-menu-button
[filterInput]="filterDepartmentInput()"
[label]="selectedDepartments()"
[commitOnClose]="true"
[label]="selectedDepartment()"
[canApply]="true"
(closed)="rollbackFilterInput()"
>
</filter-input-menu-button>
@if (selectedDepartments()) {
@if (selectedDepartment()) {
<ui-toolbar class="ui-toolbar-rounded">
<span class="flex gap-1 isa-text-body-2-regular"
><span *uiSkeletonLoader="capacityFetching()" class="isa-text-body-2-bold"

View File

@@ -52,14 +52,17 @@ export class RemissionListDepartmentElementsComponent {
});
/**
* Computed signal for the selected departments from the filter input.
* If the input type is Checkbox and has selected values, it returns a comma-separated string.
* Otherwise, it returns undefined.
* Computed signal to get the selected department from the filter input.
* Returns the committed value if department is selected, otherwise a default label.
* @returns {string} The selected departments or a default label.
*/
selectedDepartments = computed(() => {
selectedDepartment = computed(() => {
const input = this.filterDepartmentInput();
if (input?.type === InputType.Checkbox && input?.selected?.length > 0) {
return input?.selected?.filter((selected) => !!selected).join(', ');
if (input && input.type === InputType.Checkbox) {
const committedValue = this.#filterService.queryParams()[input.key];
if (input.selected.length > 0 && committedValue) {
return committedValue;
}
}
return 'Abteilung auswählen';
});
@@ -71,9 +74,7 @@ export class RemissionListDepartmentElementsComponent {
*/
capacityResource = createRemissionCapacityResource(() => {
return {
departments: this.selectedDepartments()
?.split(',')
.map((d) => d.trim()),
departments: [this.selectedDepartment()],
};
});
@@ -144,4 +145,9 @@ export class RemissionListDepartmentElementsComponent {
})
: 0;
});
rollbackFilterInput() {
const inputKey = this.filterDepartmentInput()?.key;
this.#filterService.rollbackInput([inputKey!]);
}
}

View File

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

View File

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

View File

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

View File

@@ -5,6 +5,7 @@ import {
inject,
input,
model,
signal,
} from '@angular/core';
import { FormsModule, Validators } from '@angular/forms';
import { logger } from '@isa/core/logging';
@@ -14,6 +15,7 @@ import {
RemissionListType,
RemissionReturnReceiptService,
RemissionStore,
UpdateItem,
} from '@isa/remission/data-access';
import { TextButtonComponent } from '@isa/ui/buttons';
import { injectFeedbackDialog, injectNumberInputDialog } from '@isa/ui/dialog';
@@ -80,11 +82,12 @@ export class RemissionListItemActionsComponent {
stockToRemit = input.required<number>();
/**
* ModelSignal indicating whether remission items are currently being processed.
* Used to prevent multiple submissions or actions.
* @default false
* Model to track if a delete operation is in progress.
* And the item being deleted or updated.
*/
inProgress = model<boolean>();
removeOrUpdateItem = model<UpdateItem>({
inProgress: false,
});
/**
* Signal indicating whether remission has started.
@@ -114,6 +117,12 @@ export class RemissionListItemActionsComponent {
() => this.item()?.source === RemissionItemSource.ManuallyAdded,
);
/**
* Signal to highlight the change remission quantity button when dialog is open.
* Used to improve accessibility and focus management.
*/
highlight = signal(false);
/**
* Opens a dialog to change the remission quantity for the current item.
* Prompts the user to enter a new quantity and updates the store with the new value
@@ -121,6 +130,7 @@ export class RemissionListItemActionsComponent {
* If the item is not found, it updates the impediment with a comment.
*/
async openRemissionQuantityDialog(): Promise<void> {
this.highlight.set(true);
const dialogRef = this.#dialog({
title: 'Remi-Menge ändern',
displayClose: true,
@@ -150,6 +160,7 @@ export class RemissionListItemActionsComponent {
});
const result = await firstValueFrom(dialogRef.closed);
this.highlight.set(false);
// Dialog Close
if (!result) {
@@ -168,28 +179,37 @@ export class RemissionListItemActionsComponent {
} else if (itemId) {
// Produkt nicht gefunden CTA
try {
this.inProgress.set(true);
this.removeOrUpdateItem.set({ inProgress: true });
let itemToUpdate: RemissionItem | undefined;
if (this.remissionListType() === RemissionListType.Pflicht) {
await this.#remissionReturnReceiptService.updateReturnItemImpediment({
itemId,
comment: 'Produkt nicht gefunden',
});
itemToUpdate =
await this.#remissionReturnReceiptService.updateReturnItemImpediment(
{
itemId,
comment: 'Produkt nicht gefunden',
},
);
}
if (this.remissionListType() === RemissionListType.Abteilung) {
await this.#remissionReturnReceiptService.updateReturnSuggestionImpediment(
{
itemId,
comment: 'Produkt nicht gefunden',
},
);
itemToUpdate =
await this.#remissionReturnReceiptService.updateReturnSuggestionImpediment(
{
itemId,
comment: 'Produkt nicht gefunden',
},
);
}
this.removeOrUpdateItem.set({
inProgress: false,
itemId,
impediment: itemToUpdate?.impediment,
});
} catch (error) {
this.#logger.error('Failed to update impediment', error);
this.removeOrUpdateItem.set({ inProgress: false });
}
this.inProgress.set(false);
}
}
@@ -200,17 +220,17 @@ export class RemissionListItemActionsComponent {
*/
async deleteItemFromList() {
const itemId = this.item()?.id;
if (!itemId || this.inProgress()) {
if (!itemId || this.removeOrUpdateItem().inProgress) {
return;
}
this.inProgress.set(true);
this.removeOrUpdateItem.set({ inProgress: true });
try {
await this.#remissionReturnReceiptService.deleteReturnItem({ itemId });
this.removeOrUpdateItem.set({ inProgress: false, itemId });
} catch (error) {
this.#logger.error('Failed to delete return item', error);
this.removeOrUpdateItem.set({ inProgress: false });
}
this.inProgress.set(false);
}
}

View File

@@ -58,7 +58,7 @@
[selectedQuantityDiffersFromStockToRemit]="
selectedQuantityDiffersFromStockToRemit()
"
(inProgressChange)="inProgress.set($event)"
(removeOrUpdateItemChange)="removeOrUpdateItem.emit($event)"
></remi-feature-remission-list-item-actions>
</ui-item-row-data>
</ui-client-row>

View File

@@ -1,5 +1,11 @@
:host {
@apply w-full;
@apply w-full border border-solid border-transparent rounded-2xl;
&:has(
[data-what="button"][data-which="change-remission-quantity"].highlight
) {
@apply border border-solid border-isa-accent-blue;
}
}
.ui-client-row {

View File

@@ -46,6 +46,7 @@ jest.mock('@isa/remission/data-access', () => ({
// Mock the RemissionStore
const mockRemissionStore = {
selectedQuantity: signal({}),
removeItem: jest.fn(),
};
describe('RemissionListItemComponent', () => {
@@ -112,6 +113,7 @@ describe('RemissionListItemComponent', () => {
// Reset mocks before each test
jest.clearAllMocks();
mockRemissionStore.selectedQuantity.set({});
mockRemissionStore.removeItem.mockClear();
// Reset the mocked functions to return default values
const {
@@ -176,19 +178,11 @@ describe('RemissionListItemComponent', () => {
expect(component.stockFetching()).toBe(true);
});
it('should have inProgress model with undefined default', () => {
it('should have removeOrUpdateItem output', () => {
fixture.componentRef.setInput('item', createMockReturnItem());
fixture.componentRef.setInput('stock', createMockStockInfo());
fixture.detectChanges();
expect(component.inProgress()).toBeUndefined();
});
it('should accept inProgress model value', () => {
fixture.componentRef.setInput('item', createMockReturnItem());
fixture.componentRef.setInput('stock', createMockStockInfo());
fixture.componentRef.setInput('inProgress', true);
fixture.detectChanges();
expect(component.inProgress()).toBe(true);
expect(component.removeOrUpdateItem).toBeDefined();
});
});
@@ -720,4 +714,37 @@ describe('RemissionListItemComponent', () => {
});
});
});
describe('ngOnDestroy', () => {
it('should remove item from store when component is destroyed', () => {
// Arrange
const mockItem = createMockReturnItem({ id: 123 });
fixture.componentRef.setInput('item', mockItem);
fixture.componentRef.setInput('stock', createMockStockInfo());
fixture.detectChanges();
// Act
component.ngOnDestroy();
// Assert
expect(mockRemissionStore.removeItem).toHaveBeenCalledWith(123);
expect(mockRemissionStore.removeItem).toHaveBeenCalledTimes(1);
});
it('should not call removeItem when item has no id', () => {
// Arrange
const mockItem = createMockReturnItem({ id: undefined });
fixture.componentRef.setInput('item', mockItem);
fixture.componentRef.setInput('stock', createMockStockInfo());
fixture.detectChanges();
// Act
component.ngOnDestroy();
// Assert
expect(mockRemissionStore.removeItem).not.toHaveBeenCalled();
});
});
});

View File

@@ -4,7 +4,8 @@ import {
computed,
inject,
input,
model,
OnDestroy,
output,
} from '@angular/core';
import { FormsModule } from '@angular/forms';
import {
@@ -15,6 +16,7 @@ import {
ReturnItem,
ReturnSuggestion,
StockInfo,
UpdateItem,
} from '@isa/remission/data-access';
import {
ProductInfoComponent,
@@ -59,7 +61,7 @@ import { LabelComponent, Labeltype } from '@isa/ui/label';
LabelComponent,
],
})
export class RemissionListItemComponent {
export class RemissionListItemComponent implements OnDestroy {
/**
* Type of label to display for the item.
* Defaults to 'tag', can be changed to 'notice' or other types as needed.
@@ -103,11 +105,10 @@ export class RemissionListItemComponent {
stockFetching = input<boolean>(false);
/**
* ModelSignal indicating whether remission items are currently being processed.
* Used to prevent multiple submissions or actions.
* @default false
* Output event emitter for when the item is deleted or updated.
* Emits an object containing the in-progress state and the item itself.
*/
inProgress = model<boolean>();
removeOrUpdateItem = output<UpdateItem>();
/**
* Optional product group value for display or filtering.
@@ -219,4 +220,16 @@ export class RemissionListItemComponent {
const attempts = this.item()?.impediment?.attempts;
return `${comment}${attempts ? ` (${attempts})` : ''}`;
});
/**
* Cleans up the selected item from the store when the component is destroyed.
* Removes the item using its ID.
* @returns void
*/
ngOnDestroy(): void {
const itemId = this.item()?.id;
if (itemId) {
this.#store.removeItem(itemId);
}
}
}

View File

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

View File

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

View File

@@ -6,6 +6,7 @@ import {
effect,
untracked,
signal,
linkedSignal,
} from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import {
@@ -16,7 +17,10 @@ import {
FilterService,
SearchTrigger,
} from '@isa/shared/filter';
import { injectRestoreScrollPosition } from '@isa/utils/scroll-position';
import {
injectRestoreScrollPosition,
ScrollTopButtonComponent,
} from '@isa/utils/scroll-position';
import { RemissionStartCardComponent } from './remission-start-card/remission-start-card.component';
import { RemissionListSelectComponent } from './remission-list-select/remission-list-select.component';
import {
@@ -40,15 +44,21 @@ import {
calculateAvailableStock,
RemissionReturnReceiptService,
getStockToRemit,
RemissionListType,
RemissionResponseArgsErrorMessage,
UpdateItem,
orderByListItems,
getItemType,
} from '@isa/remission/data-access';
import { injectDialog } from '@isa/ui/dialog';
import { injectDialog, injectFeedbackErrorDialog } from '@isa/ui/dialog';
import { SearchItemToRemitDialogComponent } from '@isa/remission/shared/search-item-to-remit-dialog';
import { RemissionListType } from '@isa/remission/data-access';
import { RemissionReturnCardComponent } from './remission-return-card/remission-return-card.component';
import { logger } from '@isa/core/logging';
import { RemissionProcessedHintComponent } from './remission-processed-hint/remission-processed-hint.component';
import { RemissionListDepartmentElementsComponent } from './remission-list-department-elements/remission-list-department-elements.component';
import { injectTabId } from '@isa/core/tabs';
import { RemissionListEmptyStateComponent } from './remission-list-empty-state/remission-list-empty-state.component';
import { firstValueFrom } from 'rxjs';
function querySettingsFactory() {
return inject(ActivatedRoute).snapshot.data['querySettings'];
@@ -90,6 +100,8 @@ function querySettingsFactory() {
StatefulButtonComponent,
RemissionListDepartmentElementsComponent,
RemissionProcessedHintComponent,
RemissionListEmptyStateComponent,
ScrollTopButtonComponent,
],
host: {
'[class]':
@@ -116,6 +128,7 @@ export class RemissionListComponent {
activatedTabId = injectTabId();
searchItemToRemitDialog = injectDialog(SearchItemToRemitDialogComponent);
errorDialog = injectFeedbackErrorDialog();
/**
* FilterService instance for managing filter state and queries.
@@ -165,7 +178,25 @@ export class RemissionListComponent {
* Signal indicating whether a remission list item deletion is in progress.
* Used to disable actions while deletion is happening.
*/
listItemActionInProgress = signal(false);
removeItemInProgress = signal(false);
/**
* Computed signal for the current search term from the filter service.
* @returns The current search term string or undefined if not set.
*/
searchTerm = computed<string | undefined>(() => {
return this.#filterService.query()?.input['qs'] ?? '';
});
/**
* Computed signal indicating whether there is a valid search term.
* A valid search term is defined as a non-empty string.
* @returns True if there is a valid search term, false otherwise.
*/
hasValidSearchTerm = computed(() => {
const searchTerm = this.searchTerm();
return !!searchTerm && searchTerm.length > 0;
});
/**
* Resource signal for fetching the remission list based on current filters.
@@ -208,6 +239,12 @@ export class RemissionListComponent {
*/
listResponseValue = computed(() => this.remissionResource.value());
/**
* Computed signal indicating whether the remission list resource is currently fetching data.
* @returns True if fetching, false otherwise.
*/
listFetching = computed(() => this.remissionResource.status() === 'loading');
/**
* Computed signal for the current in-stock response.
* @returns Array of StockInfo or undefined.
@@ -230,7 +267,7 @@ export class RemissionListComponent {
* Computed signal for the remission items to display.
* @returns Array of ReturnItem or ReturnSuggestion.
*/
items = computed(() => {
items = linkedSignal(() => {
const value = this.listResponseValue();
return value?.result ? value.result : [];
});
@@ -335,15 +372,29 @@ export class RemissionListComponent {
}
/**
* Handles the deletion of a remission list item.
* Updates the in-progress state and reloads the list and receipt upon completion.
*
* @param inProgress - Whether the deletion is currently in progress
* Handles the removal or update of an item from the remission list.
* Updates the local items signal and the remission store accordingly.
* Items with impediments are automatically moved to the end of the list and sorted by attempt count.
* @param param0 - Object containing inProgress state, itemId, and optional impediment.
*/
onListItemActionInProgress(inProgress: boolean) {
this.listItemActionInProgress.set(inProgress);
if (!inProgress) {
this.reloadListAndReturnData();
onRemoveOrUpdateItem({ inProgress, itemId, impediment }: UpdateItem) {
this.removeItemInProgress.set(inProgress);
if (!inProgress && itemId) {
if (!impediment || (impediment.attempts && impediment.attempts >= 4)) {
this.items.set(this.items().filter((item) => item.id !== itemId)); // Filter Item if no impediment or attempts >= 4 (#5361)
} else {
// Update Item
this.items.update((items) => {
const updatedItems = items.map((item) =>
item.id === itemId ? { ...item, impediment } : item,
);
orderByListItems(updatedItems);
return updatedItems;
});
}
// Always Unselect Item
this.#store.removeItem(itemId);
}
}
@@ -365,35 +416,52 @@ export class RemissionListComponent {
});
/**
* Effect that handles the case when there are no items in the remission list after a search.
* If the search was triggered by the user, it opens a dialog to search for items to remit.
* If remission has already started, it adds the found items to the remission store and remits them.
* If not, it navigates to the default remission list.
* Effect that handles scenarios where a search yields no results.
* If the search was user-initiated and returned no hits, it opens a dialog
* to allow the user to add a new item to remit.
* If only one hit is found and a remission is started, it selects that item automatically.
* This effect runs whenever the remission or stock resource status changes,
* or when the search term changes.
* It ensures that the user is prompted appropriately based on their actions and the current state of the remission process.
* It also checks if the remission is started or if the list type is 'Abteilung' to determine navigation behavior.
* @see {@link
* https://angular.dev/guide/effects} for more information on Angular effects.
* @remarks This effect uses `untracked` to avoid unnecessary re-evaluations
* when accessing certain signals.
*/
emptySearchResultEffect = effect(() => {
const status = this.remissionResource.status();
const stockStatus = this.inStockResource.status();
const searchTerm: string | undefined = this.searchTerm();
if (status !== 'resolved') {
return;
}
const hasItems = !!this.remissionResource.value()?.result?.length;
if (hasItems) {
if (status !== 'resolved' || stockStatus !== 'resolved') {
return;
}
untracked(() => {
if (!this.searchTriggeredByUser()) {
const hits = this.hits();
// #5338 - Select item automatically if only one hit after search
if (
!!hits ||
!searchTerm ||
!this.hasValidSearchTerm() ||
!this.searchTriggeredByUser()
) {
if (hits === 1 && this.remissionStarted()) {
this.#store.clearSelectedItems();
this.preselectRemissionItem(this.items()[0]);
}
return;
}
this.searchItemToRemitDialog({
data: {
searchTerm: this.#filterService.query()?.input['qs'] || '',
isDepartment: this.isDepartment(),
searchTerm,
},
}).closed.subscribe(async (result) => {
this.#store.clearSelectedItems();
if (result) {
if (this.remissionStarted()) {
for (const item of result) {
@@ -405,10 +473,8 @@ export class RemissionListComponent {
} else if (this.isDepartment()) {
return await this.navigateToDefaultRemissionList();
}
this.reloadListAndReturnData();
this.searchTrigger.set('reload');
}
this.reloadListAndReturnData();
});
});
});
@@ -442,13 +508,12 @@ export class RemissionListComponent {
const remissionItemIdNumber = Number(remissionItemId);
const quantity = quantities[remissionItemIdNumber];
const inStock = this.getAvailableStockForItem(item);
const stockToRemit =
quantity ??
getStockToRemit({
remissionItem: item,
remissionListType,
availableStock: inStock,
});
const stockToRemit = getStockToRemit({
remissionItem: item,
remissionListType,
availableStock: inStock,
});
const quantityToRemit = quantity ?? stockToRemit;
if (returnId && receiptId) {
await this.#remissionReturnReceiptService.remitItem({
@@ -456,24 +521,22 @@ export class RemissionListComponent {
addItem: {
returnId,
receiptId,
quantity: stockToRemit,
quantity: quantityToRemit,
inStock,
impedimentComment: stockToRemit > quantity ? 'Restmenge' : '',
remainingQuantity:
isNaN(quantity) || inStock - quantity <= 0
? undefined
: inStock - quantity,
},
type: remissionListType,
type: getItemType(item, remissionListType),
});
}
}
this.remitItemsState.set('success');
this.reloadListAndReturnData();
} catch (error) {
this.#logger.error('Failed to remit items', error);
this.remitItemsError.set(
error instanceof Error
? error.message
: 'Artikel konnten nicht remittiert werden',
);
this.remitItemsState.set('error');
await this.handleRemitItemsError(error);
}
this.#store.clearSelectedItems();
@@ -485,10 +548,67 @@ export class RemissionListComponent {
* This method is used to refresh the displayed data after changes.
*/
reloadListAndReturnData() {
this.searchTrigger.set('reload');
this.remissionResource.reload();
this.#store.reloadReturn();
}
/**
* Pre-Selects a remission item if it has available stock and can be remitted.
* Updates the remission store with the selected item.
* @param item - The ReturnItem or ReturnSuggestion to select.
* @returns void
*/
preselectRemissionItem(item: RemissionItem) {
if (!!item && item.id) {
const inStock = this.getAvailableStockForItem(item);
const stockToRemit = getStockToRemit({
remissionItem: item,
remissionListType: this.selectedRemissionListType(),
availableStock: inStock,
});
if (inStock > 0 && stockToRemit > 0) {
this.#store.selectRemissionItem(item.id, item);
}
}
}
/**
* Handles errors that occur during the remission of items.
* Logs the error, displays an error dialog, and reloads the list and return data.
* If the error indicates that the remission is already completed, it clears the remission state.
* Sets the stateful button to 'error' to indicate the failure.
* @param error - The error object caught during the remission process.
* @returns A promise that resolves when the error handling is complete.
*/
async handleRemitItemsError(error: any) {
this.#logger.error('Failed to remit items', error);
const errorMessage =
error?.error?.message ??
error?.message ??
'Artikel konnten nicht remittiert werden';
this.remitItemsError.set(errorMessage);
await firstValueFrom(
this.errorDialog({
data: {
errorMessage,
},
}).closed,
);
if (errorMessage === RemissionResponseArgsErrorMessage.AlreadyCompleted) {
this.#store.clearState();
}
this.reloadListAndReturnData();
this.remitItemsState.set('error'); // Stateful-Button auf Error setzen
}
/**
* Navigates to the default remission list based on the current activated tab ID.
* This method is used to redirect the user to the remission list after completing or starting a remission.

View File

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

View File

@@ -1,6 +1,7 @@
import { inject, resource } from '@angular/core';
import { ListResponseArgs, ResponseArgsError } from '@isa/common/data-access';
import {
orderByListItems,
QueryTokenInput,
RemissionItem,
RemissionListType,
@@ -9,7 +10,6 @@ import {
RemissionSupplierService,
} from '@isa/remission/data-access';
import { SearchTrigger } from '@isa/shared/filter';
import { parseISO, compareDesc } from 'date-fns';
import { isEan } from '@isa/utils/ean-validation';
/**
@@ -77,11 +77,10 @@ export const createRemissionListResource = (
const isReload = params.searchTrigger === 'reload';
// #5273
// #5387 Hotfix Navigation | Reload has priority over Exact Search
if (isReload) {
queryToken.input = {};
}
if (exactSearch) {
} else if (exactSearch) {
queryToken.filter = {};
queryToken.orderBy = [];
}
@@ -120,9 +119,17 @@ export const createRemissionListResource = (
...(res.result || []),
...(fetchDepartmentListResponse.result || []),
];
res.hits += fetchDepartmentListResponse.hits;
res.skip += fetchDepartmentListResponse.skip;
res.take += fetchDepartmentListResponse.take;
if (fetchDepartmentListResponse?.hits) {
res.hits += fetchDepartmentListResponse.hits;
}
if (fetchDepartmentListResponse?.skip) {
res.skip += fetchDepartmentListResponse?.skip;
}
if (fetchDepartmentListResponse?.take) {
res.take += fetchDepartmentListResponse?.take;
}
} else {
res = fetchDepartmentListResponse;
}
@@ -136,7 +143,7 @@ export const createRemissionListResource = (
const hasOrderBy = !!queryToken?.orderBy && queryToken.orderBy.length > 0;
if (!hasOrderBy && res && res.result && Array.isArray(res.result)) {
sortResponseResult(res);
orderByListItems(res.result);
}
return res;
@@ -144,55 +151,6 @@ export const createRemissionListResource = (
});
};
/**
* Sorts the remission items in the response based on specific criteria:
* - Items with impediments are moved to the end of the list.
* - Manually added items are prioritized to appear first.
* - (Commented out) Items can be sorted by creation date in descending order.
* @param {ListResponseArgs<RemissionItem>} resopnse - The response object containing remission items to be sorted
* @returns {void} The function modifies the response object in place
*/
const sortResponseResult = (
resopnse: ListResponseArgs<RemissionItem>,
): void => {
resopnse.result.sort((a, b) => {
const aHasImpediment = !!a.impediment;
const bHasImpediment = !!b.impediment;
const aIsManuallyAdded = a.source === 'manually-added';
const bIsManuallyAdded = b.source === 'manually-added';
// First priority: move all items with impediment to the end of the list
if (!aHasImpediment && bHasImpediment) {
return -1;
}
if (aHasImpediment && !bHasImpediment) {
return 1;
}
// Second priority: manually-added items come first
if (aIsManuallyAdded && !bIsManuallyAdded) {
return -1;
}
if (!aIsManuallyAdded && bIsManuallyAdded) {
return 1;
}
// #5295 Fix - Sortierung über Created (Pflichtremission) wird wie auch die Sortierung über die SORT Nummer (Abteilungsremission) bereits über das Backend erledigt
// Third priority: sort by created date (latest first)
// if (a.created && b.created) {
// const dateA = parseISO(a.created);
// const dateB = parseISO(b.created);
// return compareDesc(dateA, dateB); // Descending order (latest first)
// }
// // Handle cases where created date might be missing
// if (a.created && !b.created) return -1;
// if (!a.created && b.created) return 1;
return 0;
});
};
// #5128 #5234 Bei Exact Search soll er über Alle Listen nur mit dem Input ohne aktive Filter / orderBy suchen
/**
* Checks if the query token is an exact search based on the search trigger.

View File

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

View File

@@ -41,7 +41,6 @@
appearance="noArticles"
>
<lib-remission-return-receipt-actions
class="mt-[1.5rem]"
[remissionReturn]="returnData()"
[displayDeleteAction]="false"
(reloadData)="returnResource.reload()"
@@ -56,7 +55,7 @@
[removeable]="canRemoveItems()"
[receiptId]="receiptId()"
[returnId]="returnId()"
(removed)="returnResource.reload()"
(reloadReturn)="returnResource.reload()"
></remi-remission-return-receipt-details-item>
@if (!last) {
<hr class="border-isa-neutral-300" />
@@ -68,6 +67,7 @@
<lib-remission-return-receipt-complete
[returnId]="returnId()"
[receiptId]="receiptId()"
[hasAssignedPackage]="hasAssignedPackage()"
[itemsLength]="items?.length"
(reloadData)="returnResource.reload()"
></lib-remission-return-receipt-complete>

View File

@@ -14,6 +14,7 @@ import { RemissionReturnReceiptDetailsItemComponent } from './remission-return-r
import { Location } from '@angular/common';
import { createReturnResource } from './resources/return.resource';
import {
getPackageNumbersFromReturn,
getReceiptItemsFromReturn,
getReceiptNumberFromReturn,
} from '@isa/remission/data-access';
@@ -105,4 +106,9 @@ export class RemissionReturnReceiptDetailsComponent {
const returnData = this.returnData();
return !!returnData && !returnData.completed;
});
hasAssignedPackage = computed(() => {
const returnData = this.returnData();
return getPackageNumbersFromReturn(returnData!) !== '';
});
}

View File

@@ -25,11 +25,11 @@
</div>
<div
class="grid"
[class.grid-cols-[minmax(20rem,1fr),auto]]="!horizontal"
[ngClass]="!horizontal ? innerGridClass() : ''"
[class.gap-6]="!horizontal"
[class.grid-flow-row]="horizontal"
[class.gap-2]="horizontal"
class="grid"
>
<div class="grid grid-flow-row gap-2">
<div class="isa-text-body-2-bold" data-what="product-contributors">

View File

@@ -1,6 +1,6 @@
import { CurrencyPipe } from '@angular/common';
import { CurrencyPipe, NgClass } from '@angular/common';
import { Component, input } from '@angular/core';
import { RemissionItem, ReturnItem } from '@isa/remission/data-access';
import { RemissionItem } from '@isa/remission/data-access';
import { ProductImageDirective } from '@isa/shared/product-image';
import { ProductRouterLinkDirective } from '@isa/shared/product-router-link';
import { ProductFormatComponent } from '@isa/shared/product-foramt';
@@ -23,6 +23,7 @@ export const RemissionItemTags = {
selector: 'remi-product-info',
templateUrl: 'product-info.component.html',
imports: [
NgClass,
ProductImageDirective,
ProductRouterLinkDirective,
CurrencyPipe,
@@ -50,4 +51,6 @@ export class ProductInfoComponent {
item = input.required<ProductInfoItem>();
orientation = input<ProductInfoOrientation>('horizontal');
innerGridClass = input<string>('grid-cols-[minmax(20rem,1fr),auto]');
}

View File

@@ -1,8 +1,10 @@
<span
class="w-full flex items-center justify-center text-isa-neutral-900 isa-text-body-2-bold"
>
2/2
</span>
@if (!assignPackageOnly()) {
<span
class="w-full flex items-center justify-center text-isa-neutral-900 isa-text-body-2-bold"
>
2/2
</span>
}
<div class="flex flex-col gap-4">
<h2 class="isa-text-subtitle-1-bold flex-shrink-0" data-what="title">
Wannennummer Scannen

View File

@@ -71,6 +71,9 @@ import { RequestStatus } from './remission-start-dialog.component';
],
})
export class AssignPackageNumberComponent {
/** Input flag indicating if the dialog is opened for package assignment only */
assignPackageOnly = input<boolean>(false);
/**
* Input signal containing the current request status for the assign package operation.
* Used to display loading states and handle server-side validation errors.

View File

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

View File

@@ -59,6 +59,14 @@ export type RequestStatus = {
export type RemissionStartDialogData = {
/** The return group identifier for the remission process */
returnGroup: string | undefined;
/** #5289 - Flag indicating if the dialog is opened for package assignment only */
assignPackage?:
| {
returnId: number;
receiptId: number;
}
| undefined;
};
/**
@@ -220,17 +228,20 @@ export class RemissionStartDialogComponent extends DialogContentDirective<
packageNumber: string | undefined,
): Promise<void> {
this.assignPackageRequestStatus.set({ loading: true });
const data = this.assignPackageStepData();
const data = this.assignPackageStepData() ?? this.data?.assignPackage;
if (!data || !packageNumber) {
return this.onDialogClose(undefined);
}
const returnId = data.returnId;
const receiptId = data.receiptId;
try {
const response = await this.#remissionReturnReceiptService.assignPackage({
packageNumber,
returnId: data.returnId,
receiptId: data.receiptId,
returnId,
receiptId,
});
if (!response) {
@@ -238,8 +249,8 @@ export class RemissionStartDialogComponent extends DialogContentDirective<
}
this.onDialogClose({
returnId: data.returnId,
receiptId: data.receiptId,
returnId,
receiptId,
});
this.assignPackageRequestStatus.set({ loading: false });
} catch (error: any) {

View File

@@ -38,23 +38,120 @@ describe('RemissionStartService', () => {
service = TestBed.inject(RemissionStartService);
});
it('should start remission successfully when dialog returns result', async () => {
// Arrange
const returnGroup = 'test-return-group';
describe('startRemission', () => {
it('should start remission successfully when dialog returns result', async () => {
// Arrange
const returnGroup = 'test-return-group';
// Act
await service.startRemission(returnGroup);
// Act
await service.startRemission(returnGroup);
// Assert
expect(mockDialog).toHaveBeenCalledWith({
data: { returnGroup },
classList: ['gap-0'],
width: '30rem',
// Assert
expect(mockDialog).toHaveBeenCalledWith({
data: { returnGroup },
classList: ['gap-0'],
width: '30rem',
});
expect(mockRemissionStore.startRemission).toHaveBeenCalledWith({
returnId: 'test-return-id',
receiptId: 'test-receipt-id',
});
});
expect(mockRemissionStore.startRemission).toHaveBeenCalledWith({
returnId: 'test-return-id',
receiptId: 'test-receipt-id',
it('should handle undefined returnGroup', async () => {
// Arrange
const returnGroup = undefined;
// Act
await service.startRemission(returnGroup);
// Assert
expect(mockDialog).toHaveBeenCalledWith({
data: { returnGroup: undefined },
classList: ['gap-0'],
width: '30rem',
});
expect(mockRemissionStore.startRemission).toHaveBeenCalledWith({
returnId: 'test-return-id',
receiptId: 'test-receipt-id',
});
});
it('should not call startRemission when dialog returns falsy result', async () => {
// Arrange
const returnGroup = 'test-return-group';
mockDialogRef.closed = of(null);
// Act
await service.startRemission(returnGroup);
// Assert
expect(mockDialog).toHaveBeenCalledWith({
data: { returnGroup },
classList: ['gap-0'],
width: '30rem',
});
expect(mockRemissionStore.startRemission).not.toHaveBeenCalled();
});
});
describe('assignPackage', () => {
it('should open dialog with correct assignPackage data and return result', async () => {
// Arrange
const returnId = 12345;
const receiptId = 67890;
const expectedResult = {
returnId: 'test-return-id',
receiptId: 'test-receipt-id',
};
mockDialogRef.closed = of(expectedResult);
// Act
const result = await service.assignPackage({ returnId, receiptId });
// Assert
expect(mockDialog).toHaveBeenCalledWith({
data: {
returnGroup: undefined,
assignPackage: {
returnId,
receiptId,
},
},
classList: ['gap-0'],
width: '30rem',
});
expect(result).toEqual(expectedResult);
expect(mockRemissionStore.startRemission).not.toHaveBeenCalled();
});
it('should handle null result from dialog', async () => {
// Arrange
const returnId = 12345;
const receiptId = 67890;
mockDialogRef.closed = of(null);
// Act
const result = await service.assignPackage({ returnId, receiptId });
// Assert
expect(mockDialog).toHaveBeenCalledWith({
data: {
returnGroup: undefined,
assignPackage: {
returnId,
receiptId,
},
},
classList: ['gap-0'],
width: '30rem',
});
expect(result).toBeNull();
});
});
});

View File

@@ -26,4 +26,26 @@ export class RemissionStartService {
});
}
}
// #5289 - Bei WBS ohne Wannennummer, soll man nur die Wannennummer generieren können
async assignPackage({
returnId,
receiptId,
}: {
returnId: number;
receiptId: number;
}) {
const remissionStartDialogRef = this.#remissionStartDialog({
data: {
returnGroup: undefined,
assignPackage: {
returnId,
receiptId,
},
},
classList: ['gap-0'],
width: '30rem',
});
return await firstValueFrom(remissionStartDialogRef.closed);
}
}

View File

@@ -61,6 +61,7 @@ describe('RemissionReturnReceiptCompleteComponent', () => {
mockRemissionStartService = {
startRemission: vi.fn(),
assignPackage: vi.fn(),
};
mockRouter = {
@@ -108,6 +109,7 @@ describe('RemissionReturnReceiptCompleteComponent', () => {
fixture.componentRef.setInput('returnId', 123);
fixture.componentRef.setInput('receiptId', 456);
fixture.componentRef.setInput('itemsLength', 5);
fixture.componentRef.setInput('hasAssignedPackage', true);
fixture.detectChanges();
});
@@ -121,6 +123,7 @@ describe('RemissionReturnReceiptCompleteComponent', () => {
expect(component.returnId()).toBe(123);
expect(component.receiptId()).toBe(456);
expect(component.itemsLength()).toBe(5);
expect(component.hasAssignedPackage()).toBe(true);
expect(component.completingRemission()).toBe(false);
});
});
@@ -150,10 +153,9 @@ describe('RemissionReturnReceiptCompleteComponent', () => {
});
describe('completeRemission', () => {
it('should complete remission without return group', async () => {
it('should complete remission with package already assigned and no return group', async () => {
// Arrange
const mockReturn = { id: 123, returnGroup: null };
mockRemissionReturnReceiptService.completeReturnReceiptAndReturn.mockResolvedValue(
mockReturn,
);
@@ -166,10 +168,114 @@ describe('RemissionReturnReceiptCompleteComponent', () => {
// Assert
expect(component.completingRemission()).toBe(false);
expect(mockRemissionStartService.assignPackage).not.toHaveBeenCalled();
expect(mockInjectConfirmationDialog).not.toHaveBeenCalled();
expect(reloadDataSpy).toHaveBeenCalled();
});
it('should complete remission without package assigned and assign package successfully', async () => {
// Arrange
fixture.componentRef.setInput('hasAssignedPackage', false);
fixture.detectChanges();
const mockReturn = { id: 123, returnGroup: null };
mockRemissionStartService.assignPackage.mockResolvedValue(true);
mockRemissionReturnReceiptService.completeReturnReceiptAndReturn.mockResolvedValue(
mockReturn,
);
const reloadDataSpy = vi.fn();
component.reloadData.subscribe(reloadDataSpy);
// Act
await component.completeRemission();
// Assert
expect(mockRemissionStartService.assignPackage).toHaveBeenCalledWith({
returnId: 123,
receiptId: 456,
});
expect(component.completingRemission()).toBe(false);
expect(reloadDataSpy).toHaveBeenCalled();
});
it('should complete remission with return group and user confirms completion', async () => {
// Arrange
const mockReturn = { id: 123, returnGroup: 'RG001' };
mockRemissionReturnReceiptService.completeReturnReceiptAndReturn.mockResolvedValue(
mockReturn,
);
mockRemissionReturnReceiptService.completeReturnGroup.mockResolvedValue(
undefined,
);
// Mock dialog result with confirmed=true
mockDialogRef.closed = of({ confirmed: true });
const reloadDataSpy = vi.fn();
component.reloadData.subscribe(reloadDataSpy);
// Act
await component.completeRemission();
// Assert
expect(mockInjectConfirmationDialog).toHaveBeenCalledWith({
title: 'Wanne abgeschlossen',
width: '30rem',
data: {
message: expect.stringContaining('Legen Sie abschließend den'),
closeText: 'Neue Wanne',
confirmText: 'Beenden',
},
});
expect(
mockRemissionReturnReceiptService.completeReturnGroup,
).toHaveBeenCalledWith({
returnGroup: 'RG001',
});
expect(component.completingRemission()).toBe(false);
expect(reloadDataSpy).toHaveBeenCalled();
});
it('should complete remission with return group and user chooses new container', async () => {
// Arrange
const mockReturn = { id: 123, returnGroup: 'RG001' };
mockRemissionReturnReceiptService.completeReturnReceiptAndReturn.mockResolvedValue(
mockReturn,
);
mockRemissionStartService.startRemission.mockResolvedValue(undefined);
// Mock dialog result with confirmed=false (user chose "Neue Wanne")
mockDialogRef.closed = of({ confirmed: false });
const reloadDataSpy = vi.fn();
component.reloadData.subscribe(reloadDataSpy);
// Act
await component.completeRemission();
// Assert
expect(mockInjectConfirmationDialog).toHaveBeenCalledWith({
title: 'Wanne abgeschlossen',
width: '30rem',
data: {
message: expect.stringContaining('Legen Sie abschließend den'),
closeText: 'Neue Wanne',
confirmText: 'Beenden',
},
});
expect(mockRemissionStartService.startRemission).toHaveBeenCalledWith(
'RG001',
);
expect(mockRouter.navigate).toHaveBeenCalledWith([
'/',
'test-tab-id',
'remission',
]);
expect(component.completingRemission()).toBe(false);
expect(reloadDataSpy).toHaveBeenCalled();
});
it('should prevent multiple completion attempts', async () => {
// Arrange
component.completingRemission.set(true);

View File

@@ -89,6 +89,13 @@ export class RemissionReturnReceiptCompleteComponent {
*/
itemsLength = input.required<number>();
/**
* Required input indicating if there is at least one package assigned to the return.
* @input
* @required
*/
hasAssignedPackage = input.required<boolean>();
/**
* Output event that emits when the list needs to be reloaded.
* This is used to refresh the remission list after completing a return.
@@ -128,7 +135,22 @@ export class RemissionReturnReceiptCompleteComponent {
return;
}
this.completingRemission.set(true);
try {
// #5289 - Ensure a package is assigned before completing the remission
if (!this.hasAssignedPackage()) {
const res = await this.#remissionStartService.assignPackage({
returnId: this.returnId(),
receiptId: this.receiptId(),
});
if (!res) {
this.completingRemission.set(false);
return;
}
}
// Complete Remission Flow
const completedReturn = await this.completeSingleReturnReceipt();
const returnGroup = completedReturn?.returnGroup;
@@ -146,7 +168,7 @@ export class RemissionReturnReceiptCompleteComponent {
const dialogResult = await firstValueFrom(dialogRef.closed);
if (dialogResult?.confirmed) {
// Beenden - Remission abschließen Flow
// Beenden - Remission abschließen Flow - Return Group Abschließen
await this.#remissionReturnReceiptService.completeReturnGroup({
returnGroup,
});

View File

@@ -0,0 +1,39 @@
import { inject, resource } from '@angular/core';
import { RemissionStockService } from '@isa/remission/data-access';
export const createInStockResource = (
params: () => {
itemIds: number[];
},
) => {
const remissionStockService = inject(RemissionStockService);
return resource({
params,
loader: async ({ abortSignal, params }) => {
if (!params?.itemIds || params.itemIds.length === 0) {
return;
}
const assignedStock =
await remissionStockService.fetchAssignedStock(abortSignal);
if (!assignedStock || !assignedStock.id) {
throw new Error('No current stock available');
}
const itemIds = params.itemIds;
if (itemIds.some((id) => isNaN(id))) {
throw new Error('Invalid Catalog Product Number provided');
}
return await remissionStockService.fetchStock(
{
itemIds,
assignedStockId: assignedStock.id,
},
abortSignal,
);
},
});
};

View File

@@ -8,7 +8,7 @@
name="quantity"
placeholder="Menge eingeben"
type="number"
[ngModel]="quantityAndReason().quantity"
[ngModel]="quantity()"
(ngModelChange)="setQuantity($event)"
#model="ngModel"
[min]="1"
@@ -16,7 +16,7 @@
required
data-what="input"
data-which="quantity"
class="isa-text-body-2-bold placeholder:isa-text-body-2-regular placeholder:text-isa-neutral-200 text-isa-neutral-900 focus:outline-none w-[9rem] px-4 text-right"
class="isa-text-body-2-bold placeholder:isa-text-body-2-regular placeholder:text-isa-neutral-500 text-isa-neutral-900 focus:outline-none w-[9rem] px-4 text-right"
/>
<ui-dropdown
[ngModel]="quantityAndReason().reason"

View File

@@ -15,7 +15,7 @@ import {
DropdownButtonComponent,
DropdownOptionComponent,
} from '@isa/ui/input-controls';
import { QuantityAndReason } from './select-remi-quantity-and-reason.component';
import { QuantityAndReason } from './select-remi-quantity-and-reason-dialog.component';
import { ReturnValue } from '@isa/common/data-access';
import { provideIcons } from '@ng-icons/core';
import { isaActionChevronDown, isaActionChevronUp } from '@isa/icons';
@@ -53,6 +53,11 @@ export class QuantityAndReasonItemComponent {
},
});
quantity = computed(() => {
const quantity = this.quantityAndReason().quantity;
return quantity !== undefined && quantity >= 1 ? quantity : undefined;
});
setQuantity(quantity: number): void {
this.quantityAndReason.update((qar) => ({
...qar,

View File

@@ -1,18 +1,14 @@
@if (item()) {
<remi-select-remi-quantity-and-reason></remi-select-remi-quantity-and-reason>
} @else {
<button
class="absolute top-1 right-[1.33rem]"
type="button"
uiTextButton
size="small"
color="subtle"
(click)="close(undefined)"
tabindex="-1"
data-what="button"
data-which="close-dialog"
>
Schließen
</button>
<remi-search-item-to-remit-list></remi-search-item-to-remit-list>
}
<button
class="absolute top-4 right-[1.33rem]"
type="button"
uiTextButton
size="small"
color="subtle"
(click)="close(undefined)"
tabindex="-1"
data-what="button"
data-which="close-dialog"
>
Schließen
</button>
<remi-search-item-to-remit-list></remi-search-item-to-remit-list>

View File

@@ -1,3 +1,3 @@
:host {
@apply block h-full;
@apply block h-full mt-6;
}

View File

@@ -1,377 +0,0 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { SearchItemToRemitDialogComponent } from './search-item-to-remit-dialog.component';
import { DialogRef, DIALOG_DATA } from '@angular/cdk/dialog';
import { DialogComponent } from '@isa/ui/dialog';
import { MockComponents } from 'ng-mocks';
import { TextButtonComponent } from '@isa/ui/buttons';
import { SearchItemToRemitListComponent } from './search-item-to-remit-list.component';
import { SelectRemiQuantityAndReasonComponent } from './select-remi-quantity-and-reason.component';
import { signal } from '@angular/core';
import { Item } from '@isa/catalogue/data-access';
import { By } from '@angular/platform-browser';
describe('SearchItemToRemitDialogComponent', () => {
let component: SearchItemToRemitDialogComponent;
let fixture: ComponentFixture<SearchItemToRemitDialogComponent>;
let mockDialogRef: {
updateSize: ReturnType<typeof vi.fn>;
close: ReturnType<typeof vi.fn>;
};
let mockDialogComponent: {
title: ReturnType<typeof signal>;
};
const mockItem = {
id: 1,
product: {
id: 1,
name: 'Test Product',
},
catalogAvailability: {},
} as unknown as Item;
beforeEach(async () => {
mockDialogRef = {
updateSize: vi.fn(),
close: vi.fn(),
};
mockDialogComponent = {
title: signal(''),
};
const mockData = { searchTerm: 'test' };
await TestBed.configureTestingModule({
imports: [SearchItemToRemitDialogComponent],
providers: [
{ provide: DialogRef, useValue: mockDialogRef },
{ provide: DIALOG_DATA, useValue: mockData },
{ provide: DialogComponent, useValue: mockDialogComponent },
],
})
.overrideComponent(SearchItemToRemitDialogComponent, {
remove: {
imports: [
TextButtonComponent,
SearchItemToRemitListComponent,
SelectRemiQuantityAndReasonComponent,
],
},
add: {
imports: MockComponents(
TextButtonComponent,
SearchItemToRemitListComponent,
SelectRemiQuantityAndReasonComponent,
),
},
})
.compileComponents();
fixture = TestBed.createComponent(SearchItemToRemitDialogComponent);
component = fixture.componentInstance;
});
describe('Component Setup and Initialization', () => {
it('should create', () => {
expect(component).toBeTruthy();
});
it('should initialize searchTerm from string data', () => {
fixture.detectChanges();
expect(component.searchTerm()).toBe('test');
});
it('should initialize searchTerm from Signal data', async () => {
const searchTermSignal = signal('signal test');
const mockSignalData = { searchTerm: searchTermSignal };
TestBed.resetTestingModule();
await TestBed.configureTestingModule({
imports: [SearchItemToRemitDialogComponent],
providers: [
{ provide: DialogRef, useValue: mockDialogRef },
{ provide: DIALOG_DATA, useValue: mockSignalData },
{ provide: DialogComponent, useValue: mockDialogComponent },
],
})
.overrideComponent(SearchItemToRemitDialogComponent, {
remove: {
imports: [
TextButtonComponent,
SearchItemToRemitListComponent,
SelectRemiQuantityAndReasonComponent,
],
},
add: {
imports: MockComponents(
TextButtonComponent,
SearchItemToRemitListComponent,
SelectRemiQuantityAndReasonComponent,
),
},
})
.compileComponents();
fixture = TestBed.createComponent(SearchItemToRemitDialogComponent);
component = fixture.componentInstance;
fixture.detectChanges();
expect(component.searchTerm()).toBe('signal test');
// Test that it reacts to signal changes
searchTermSignal.set('updated signal test');
fixture.detectChanges();
expect(component.searchTerm()).toBe('updated signal test');
});
it('should initialize item signal as undefined', () => {
fixture.detectChanges();
expect(component.item()).toBeUndefined();
});
it('should extend DialogContentDirective', () => {
expect(component.dialogRef).toBeDefined();
expect(component.data).toBeDefined();
expect(component.close).toBeDefined();
});
});
describe('Signal and Effect Behavior', () => {
it('should update dialog size to auto when item is undefined', () => {
fixture.detectChanges();
expect(mockDialogRef.updateSize).toHaveBeenCalledWith('auto');
});
it('should update dialog size to 36rem when item is set', () => {
fixture.detectChanges();
mockDialogRef.updateSize.mockClear();
component.item.set(mockItem);
fixture.detectChanges();
expect(mockDialogRef.updateSize).toHaveBeenCalledWith('36rem');
});
it('should update searchTerm when linkedSignal source changes', () => {
const searchTermSignal = signal('initial');
const mockSignalData = { searchTerm: searchTermSignal };
TestBed.resetTestingModule();
TestBed.configureTestingModule({
imports: [SearchItemToRemitDialogComponent],
providers: [
{ provide: DialogRef, useValue: mockDialogRef },
{ provide: DIALOG_DATA, useValue: mockSignalData },
{ provide: DialogComponent, useValue: mockDialogComponent },
],
})
.overrideComponent(SearchItemToRemitDialogComponent, {
remove: {
imports: [
TextButtonComponent,
SearchItemToRemitListComponent,
SelectRemiQuantityAndReasonComponent,
],
},
add: {
imports: MockComponents(
TextButtonComponent,
SearchItemToRemitListComponent,
SelectRemiQuantityAndReasonComponent,
),
},
})
.compileComponents();
fixture = TestBed.createComponent(SearchItemToRemitDialogComponent);
component = fixture.componentInstance;
fixture.detectChanges();
expect(component.searchTerm()).toBe('initial');
searchTermSignal.set('updated');
fixture.detectChanges();
expect(component.searchTerm()).toBe('updated');
});
});
describe('Template Behavior', () => {
it('should show search list component when item is undefined', () => {
fixture.detectChanges();
const searchList = fixture.debugElement.query(
By.css('remi-search-item-to-remit-list'),
);
const selectQuantity = fixture.debugElement.query(
By.css('remi-select-remi-quantity-and-reason'),
);
expect(searchList).toBeTruthy();
expect(selectQuantity).toBeFalsy();
});
it('should show select quantity component when item is set', () => {
component.item.set(mockItem);
fixture.detectChanges();
const searchList = fixture.debugElement.query(
By.css('remi-search-item-to-remit-list'),
);
const selectQuantity = fixture.debugElement.query(
By.css('remi-select-remi-quantity-and-reason'),
);
expect(searchList).toBeFalsy();
expect(selectQuantity).toBeTruthy();
});
it('should show close button only when item is undefined', () => {
fixture.detectChanges();
let closeButton = fixture.debugElement.query(
By.css('button[uiTextButton]'),
);
expect(closeButton).toBeTruthy();
expect(closeButton.nativeElement.textContent.trim()).toBe('Schließen');
component.item.set(mockItem);
fixture.detectChanges();
closeButton = fixture.debugElement.query(By.css('button[uiTextButton]'));
expect(closeButton).toBeFalsy();
});
it('should call close with undefined when close button is clicked', () => {
fixture.detectChanges();
const closeButton = fixture.debugElement.query(
By.css('button[uiTextButton]'),
);
closeButton.nativeElement.click();
expect(mockDialogRef.close).toHaveBeenCalledWith(undefined);
});
it('should have correct button attributes', () => {
fixture.detectChanges();
const closeButton = fixture.debugElement.query(By.css('button'));
const buttonEl = closeButton.nativeElement;
expect(buttonEl.type).toBe('button');
expect(buttonEl.classList.contains('absolute')).toBe(true);
expect(buttonEl.classList.contains('top-1')).toBe(true);
expect(buttonEl.classList.contains('right-[1.33rem]')).toBe(true);
});
});
describe('DialogRef Integration', () => {
it('should call dialogRef.updateSize on initialization', () => {
fixture.detectChanges();
expect(mockDialogRef.updateSize).toHaveBeenCalledWith('auto');
});
it('should call dialogRef.updateSize when item changes', () => {
fixture.detectChanges();
mockDialogRef.updateSize.mockClear();
component.item.set(mockItem);
fixture.detectChanges();
expect(mockDialogRef.updateSize).toHaveBeenCalledWith('36rem');
component.item.set(undefined);
fixture.detectChanges();
expect(mockDialogRef.updateSize).toHaveBeenCalledWith('auto');
});
it('should inherit close method from DialogContentDirective', () => {
const closeSpy = vi.spyOn(component, 'close');
component.close(mockItem);
expect(closeSpy).toHaveBeenCalledWith(mockItem);
expect(mockDialogRef.close).toHaveBeenCalledWith(mockItem);
});
});
describe('Edge Cases and Error Handling', () => {
it('should handle empty searchTerm', async () => {
const emptyData = { searchTerm: '' };
TestBed.resetTestingModule();
await TestBed.configureTestingModule({
imports: [SearchItemToRemitDialogComponent],
providers: [
{ provide: DialogRef, useValue: mockDialogRef },
{ provide: DIALOG_DATA, useValue: emptyData },
{ provide: DialogComponent, useValue: mockDialogComponent },
],
})
.overrideComponent(SearchItemToRemitDialogComponent, {
remove: {
imports: [
TextButtonComponent,
SearchItemToRemitListComponent,
SelectRemiQuantityAndReasonComponent,
],
},
add: {
imports: MockComponents(
TextButtonComponent,
SearchItemToRemitListComponent,
SelectRemiQuantityAndReasonComponent,
),
},
})
.compileComponents();
fixture = TestBed.createComponent(SearchItemToRemitDialogComponent);
component = fixture.componentInstance;
fixture.detectChanges();
expect(component.searchTerm()).toBe('');
});
it('should handle multiple rapid item changes', () => {
fixture.detectChanges();
mockDialogRef.updateSize.mockClear();
// First change
component.item.set(mockItem);
fixture.detectChanges();
expect(mockDialogRef.updateSize).toHaveBeenCalledWith('36rem');
// Second change
component.item.set(undefined);
fixture.detectChanges();
expect(mockDialogRef.updateSize).toHaveBeenCalledWith('auto');
// Third change
component.item.set(mockItem);
fixture.detectChanges();
expect(mockDialogRef.updateSize).toHaveBeenCalledWith('36rem');
// Total calls
expect(mockDialogRef.updateSize).toHaveBeenCalledTimes(3);
});
it('should handle component destruction gracefully', () => {
fixture.detectChanges();
// Component destruction should not throw errors
expect(() => {
fixture.destroy();
}).not.toThrow();
});
it('should maintain data integrity', () => {
const originalData = { searchTerm: 'test' };
fixture.detectChanges();
// Data should remain unchanged
expect(component.data).toEqual(originalData);
expect(component.data.searchTerm).toBe('test');
});
});
});

View File

@@ -1,33 +1,23 @@
import {
ChangeDetectionStrategy,
Component,
effect,
isSignal,
linkedSignal,
signal,
Signal,
} from '@angular/core';
import { DialogContentDirective, NumberInputValidation } from '@isa/ui/dialog';
import { Item } from '@isa/catalogue/data-access';
import { TextButtonComponent } from '@isa/ui/buttons';
import { provideIcons } from '@ng-icons/core';
import { isaActionSearch } from '@isa/icons';
import { SearchItemToRemitListComponent } from './search-item-to-remit-list.component';
import { SelectRemiQuantityAndReasonComponent } from './select-remi-quantity-and-reason.component';
import { Validators } from '@angular/forms';
import { ReturnSuggestion, ReturnItem } from '@isa/remission/data-access';
import { ReturnItem } from '@isa/remission/data-access';
export type SearchItemToRemitDialogData = {
searchTerm: string | Signal<string>;
isDepartment: boolean;
};
export type SearchItemToRemitDialogResult =
SearchItemToRemitDialogData extends { isDepartment: infer D }
? D extends true
? ReturnSuggestion
: ReturnItem
: never;
// #5273, #4768 Fix - Nur ReturnItems sind zugelassen und dürfen zur Pflichtremission hinzugefügt werden
export type SearchItemToRemitDialogResult = ReturnItem;
@Component({
selector: 'remi-search-item-to-remit-dialog',
@@ -35,11 +25,7 @@ export type SearchItemToRemitDialogResult =
styleUrls: ['./search-item-to-remit-dialog.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [
TextButtonComponent,
SearchItemToRemitListComponent,
SelectRemiQuantityAndReasonComponent,
],
imports: [TextButtonComponent, SearchItemToRemitListComponent],
providers: [provideIcons({ isaActionSearch })],
})
export class SearchItemToRemitDialogComponent extends DialogContentDirective<
@@ -51,35 +37,4 @@ export class SearchItemToRemitDialogComponent extends DialogContentDirective<
? this.data.searchTerm()
: this.data.searchTerm,
);
item = signal<Item | undefined>(undefined);
itemEffect = effect(() => {
const item = this.item();
this.dialogRef.updateSize(item ? '36rem' : 'auto');
if (item) {
this.dialog.title.set(`Dieser Artikel steht nicht auf der Remi Liste`);
} else {
this.dialog.title.set(undefined);
}
});
quantityValidators: NumberInputValidation[] = [
{
errorKey: 'required',
inputValidator: Validators.required,
errorText: 'Bitte geben Sie eine Menge an.',
},
{
errorKey: 'min',
inputValidator: Validators.min(1),
errorText: 'Die Menge muss mindestens 1 sein.',
},
{
errorKey: 'max',
inputValidator: Validators.max(1000),
errorText: 'Die Menge darf höchstens 1000 sein.',
},
];
}

View File

@@ -3,7 +3,7 @@
cdkFocusRegionStart
[(ngModel)]="host.searchTerm"
type="text"
placeholder="Rechnungsnummer, E-Mail, Kundenkarte, Name..."
placeholder="EAN, Titel, ..."
(keydown.enter)="triggerSearch()"
data-what="input"
data-which="search-remission"
@@ -14,7 +14,7 @@
name="isaActionSearch"
color="brand"
(click)="triggerSearch()"
[pending]="searchResource.isLoading()"
[pending]="searchResource.isLoading() || inStockResource.isLoading()"
data-what="button"
data-which="search-submit"
></ui-icon-button>
@@ -24,13 +24,23 @@
>
Sie können Artikel die nicht auf der Remi Liste stehen direkt zum
Warenbegleitschein hinzufügen.
<button
class="relative top-[0.375rem] w-6 h-6 inline-flex items-center justify-center text-isa-accent-blue"
uiTooltip
[content]="'Es werden nur Artikel mit Bestand angezeigt'"
[triggerOn]="['click', 'hover']"
>
<ng-icon size="1.5rem" name="isaOtherInfo"></ng-icon>
</button>
</p>
<div class="overflow-y-auto">
<div #list class="overflow-y-auto overflow-x-hidden">
@if (searchResource.value()?.result; as items) {
@for (item of items; track item.id) {
@for (item of availableSearchResults(); track item.id) {
@defer {
<remi-search-item-to-remit
<remi-search-item-to-remit
[item]="item"
[inStock]="getAvailableStockForItem(item)"
data-what="list-item"
data-which="search-result"
[attr.data-item-id]="item.id"
@@ -38,4 +48,18 @@
}
}
}
@if (
!hasItems() && !searchResource.isLoading() && !inStockResource.isLoading()
) {
<ui-empty-state
class="w-full justify-self-center"
title="Keine Suchergebnisse"
description="Bitte prüfen Sie die Schreibweise."
>
</ui-empty-state>
}
<utils-scroll-top-button
class="flex flex-col self-end absolute bottom-6 right-6"
[target]="list"
></utils-scroll-top-button>
</div>

View File

@@ -1,18 +1,19 @@
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
OnInit,
resource,
signal,
} from '@angular/core';
import { isaActionSearch } from '@isa/icons';
import { isaActionSearch, isaOtherInfo } from '@isa/icons';
import { IconButtonComponent } from '@isa/ui/buttons';
import {
UiSearchBarClearComponent,
UiSearchBarComponent,
} from '@isa/ui/search-bar';
import { provideIcons } from '@ng-icons/core';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { SearchItemToRemitComponent } from './search-item-to-remit.component';
import { FormsModule } from '@angular/forms';
import { DEFAULT_LIST_RESPONSE_ARGS_OF_ITEM } from './constants';
@@ -27,6 +28,11 @@ import {
} from '@isa/common/data-access';
import { SearchItemToRemitDialogComponent } from './search-item-to-remit-dialog.component';
import { CdkTrapFocus } from '@angular/cdk/a11y';
import { TooltipDirective } from '@isa/ui/tooltip';
import { createInStockResource } from './instock.resource';
import { calculateAvailableStock } from '@isa/remission/data-access';
import { EmptyStateComponent } from '@isa/ui/empty-state';
import { ScrollTopButtonComponent } from '@isa/utils/scroll-position';
@Component({
selector: 'remi-search-item-to-remit-list',
@@ -41,8 +47,12 @@ import { CdkTrapFocus } from '@angular/cdk/a11y';
FormsModule,
SearchItemToRemitComponent,
CdkTrapFocus,
TooltipDirective,
NgIcon,
EmptyStateComponent,
ScrollTopButtonComponent,
],
providers: [provideIcons({ isaActionSearch })],
providers: [provideIcons({ isaActionSearch, isaOtherInfo })],
})
export class SearchItemToRemitListComponent implements OnInit {
host = inject(SearchItemToRemitDialogComponent);
@@ -50,6 +60,42 @@ export class SearchItemToRemitListComponent implements OnInit {
searchParams = signal<SearchByTermInput | undefined>(undefined);
availableSearchResults = computed(() => {
return (
this.searchResource.value()?.result?.filter((item) => {
return this.getAvailableStockForItem(item) > 0;
}) ?? []
);
});
inStockResource = createInStockResource(() => {
return {
itemIds:
this.searchResource
.value()
?.result?.map((item) => item?.id)
.filter((id) => !!id) ?? [],
};
});
inStockResponseValue = computed(() => this.inStockResource.value());
hasItems = computed(() => {
return (this.availableSearchResults()?.length ?? 0) > 0;
});
stockInfoMap = computed(() => {
const infos = this.inStockResponseValue() ?? [];
return new Map(infos.map((info) => [info.itemId, info]));
});
getAvailableStockForItem(item: Item): number {
const stockInfo = this.stockInfoMap().get(item.id);
return calculateAvailableStock({
stock: stockInfo?.inStock,
removedFromStock: stockInfo?.removedFromStock,
});
}
triggerSearch(): void {
this.searchParams.set({
searchTerm: this.host.searchTerm(),

View File

@@ -4,14 +4,21 @@
retailPrice: item().catalogAvailability.price,
}"
[orientation]="productInfoOrientation()"
[innerGridClass]="'grid-cols-[minmax(20rem,1fr),minmax(18rem,auto)]'"
></remi-product-info>
<div class="text-right">
<div class="flex flex-col items-end justify-center gap-6">
<div
class="text-isa-neutral-900 w-[18rem] flex flex-row items-center justify-between"
>
<span class="isa-text-body-2-regular">Aktueller Bestand</span>
<span class="isa-text-body-2-bold">{{ inStock() }}x</span>
</div>
<button
class="-mr-5"
type="button"
uiTextButton
color="strong"
(click)="host.item.set(item())"
(click)="openQuantityAndReasonDialog()"
>
Remimenge auswählen
</button>

View File

@@ -10,6 +10,9 @@ import { ProductInfoComponent } from '@isa/remission/shared/product';
import { TextButtonComponent } from '@isa/ui/buttons';
import { Breakpoint, breakpoint } from '@isa/ui/layout';
import { SearchItemToRemitDialogComponent } from './search-item-to-remit-dialog.component';
import { injectDialog } from '@isa/ui/dialog';
import { SelectRemiQuantityAndReasonDialogComponent } from './select-remi-quantity-and-reason-dialog.component';
import { firstValueFrom } from 'rxjs';
@Component({
selector: 'remi-search-item-to-remit',
@@ -20,12 +23,34 @@ import { SearchItemToRemitDialogComponent } from './search-item-to-remit-dialog.
})
export class SearchItemToRemitComponent {
host = inject(SearchItemToRemitDialogComponent);
quantityAndReasonDialog = injectDialog(
SelectRemiQuantityAndReasonDialogComponent,
);
item = input.required<Item>();
inStock = input.required<number>();
desktopBreakpoint = breakpoint([Breakpoint.DekstopL, Breakpoint.DekstopXL]);
productInfoOrientation = computed(() => {
return this.desktopBreakpoint() ? 'vertical' : 'horizontal';
});
async openQuantityAndReasonDialog() {
if (this.item()) {
const dialogRef = this.quantityAndReasonDialog({
title: 'Dieser Artikel steht nicht auf der Remi Liste',
data: {
item: this.item(),
inStock: this.inStock(),
},
width: '36rem',
});
const dialogResult = await firstValueFrom(dialogRef.closed);
if (dialogResult) {
this.host.close(dialogResult);
}
}
}
}

View File

@@ -1,84 +1,94 @@
<p class="text-isa-neutral-600 isa-text-body-1-regular">
Wie viele Exemplare können remittiert werden?
</p>
<div class="flex flex-col gap-4">
@for (
quantityAndReason of quantitiesAndResons();
track $index;
let i = $index
) {
<div class="flex items-center gap-1">
<remi-quantity-and-reason-item
[position]="$index + 1"
[quantityAndReason]="quantityAndReason"
(quantityAndReasonChange)="setQuantityAndReason($index, $event)"
class="flex-1"
data-what="component"
data-which="quantity-reason-item"
[attr.data-position]="$index + 1"
></remi-quantity-and-reason-item>
@if (i > 0) {
<ui-icon-button
type="button"
(click)="removeQuantityReasonItem($index)"
data-what="button"
data-which="remove-quantity"
[attr.data-position]="$index + 1"
name="isaActionClose"
color="neutral"
></ui-icon-button>
}
</div>
}
</div>
<div>
<button
type="button"
class="flex items-center gap-2 -ml-5"
uiTextButton
color="strong"
(click)="addQuantityReasonItem()"
data-what="button"
data-which="add-quantity"
>
<ng-icon name="isaActionPlus" size="1.5rem"></ng-icon>
<div>Menge hinzufügen</div>
</button>
</div>
<div class="text-isa-accent-red isa-text-body-1-regular">
<span>
@if (canReturnErrors(); as errors) {
@for (error of errors; track $index) {
{{ error }}
}
}
</span>
</div>
<div class="grid grid-cols-2 items-center gap-2">
<button
type="button"
color="secondary"
size="large"
uiButton
(click)="host.item.set(undefined)"
data-what="button"
data-which="back"
>
Zurück
</button>
<button
type="button"
color="primary"
size="large"
uiButton
[pending]="canAddToRemiListResource.isLoading()"
[disabled]="canAddToRemiListResource.isLoading() || canReturn() === false"
(click)="addToRemiList()"
data-what="button"
data-which="save-remission"
>
Speichern
</button>
</div>
<remi-product-info
[item]="{
product: data.item.product,
retailPrice: data.item.catalogAvailability.price,
}"
></remi-product-info>
<div class="text-isa-neutral-900 flex flex-row items-center justify-end gap-8">
<span class="isa-text-body-2-regular">Aktueller Bestand</span>
<span class="isa-text-body-2-bold">{{ data.inStock }}x</span>
</div>
<p class="text-isa-neutral-600 isa-text-body-1-regular">
Wie viele Exemplare können remittiert werden?
</p>
<div class="flex flex-col gap-4">
@for (
quantityAndReason of quantitiesAndResons();
track $index;
let i = $index
) {
<div class="flex items-center gap-1">
<remi-quantity-and-reason-item
[position]="$index + 1"
[quantityAndReason]="quantityAndReason"
(quantityAndReasonChange)="setQuantityAndReason($index, $event)"
class="flex-1"
data-what="component"
data-which="quantity-reason-item"
[attr.data-position]="$index + 1"
></remi-quantity-and-reason-item>
@if (i > 0) {
<ui-icon-button
type="button"
(click)="removeQuantityReasonItem($index)"
data-what="button"
data-which="remove-quantity"
[attr.data-position]="$index + 1"
name="isaActionClose"
color="neutral"
></ui-icon-button>
}
</div>
}
</div>
<div>
<button
type="button"
class="flex items-center gap-2 -ml-5"
uiTextButton
color="strong"
(click)="addQuantityReasonItem()"
data-what="button"
data-which="add-quantity"
>
<ng-icon name="isaActionPlus" size="1.5rem"></ng-icon>
<div>Menge hinzufügen</div>
</button>
</div>
<div class="text-isa-accent-red isa-text-body-1-regular">
<span>
@if (canReturnErrors(); as errors) {
@for (error of errors; track $index) {
{{ error }}
}
}
</span>
</div>
<div class="grid grid-cols-2 items-center gap-2">
<button
type="button"
color="secondary"
size="large"
uiButton
(click)="close(undefined)"
data-what="button"
data-which="back"
>
Zurück
</button>
<button
type="button"
color="primary"
size="large"
uiButton
[pending]="canAddToRemiListResource.isLoading()"
[disabled]="canAddToRemiListResource.isLoading() || canReturn() === false"
(click)="addToRemiList()"
data-what="button"
data-which="save-remission"
>
Speichern
</button>
</div>

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