Merge branch 'develop' into release/4.0

This commit is contained in:
Lorenz Hilpert
2025-06-12 21:12:08 +02:00
218 changed files with 6544 additions and 1881 deletions

View File

@@ -30,6 +30,9 @@
}
],
"github.copilot.chat.codeGeneration.instructions": [
{
"file": ".vscode/llms/angular.txt"
},
{
"file": "docs/tech-stack.md"
},

View File

@@ -1,12 +1,22 @@
import { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from '@angular/router';
import { ApplicationProcess, ApplicationService } from '@core/application';
import { DomainCheckoutService } from '@domain/checkout';
import { CustomerSearchNavigation } from '@shared/services/navigation';
import { first } from 'rxjs/operators';
import { Injectable } from "@angular/core";
import {
ActivatedRouteSnapshot,
Router,
RouterStateSnapshot,
} from "@angular/router";
import { ApplicationProcess, ApplicationService } from "@core/application";
import { DomainCheckoutService } from "@domain/checkout";
import { logger } from "@isa/core/logging";
import { CustomerSearchNavigation } from "@shared/services/navigation";
import { first } from "rxjs/operators";
@Injectable({ providedIn: 'root' })
@Injectable({ providedIn: "root" })
export class CanActivateCustomerGuard {
#logger = logger(() => ({
context: "CanActivateCustomerGuard",
tags: ["guard", "customer", "navigation"],
}));
constructor(
private readonly _applicationService: ApplicationService,
private readonly _checkoutService: DomainCheckoutService,
@@ -14,36 +24,77 @@ export class CanActivateCustomerGuard {
private readonly _navigation: CustomerSearchNavigation,
) {}
async canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot) {
const processes = await this._applicationService.getProcesses$('customer').pipe(first()).toPromise();
let lastActivatedProcessId = (
async canActivate(
route: ActivatedRouteSnapshot,
{ url }: RouterStateSnapshot,
) {
if (url.startsWith("/kunde/customer/search/")) {
const processId = Date.now(); // Generate a new process ID
// Extract parts before and after the pattern
const parts = url.split("/kunde/customer/");
if (parts.length === 2) {
const prefix = parts[0] + "/kunde/";
const suffix = "customer/" + parts[1];
// Construct the new URL with process ID inserted
const newUrl = `${prefix}${processId}/${suffix}`;
this.#logger.info("Redirecting to URL with process ID", () => ({
originalUrl: url,
newUrl,
processId,
}));
// Navigate to the new URL and prevent original navigation
this._router.navigateByUrl(newUrl);
return false;
}
}
const processes = await this._applicationService
.getProcesses$("customer")
.pipe(first())
.toPromise();
const lastActivatedProcessId = (
await this._applicationService
.getLastActivatedProcessWithSectionAndType$('customer', 'cart')
.getLastActivatedProcessWithSectionAndType$("customer", "cart")
.pipe(first())
.toPromise()
)?.id;
const lastActivatedCartCheckoutProcessId = (
await this._applicationService
.getLastActivatedProcessWithSectionAndType$('customer', 'cart-checkout')
.getLastActivatedProcessWithSectionAndType$("customer", "cart-checkout")
.pipe(first())
.toPromise()
)?.id;
const lastActivatedGoodsOutProcessId = (
await this._applicationService
.getLastActivatedProcessWithSectionAndType$('customer', 'goods-out')
.getLastActivatedProcessWithSectionAndType$("customer", "goods-out")
.pipe(first())
.toPromise()
)?.id;
const activatedProcessId = await this._applicationService.getActivatedProcessId$().pipe(first()).toPromise();
const activatedProcessId = await this._applicationService
.getActivatedProcessId$()
.pipe(first())
.toPromise();
// Darf nur reinkommen wenn der aktuell aktive Tab ein Bestellabschluss Tab ist
if (!!lastActivatedCartCheckoutProcessId && lastActivatedCartCheckoutProcessId === activatedProcessId) {
await this.fromCartCheckoutProcess(processes, lastActivatedCartCheckoutProcessId);
if (
!!lastActivatedCartCheckoutProcessId &&
lastActivatedCartCheckoutProcessId === activatedProcessId
) {
await this.fromCartCheckoutProcess(
processes,
lastActivatedCartCheckoutProcessId,
);
return false;
} else if (!!lastActivatedGoodsOutProcessId && lastActivatedGoodsOutProcessId === activatedProcessId) {
} else if (
!!lastActivatedGoodsOutProcessId &&
lastActivatedGoodsOutProcessId === activatedProcessId
) {
await this.fromGoodsOutProcess(processes, lastActivatedGoodsOutProcessId);
return false;
}
@@ -68,25 +119,28 @@ export class CanActivateCustomerGuard {
const newProcessId = Date.now();
await this._applicationService.createProcess({
id: newProcessId,
type: 'cart',
section: 'customer',
name: `Vorgang ${this.processNumber(processes.filter((process) => process.type === 'cart'))}`,
type: "cart",
section: "customer",
name: `Vorgang ${this.processNumber(processes.filter((process) => process.type === "cart"))}`,
});
await this.navigateToDefaultRoute(newProcessId);
}
// Bei offener Bestellbestätigung und Klick auf Footer Kundensuche
async fromCartCheckoutProcess(processes: ApplicationProcess[], processId: number) {
async fromCartCheckoutProcess(
processes: ApplicationProcess[],
processId: number,
) {
// Um alle Checkout Daten zu resetten die mit dem Prozess assoziiert sind
this._checkoutService.removeProcess({ processId });
// Ändere type cart-checkout zu cart
this._applicationService.patchProcess(processId, {
id: processId,
type: 'cart',
section: 'customer',
name: `Vorgang ${this.processNumber(processes.filter((process) => process.type === 'cart'))}`,
type: "cart",
section: "customer",
name: `Vorgang ${this.processNumber(processes.filter((process) => process.type === "cart"))}`,
data: {},
});
@@ -95,22 +149,31 @@ export class CanActivateCustomerGuard {
}
// Bei offener Warenausgabe und Klick auf Footer Kundensuche
async fromGoodsOutProcess(processes: ApplicationProcess[], processId: number) {
const buyer = await this._checkoutService.getBuyer({ processId }).pipe(first()).toPromise();
const customerFeatures = await this._checkoutService.getCustomerFeatures({ processId }).pipe(first()).toPromise();
async fromGoodsOutProcess(
processes: ApplicationProcess[],
processId: number,
) {
const buyer = await this._checkoutService
.getBuyer({ processId })
.pipe(first())
.toPromise();
const customerFeatures = await this._checkoutService
.getCustomerFeatures({ processId })
.pipe(first())
.toPromise();
const name = buyer
? customerFeatures?.b2b
? buyer.organisation?.name
? buyer.organisation?.name
: buyer.lastName
: buyer.lastName
: `Vorgang ${this.processNumber(processes.filter((process) => process.type === 'cart'))}`;
: `Vorgang ${this.processNumber(processes.filter((process) => process.type === "cart"))}`;
// Ändere type goods-out zu cart
this._applicationService.patchProcess(processId, {
id: processId,
type: 'cart',
section: 'customer',
type: "cart",
section: "customer",
name,
});
@@ -119,12 +182,20 @@ export class CanActivateCustomerGuard {
}
processNumber(processes: ApplicationProcess[]) {
const processNumbers = processes?.map((process) => Number(process?.name?.replace(/\D/g, '')));
return !!processNumbers && processNumbers.length > 0 ? this.findMissingNumber(processNumbers) : 1;
const processNumbers = processes?.map((process) =>
Number(process?.name?.replace(/\D/g, "")),
);
return !!processNumbers && processNumbers.length > 0
? this.findMissingNumber(processNumbers)
: 1;
}
findMissingNumber(processNumbers: number[]) {
for (let missingNumber = 1; missingNumber < Math.max(...processNumbers); missingNumber++) {
for (
let missingNumber = 1;
missingNumber < Math.max(...processNumbers);
missingNumber++
) {
if (!processNumbers.find((number) => number === missingNumber)) {
return missingNumber;
}

View File

@@ -1,13 +1,18 @@
import { coerceArray } from '@angular/cdk/coercion';
import { inject, Injectable } from '@angular/core';
import { Config } from '@core/config';
import { isNullOrUndefined } from '@utils/common';
import { AuthConfig, OAuthService } from 'angular-oauth2-oidc';
import { JwksValidationHandler } from 'angular-oauth2-oidc-jwks';
import { BehaviorSubject } from 'rxjs';
import { coerceArray } from "@angular/cdk/coercion";
import { inject, Injectable } from "@angular/core";
import { Config } from "@core/config";
import { isNullOrUndefined } from "@utils/common";
import { AuthConfig, OAuthService } from "angular-oauth2-oidc";
import { JwksValidationHandler } from "angular-oauth2-oidc-jwks";
import { BehaviorSubject } from "rxjs";
/**
* Storage key for the URL to redirect to after login
*/
const REDIRECT_URL_KEY = "auth_redirect_url";
@Injectable({
providedIn: 'root',
providedIn: "root",
})
export class AuthService {
private readonly _initialized = new BehaviorSubject<boolean>(false);
@@ -16,28 +21,39 @@ export class AuthService {
}
private _authConfig: AuthConfig;
constructor(
private _config: Config,
private readonly _oAuthService: OAuthService,
) {
this._oAuthService.events?.subscribe((event) => {
if (event.type === 'token_received') {
console.log('SSO Token Expiration:', new Date(this._oAuthService.getAccessTokenExpiration()));
if (event.type === "token_received") {
console.log(
"SSO Token Expiration:",
new Date(this._oAuthService.getAccessTokenExpiration()),
);
// Handle redirect after successful authentication
setTimeout(() => {
const redirectUrl = this._getAndClearRedirectUrl();
if (redirectUrl) {
window.location.href = redirectUrl;
}
}, 100);
}
});
}
async init() {
if (this._initialized.getValue()) {
throw new Error('AuthService is already initialized');
throw new Error("AuthService is already initialized");
}
this._authConfig = this._config.get('@core/auth');
this._authConfig = this._config.get("@core/auth");
this._authConfig.redirectUri = window.location.origin;
this._authConfig.silentRefreshRedirectUri = window.location.origin + '/silent-refresh.html';
this._authConfig.silentRefreshRedirectUri =
window.location.origin + "/silent-refresh.html";
this._authConfig.useSilentRefresh = true;
this._oAuthService.configure(this._authConfig);
@@ -55,12 +71,18 @@ export class AuthService {
}
isIdTokenValid() {
console.log('ID Token Expiration:', new Date(this._oAuthService.getIdTokenExpiration()));
console.log(
"ID Token Expiration:",
new Date(this._oAuthService.getIdTokenExpiration()),
);
return this._oAuthService.hasValidIdToken();
}
isAccessTokenValid() {
console.log('ACCESS Token Expiration:', new Date(this._oAuthService.getAccessTokenExpiration()));
console.log(
"ACCESS Token Expiration:",
new Date(this._oAuthService.getAccessTokenExpiration()),
);
return this._oAuthService.hasValidAccessToken();
}
@@ -85,14 +107,31 @@ export class AuthService {
if (isNullOrUndefined(token)) {
return null;
}
const base64Url = token.split('.')[1];
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
const base64Url = token.split(".")[1];
const base64 = base64Url.replace(/-/g, "+").replace(/_/g, "/");
const encoded = window.atob(base64);
return JSON.parse(encoded);
}
/**
* Saves the URL to redirect to after successful login
*/
_saveRedirectUrl(): void {
localStorage.setItem(REDIRECT_URL_KEY, window.location.href);
}
/**
* Gets and clears the saved redirect URL
*/
_getAndClearRedirectUrl(): string | null {
const url = localStorage.getItem(REDIRECT_URL_KEY);
localStorage.removeItem(REDIRECT_URL_KEY);
return url;
}
login() {
this._saveRedirectUrl();
this._oAuthService.initLoginFlow();
}
@@ -109,7 +148,7 @@ export class AuthService {
hasRole(role: string | string[]) {
const roles = coerceArray(role);
const userRoles = this.getClaimByKey('role');
const userRoles = this.getClaimByKey("role");
if (isNullOrUndefined(userRoles)) {
return false;
@@ -120,7 +159,10 @@ export class AuthService {
async refresh() {
try {
if (this._authConfig.responseType.includes('code') && this._authConfig.scope.includes('offline_access')) {
if (
this._authConfig.responseType.includes("code") &&
this._authConfig.scope.includes("offline_access")
) {
await this._oAuthService.refreshToken();
} else {
await this._oAuthService.silentRefresh();

View File

@@ -9,37 +9,53 @@ import {
AfterViewInit,
TrackByFunction,
inject,
} from '@angular/core';
import { Router } from '@angular/router';
import { ApplicationService } from '@core/application';
import { DomainAvailabilityService } from '@domain/availability';
import { DomainCheckoutService } from '@domain/checkout';
import { AvailabilityDTO, BranchDTO, DestinationDTO, ShoppingCartItemDTO } from '@generated/swagger/checkout-api';
import { UiMessageModalComponent, UiModalService } from '@ui/modal';
import { PrintModalData, PrintModalComponent } from '@modal/printer';
import { delay, first, map, switchMap, takeUntil, tap } from 'rxjs/operators';
import { Subject, NEVER, combineLatest, BehaviorSubject, Subscription } from 'rxjs';
import { DomainCatalogService } from '@domain/catalog';
import { BreadcrumbService } from '@core/breadcrumb';
import { DomainPrinterService } from '@domain/printer';
import { CheckoutDummyComponent } from '../checkout-dummy/checkout-dummy.component';
import { CheckoutDummyData } from '../checkout-dummy/checkout-dummy-data';
import { PurchaseOptionsModalService } from '@modal/purchase-options';
import { CheckoutNavigationService, ProductCatalogNavigationService } from '@shared/services/navigation';
import { EnvironmentService } from '@core/environment';
import { CheckoutReviewStore } from './checkout-review.store';
import { ToasterService } from '@shared/shell';
import { ShoppingCartItemComponent } from './shopping-cart-item/shopping-cart-item.component';
import { CustomerSearchNavigation } from '@shared/services/navigation';
} from "@angular/core";
import { Router } from "@angular/router";
import { ApplicationService } from "@core/application";
import { DomainAvailabilityService } from "@domain/availability";
import { DomainCheckoutService } from "@domain/checkout";
import {
AvailabilityDTO,
BranchDTO,
DestinationDTO,
ShoppingCartItemDTO,
} from "@generated/swagger/checkout-api";
import { UiMessageModalComponent, UiModalService } from "@ui/modal";
import { PrintModalData, PrintModalComponent } from "@modal/printer";
import { delay, first, map, switchMap, takeUntil, tap } from "rxjs/operators";
import {
Subject,
NEVER,
combineLatest,
BehaviorSubject,
Subscription,
} from "rxjs";
import { DomainCatalogService } from "@domain/catalog";
import { BreadcrumbService } from "@core/breadcrumb";
import { DomainPrinterService } from "@domain/printer";
import { CheckoutDummyComponent } from "../checkout-dummy/checkout-dummy.component";
import { CheckoutDummyData } from "../checkout-dummy/checkout-dummy-data";
import { PurchaseOptionsModalService } from "@modal/purchase-options";
import {
CheckoutNavigationService,
ProductCatalogNavigationService,
} from "@shared/services/navigation";
import { EnvironmentService } from "@core/environment";
import { CheckoutReviewStore } from "./checkout-review.store";
import { ToasterService } from "@shared/shell";
import { ShoppingCartItemComponent } from "./shopping-cart-item/shopping-cart-item.component";
import { CustomerSearchNavigation } from "@shared/services/navigation";
@Component({
selector: 'page-checkout-review',
templateUrl: 'checkout-review.component.html',
styleUrls: ['checkout-review.component.scss'],
selector: "page-checkout-review",
templateUrl: "checkout-review.component.html",
styleUrls: ["checkout-review.component.scss"],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: false,
})
export class CheckoutReviewComponent implements OnInit, OnDestroy, AfterViewInit {
export class CheckoutReviewComponent
implements OnInit, OnDestroy, AfterViewInit
{
private _onDestroy$ = new Subject<void>();
private _customerSearchNavigation = inject(CustomerSearchNavigation);
@@ -57,7 +73,9 @@ export class CheckoutReviewComponent implements OnInit, OnDestroy, AfterViewInit
shoppingCartItemsWithoutOrderType$ = this._store.shoppingCartItems$.pipe(
takeUntil(this._store.orderCompleted),
map((items) => items?.filter((item) => item?.features?.orderType === undefined)),
map((items) =>
items?.filter((item) => item?.features?.orderType === undefined),
),
);
trackByGroupedItems: TrackByFunction<{
@@ -71,11 +89,11 @@ export class CheckoutReviewComponent implements OnInit, OnDestroy, AfterViewInit
map((items) =>
items.reduce(
(grouped, item) => {
let index = grouped.findIndex((g) =>
item?.availability?.supplyChannel === 'MANUALLY'
? g?.orderType === 'Dummy'
: item?.features?.orderType === 'DIG-Versand'
? g?.orderType === 'Versand'
const index = grouped.findIndex((g) =>
item?.availability?.supplyChannel === "MANUALLY"
? g?.orderType === "Dummy"
: item?.features?.orderType === "DIG-Versand"
? g?.orderType === "Versand"
: g?.orderType === item?.features?.orderType,
);
@@ -83,10 +101,10 @@ export class CheckoutReviewComponent implements OnInit, OnDestroy, AfterViewInit
if (!group) {
group = {
orderType:
item?.availability?.supplyChannel === 'MANUALLY'
? 'Dummy'
: item?.features?.orderType === 'DIG-Versand'
? 'Versand'
item?.availability?.supplyChannel === "MANUALLY"
? "Dummy"
: item?.features?.orderType === "DIG-Versand"
? "Versand"
: item?.features?.orderType,
destination: item?.destination?.data,
items: [],
@@ -95,7 +113,8 @@ export class CheckoutReviewComponent implements OnInit, OnDestroy, AfterViewInit
group.items = [...group.items, item]?.sort(
(a, b) =>
a.destination?.data?.targetBranch?.id - b.destination?.data?.targetBranch?.id ||
a.destination?.data?.targetBranch?.id -
b.destination?.data?.targetBranch?.id ||
a.product?.name.localeCompare(b.product?.name),
);
@@ -105,9 +124,19 @@ export class CheckoutReviewComponent implements OnInit, OnDestroy, AfterViewInit
grouped.push(group);
}
return [...grouped].sort((a, b) => (a?.orderType === undefined ? -1 : b?.orderType === undefined ? 1 : 0));
return [...grouped].sort((a, b) =>
a?.orderType === undefined
? -1
: b?.orderType === undefined
? 1
: 0,
);
},
[] as { orderType: string; destination: DestinationDTO; items: ShoppingCartItemDTO[] }[],
[] as {
orderType: string;
destination: DestinationDTO;
items: ShoppingCartItemDTO[];
}[],
),
),
);
@@ -138,7 +167,14 @@ export class CheckoutReviewComponent implements OnInit, OnDestroy, AfterViewInit
.getPromotionPoints({
items,
})
.pipe(map((response) => Object.values(response.result).reduce((sum, points) => sum + points, 0)));
.pipe(
map((response) =>
Object.values(response.result).reduce(
(sum, points) => sum + points,
0,
),
),
);
} else {
return NEVER;
}
@@ -147,20 +183,25 @@ export class CheckoutReviewComponent implements OnInit, OnDestroy, AfterViewInit
customerFeatures$ = this._store.customerFeatures$;
checkNotificationChannelControl$ = this._store.checkNotificationChannelControl$;
checkNotificationChannelControl$ =
this._store.checkNotificationChannelControl$;
showQuantityControlSpinnerItemId: number;
quantityError$ = new BehaviorSubject<{ [key: string]: string }>({});
primaryCtaLabel$ = combineLatest([this.payer$, this.buyer$, this.shoppingCartItemsWithoutOrderType$]).pipe(
primaryCtaLabel$ = combineLatest([
this.payer$,
this.buyer$,
this.shoppingCartItemsWithoutOrderType$,
]).pipe(
map(([payer, buyer, shoppingCartItemsWithoutOrderType]) => {
if (shoppingCartItemsWithoutOrderType?.length > 0) {
return 'Kaufoptionen';
return "Kaufoptionen";
}
if (!(payer || buyer)) {
return 'Weiter';
return "Weiter";
}
return 'Bestellen';
return "Bestellen";
}),
);
@@ -181,12 +222,16 @@ export class CheckoutReviewComponent implements OnInit, OnDestroy, AfterViewInit
checkoutIsInValid$ = this.applicationService.activatedProcessId$.pipe(
takeUntil(this._onDestroy$),
switchMap((processId) => this.domainCheckoutService.checkoutIsValid({ processId })),
switchMap((processId) =>
this.domainCheckoutService.checkoutIsValid({ processId }),
),
map((valid) => !valid),
);
get productSearchBasePath() {
return this._productNavigationService.getArticleSearchBasePath(this.applicationService.activatedProcessId).path;
return this._productNavigationService.getArticleSearchBasePath(
this.applicationService.activatedProcessId,
).path;
}
get isDesktop$() {
@@ -219,14 +264,16 @@ export class CheckoutReviewComponent implements OnInit, OnDestroy, AfterViewInit
) {}
async ngOnInit() {
this.applicationService.activatedProcessId$.pipe(takeUntil(this._onDestroy$)).subscribe((_) => {
this._store.loadShoppingCart();
});
this.applicationService.activatedProcessId$
.pipe(takeUntil(this._onDestroy$))
.subscribe((_) => {
this._store.loadShoppingCart();
});
await this.removeBreadcrumbs();
await this.updateBreadcrumb();
window['Checkout'] = {
window["Checkout"] = {
refreshAvailabilities: this.refreshAvailabilities.bind(this),
};
}
@@ -267,13 +314,16 @@ export class CheckoutReviewComponent implements OnInit, OnDestroy, AfterViewInit
group: { items: ShoppingCartItemDTO[] },
i: number,
) {
return i === 0 ? false : targetBranch.id !== group.items[i - 1].destination?.data?.targetBranch?.data.id;
return i === 0
? false
: targetBranch.id !==
group.items[i - 1].destination?.data?.targetBranch?.data.id;
}
async refreshAvailabilities() {
this.checkingOla$.next(true);
for (let itemComp of this._shoppingCartItems.toArray()) {
for (const itemComp of this._shoppingCartItems.toArray()) {
await itemComp.refreshAvailability();
await new Promise((resolve) => setTimeout(resolve, 100));
}
@@ -283,16 +333,22 @@ export class CheckoutReviewComponent implements OnInit, OnDestroy, AfterViewInit
async updateBreadcrumb() {
await this.breadcrumb.addOrUpdateBreadcrumbIfNotExists({
key: this.applicationService.activatedProcessId,
name: 'Warenkorb',
path: this._navigationService.getCheckoutReviewPath(this.applicationService.activatedProcessId).path,
tags: ['checkout', 'cart'],
section: 'customer',
name: "Warenkorb",
path: this._navigationService.getCheckoutReviewPath(
this.applicationService.activatedProcessId,
).path,
tags: ["checkout", "cart"],
section: "customer",
});
}
async removeBreadcrumbs() {
const checkoutDummyCrumbs = await this.breadcrumb
.getBreadcrumbsByKeyAndTags$(this.applicationService.activatedProcessId, ['checkout', 'cart', 'dummy'])
.getBreadcrumbsByKeyAndTags$(this.applicationService.activatedProcessId, [
"checkout",
"cart",
"dummy",
])
.pipe(first())
.toPromise();
checkoutDummyCrumbs.forEach(async (crumb) => {
@@ -304,32 +360,49 @@ export class CheckoutReviewComponent implements OnInit, OnDestroy, AfterViewInit
this._store.notificationsControl = undefined;
}
openDummyModal({ data, changeDataFromCart = false }: { data?: CheckoutDummyData; changeDataFromCart?: boolean }) {
openDummyModal({
data,
changeDataFromCart = false,
}: {
data?: CheckoutDummyData;
changeDataFromCart?: boolean;
}) {
this.uiModal.open({
content: CheckoutDummyComponent,
data: { ...data, changeDataFromCart },
});
}
changeDummyItem({ shoppingCartItem }: { shoppingCartItem: ShoppingCartItemDTO }) {
changeDummyItem({
shoppingCartItem,
}: {
shoppingCartItem: ShoppingCartItemDTO;
}) {
this.openDummyModal({ data: shoppingCartItem, changeDataFromCart: true });
}
async changeItem({ shoppingCartItem }: { shoppingCartItem: ShoppingCartItemDTO }) {
async changeItem({
shoppingCartItem,
}: {
shoppingCartItem: ShoppingCartItemDTO;
}) {
this._purchaseOptionsModalService.open({
processId: this.applicationService.activatedProcessId,
items: [shoppingCartItem],
type: 'update',
type: "update",
});
}
async openPrintModal() {
let shoppingCart = await this.shoppingCart$.pipe(first()).toPromise();
const shoppingCart = await this.shoppingCart$.pipe(first()).toPromise();
this.uiModal.open({
content: PrintModalComponent,
data: {
printerType: 'Label',
print: (printer) => this.domainPrinterService.printCart({ cartId: shoppingCart.id, printer }).toPromise(),
printerType: "Label",
print: (printer) =>
this.domainPrinterService
.printCart({ cartId: shoppingCart.id, printer })
.toPromise(),
} as PrintModalData,
config: {
panelClass: [],
@@ -351,7 +424,8 @@ export class CheckoutReviewComponent implements OnInit, OnDestroy, AfterViewInit
this.loadingOnQuantityChangeById$.next(shoppingCartItem.id);
const shoppingCartItemPrice = shoppingCartItem?.availability?.price?.value?.value;
const shoppingCartItemPrice =
shoppingCartItem?.availability?.price?.value?.value;
const orderType = shoppingCartItem?.features?.orderType;
let availability: AvailabilityDTO;
@@ -360,7 +434,7 @@ export class CheckoutReviewComponent implements OnInit, OnDestroy, AfterViewInit
if (orderType) {
switch (orderType) {
case 'Rücklage':
case "Rücklage":
availability = await this.availabilityService
.getTakeAwayAvailability({
item: {
@@ -369,12 +443,13 @@ export class CheckoutReviewComponent implements OnInit, OnDestroy, AfterViewInit
price: shoppingCartItem.availability.price,
},
quantity,
branch,
})
.toPromise();
// this.setQuantityError(shoppingCartItem, availability, availability?.inStock < quantity);
break;
case 'Abholung':
case "Abholung":
availability = await this.availabilityService
.getPickUpAvailability({
branch,
@@ -388,7 +463,7 @@ export class CheckoutReviewComponent implements OnInit, OnDestroy, AfterViewInit
.pipe(map((av) => av[0]))
.toPromise();
break;
case 'Versand':
case "Versand":
availability = await this.availabilityService
.getDeliveryAvailability({
item: {
@@ -400,7 +475,7 @@ export class CheckoutReviewComponent implements OnInit, OnDestroy, AfterViewInit
})
.toPromise();
break;
case 'DIG-Versand':
case "DIG-Versand":
availability = await this.availabilityService
.getDigDeliveryAvailability({
item: {
@@ -412,7 +487,7 @@ export class CheckoutReviewComponent implements OnInit, OnDestroy, AfterViewInit
})
.toPromise();
break;
case 'B2B-Versand':
case "B2B-Versand":
availability = await this.availabilityService
.getB2bDeliveryAvailability({
item: {
@@ -424,7 +499,7 @@ export class CheckoutReviewComponent implements OnInit, OnDestroy, AfterViewInit
})
.toPromise();
break;
case 'Download':
case "Download":
availability = await this.availabilityService
.getDownloadAvailability({
item: {
@@ -463,7 +538,11 @@ export class CheckoutReviewComponent implements OnInit, OnDestroy, AfterViewInit
shoppingCartItemId: shoppingCartItem.id,
update: {
quantity,
availability: this.compareDeliveryAndCatalogPrice(updateAvailability, orderType, shoppingCartItemPrice),
availability: this.compareDeliveryAndCatalogPrice(
updateAvailability,
orderType,
shoppingCartItemPrice,
),
},
})
.toPromise();
@@ -483,8 +562,15 @@ export class CheckoutReviewComponent implements OnInit, OnDestroy, AfterViewInit
}
// Bei unbekannten Kunden und DIG Bestellung findet ein Vergleich der Preise statt
compareDeliveryAndCatalogPrice(availability: AvailabilityDTO, orderType: string, shoppingCartItemPrice: number) {
if (['Versand', 'DIG-Versand'].includes(orderType) && shoppingCartItemPrice < availability?.price?.value?.value) {
compareDeliveryAndCatalogPrice(
availability: AvailabilityDTO,
orderType: string,
shoppingCartItemPrice: number,
) {
if (
["Versand", "DIG-Versand"].includes(orderType) &&
shoppingCartItemPrice < availability?.price?.value?.value
) {
return {
...availability,
price: {
@@ -507,7 +593,10 @@ export class CheckoutReviewComponent implements OnInit, OnDestroy, AfterViewInit
.pipe(
first(),
switchMap((customerFeatures) => {
return this.domainCheckoutService.canSetCustomer({ processId, customerFeatures });
return this.domainCheckoutService.canSetCustomer({
processId,
customerFeatures,
});
}),
)
.toPromise();
@@ -524,24 +613,31 @@ export class CheckoutReviewComponent implements OnInit, OnDestroy, AfterViewInit
this._purchaseOptionsModalService.open({
processId: this.applicationService.activatedProcessId,
items: shoppingCartItems,
type: 'update',
type: "update",
});
}
async changeAddress() {
const processId = this.applicationService.activatedProcessId;
const customer = await this.domainCheckoutService.getBuyer({ processId }).pipe(first()).toPromise();
const customer = await this.domainCheckoutService
.getBuyer({ processId })
.pipe(first())
.toPromise();
if (!customer) {
this.navigateToCustomerSearch(processId);
return;
}
const customerId = customer.source;
const nav = this._customerSearchNavigation.detailsRoute({ processId, customerId });
const nav = this._customerSearchNavigation.detailsRoute({
processId,
customerId,
});
this.router.navigate(nav.path);
}
async order() {
const shoppingCartItemsWithoutOrderType = await this.shoppingCartItemsWithoutOrderType$.pipe(first()).toPromise();
const shoppingCartItemsWithoutOrderType =
await this.shoppingCartItemsWithoutOrderType$.pipe(first()).toPromise();
if (shoppingCartItemsWithoutOrderType?.length > 0) {
this.showPurchasingListModal(shoppingCartItemsWithoutOrderType);
@@ -549,7 +645,10 @@ export class CheckoutReviewComponent implements OnInit, OnDestroy, AfterViewInit
}
const processId = this.applicationService.activatedProcessId;
const customer = await this.domainCheckoutService.getBuyer({ processId }).pipe(first()).toPromise();
const customer = await this.domainCheckoutService
.getBuyer({ processId })
.pipe(first())
.toPromise();
if (!customer) {
this.navigateToCustomerSearch(processId);
} else {
@@ -557,33 +656,42 @@ export class CheckoutReviewComponent implements OnInit, OnDestroy, AfterViewInit
this.showOrderButtonSpinner = true;
// Ticket #3287 Um nur E-Mail und SMS Benachrichtigungen zu setzen und um alle anderen Benachrichtigungskanäle wie z.B. Brief zu deaktivieren
await this._store.onNotificationChange();
const orders = await this.domainCheckoutService.completeCheckout({ processId }).toPromise();
const orderIds = orders.map((order) => order.id).join(',');
const orders = await this.domainCheckoutService
.completeCheckout({ processId })
.toPromise();
const orderIds = orders.map((order) => order.id).join(",");
this._store.orderCompleted.next();
await this.patchProcess(processId);
await this._navigationService.getCheckoutSummaryPath({ processId, orderIds }).navigate();
await this._navigationService
.getCheckoutSummaryPath({ processId, orderIds })
.navigate();
} catch (error) {
const response = error?.error;
let message: string = response?.message ?? '';
let message: string = response?.message ?? "";
if (response?.invalidProperties && Object.values(response?.invalidProperties)?.length) {
message += `\n${Object.values(response.invalidProperties).join('\n')}`;
if (
response?.invalidProperties &&
Object.values(response?.invalidProperties)?.length
) {
message += `\n${Object.values(response.invalidProperties).join("\n")}`;
}
if (message?.length) {
this.uiModal.open({
content: UiMessageModalComponent,
title: 'Hinweis',
title: "Hinweis",
data: { message: message.trim() },
});
} else if (error) {
this.uiModal.error('Fehler beim abschließen der Bestellung', error);
this.uiModal.error("Fehler beim abschließen der Bestellung", error);
}
if (error.status === 409) {
this._store.orderCompleted.next();
await this.patchProcess(processId);
await this._navigationService.getCheckoutSummaryPath({ processId }).navigate();
await this._navigationService
.getCheckoutSummaryPath({ processId })
.navigate();
}
} finally {
this.showOrderButtonSpinner = false;
@@ -593,11 +701,14 @@ export class CheckoutReviewComponent implements OnInit, OnDestroy, AfterViewInit
}
async patchProcess(processId: number) {
const process = await this.applicationService.getProcessById$(processId).pipe(first()).toPromise();
const process = await this.applicationService
.getProcessById$(processId)
.pipe(first())
.toPromise();
if (process) {
this.applicationService.patchProcess(process.id, {
name: `${process.name} Bestellbestätigung`,
type: 'cart-checkout',
type: "cart-checkout",
});
}
}

View File

@@ -12,14 +12,24 @@
(focus)="clearHint(); focused.emit(true)"
(blur)="focused.emit(false)"
(keyup)="onKeyup($event)"
(keyup.enter)="tracker.trackEvent({ action: 'keyup enter', name: 'search' })"
(keyup.enter)="
tracker.trackEvent({ action: 'keyup enter', name: 'search' })
"
matomoTracker
#tracker="matomo"
matomoCategory="searchbox"
/>
<div *ngIf="showHint" class="searchbox-hint" (click)="focus()">{{ hint }}</div>
<div *ngIf="showHint" class="searchbox-hint" (click)="focus()">
{{ hint }}
</div>
</div>
<button (click)="clear(); focus()" tabindex="-1" *ngIf="input.value" class="searchbox-clear-btn" type="button">
<button
(click)="clear(); focus()"
tabindex="-1"
*ngIf="input.value"
class="searchbox-clear-btn"
type="button"
>
<shared-icon icon="close" [size]="32"></shared-icon>
</button>
<ng-container *ngIf="!loading">
@@ -27,7 +37,7 @@
tabindex="0"
class="searchbox-search-btn"
type="button"
*ngIf="!canScan"
*ngIf="!showScannerButton"
(click)="emitSearch()"
[disabled]="completeValue !== query"
matomoClickAction="click"
@@ -40,7 +50,7 @@
tabindex="0"
class="searchbox-scan-btn"
type="button"
*ngIf="canScan"
*ngIf="showScannerButton"
(click)="startScan()"
matomoClickAction="open"
matomoClickCategory="searchbox"

View File

@@ -31,7 +31,11 @@ import { EnvironmentService } from '@core/environment';
templateUrl: 'searchbox.component.html',
styleUrls: ['searchbox.component.scss'],
providers: [
{ provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => SearchboxComponent), multi: true },
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => SearchboxComponent),
multi: true,
},
],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: false,
@@ -54,10 +58,10 @@ export class SearchboxComponent
autocomplete: UiAutocompleteComponent;
@Input()
focusAfterViewInit: boolean = true;
focusAfterViewInit = true;
@Input()
placeholder: string = '';
placeholder = '';
private _query = '';
@@ -90,7 +94,7 @@ export class SearchboxComponent
scanner = false;
@Input()
hint: string = '';
hint = '';
@Input()
autocompleteValueSelector: (item: any) => string = (item: any) => item;
@@ -113,6 +117,10 @@ export class SearchboxComponent
return this.#env.isMobileDevice() && this.scanAdapterService?.isReady();
}
get showScannerButton() {
return this.canScan && !this.query;
}
get canClear() {
return !!this.query;
}
@@ -123,9 +131,9 @@ export class SearchboxComponent
subscriptions = new Subscription();
onChange = (_: any) => {};
onChange?: (_: any) => void;
onTouched = () => {};
onTouched?: () => void;
constructor(
private cdr: ChangeDetectorRef,
@@ -186,12 +194,12 @@ export class SearchboxComponent
}
}
setQuery(query: string, emitEvent: boolean = true, complete?: boolean) {
setQuery(query: string, emitEvent = true, complete?: boolean) {
this._query = query;
if (emitEvent) {
this.queryChange.emit(query);
this.onChange(query);
this.onTouched();
this.onChange?.(query);
this.onTouched?.();
}
if (complete) {
this.completeValue = query;
@@ -227,7 +235,9 @@ export class SearchboxComponent
handleArrowUpDownEvent(event: KeyboardEvent) {
this.autocomplete?.handleKeyboardEvent(event);
if (this.autocomplete?.activeItem) {
const query = this.autocompleteValueSelector(this.autocomplete.activeItem.item);
const query = this.autocompleteValueSelector(
this.autocomplete.activeItem.item,
);
this.setQuery(query, false, false);
}
}

View File

@@ -5,6 +5,31 @@
@import "./scss/components";
/* Scanner Fullscreen Styles */
.full-screen-scanner {
max-width: 100vw !important;
max-height: 100vh !important;
width: 100vw !important;
height: 100vh !important;
.scanner-component {
width: 100%;
height: 100%;
}
}
/* Override CDK overlay container styles for scanner */
.cdk-overlay-container {
.full-screen-scanner {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
margin: 0;
}
}
@import "./scss/root";
@import "./scss/customer";
@import "./scss/branch";

View File

@@ -1,6 +1,7 @@
@use "../../../libs/ui/buttons/src/buttons.scss";
@use "../../../libs/ui/datepicker/src/datepicker.scss";
@use "../../../libs/ui/dialog/src/dialog.scss";
@use "../../../libs/ui/input-controls/src/input-controls.scss";
@use "../../../libs/ui/menu/src/menu.scss";
@use "../../../libs/ui/progress-bar/src/lib/progress-bar.scss";
@use '../../../libs/ui/buttons/src/buttons.scss';
@use '../../../libs/ui/datepicker/src/datepicker.scss';
@use '../../../libs/ui/dialog/src/dialog.scss';
@use '../../../libs/ui/input-controls/src/input-controls.scss';
@use '../../../libs/ui/menu/src/menu.scss';
@use '../../../libs/ui/progress-bar/src/lib/progress-bar.scss';
@use '../../../libs/ui/tooltip/src/tooltip.scss';

View File

@@ -1,5 +1,14 @@
import { argsToTemplate, type Meta, type StoryObj, moduleMetadata } from '@storybook/angular';
import { IconButtonColor, IconButtonSize, IconButtonComponent } from '@isa/ui/buttons';
import {
argsToTemplate,
type Meta,
type StoryObj,
moduleMetadata,
} from '@storybook/angular';
import {
IconButtonColor,
IconButtonSize,
IconButtonComponent,
} from '@isa/ui/buttons';
import { IsaIcons } from '@isa/icons';
import { NgIconComponent, provideIcons } from '@ng-icons/core';
@@ -28,7 +37,12 @@ const meta: Meta<UiIconButtonComponentInputs> = {
},
color: {
control: { type: 'select' },
options: ['brand', 'primary', 'secondary', 'tertiary'] as IconButtonColor[],
options: [
'brand',
'primary',
'secondary',
'tertiary',
] as IconButtonColor[],
description: 'Color style of the button',
},
size: {
@@ -54,8 +68,8 @@ const meta: Meta<UiIconButtonComponentInputs> = {
},
render: (args) => ({
props: args,
template: `<button uiIconButton ${argsToTemplate(args, { exclude: ['icon'] })} >
<ng-icon name="${args.icon}"></ng-icon>
template: `<button uiIconButton name="${args.icon}" ${argsToTemplate(args, { exclude: ['icon'] })} >
</button>`,
}),
};

View File

@@ -0,0 +1,49 @@
import { Component, Input } from '@angular/core';
import { ButtonComponent } from '@isa/ui/buttons';
import { ExpandableDirectives } from '@isa/ui/expandable';
import { argsToTemplate, type Meta, type StoryObj } from '@storybook/angular';
@Component({
selector: 'app-expandable-directives',
template: `
<div uiExpandable [(uiExpandable)]="isExpanded" class="border border-black">
<button uiButton uiExpandableTrigger>Toggle</button>
<div class="bg-red-200" *uiExpanded>Expanded Content</div>
<div class="bg-blue-200" *uiCollapsed>Collapsed Content</div>
</div>
`,
standalone: true,
imports: [ExpandableDirectives, ButtonComponent],
})
class ExpandableDirectivesComponent {
@Input()
isExpanded = false;
}
const meta: Meta<ExpandableDirectivesComponent> = {
title: 'ui/expandable/Expandable',
component: ExpandableDirectivesComponent,
argTypes: {
isExpanded: {
control: 'boolean',
description: 'Controls the expanded state of the section',
table: {
defaultValue: { summary: 'false' },
},
},
},
args: {
isExpanded: false,
},
render: (args) => ({
props: args,
template: `<app-expandable-directives ${argsToTemplate(args)}></app-expandable-directives>`,
}),
};
export default meta;
type Story = StoryObj<ExpandableDirectivesComponent>;
export const Default: Story = {
args: {},
};

View File

@@ -1,24 +1,72 @@
import { type Meta, type StoryObj } from '@storybook/angular';
import { TextFieldComponent } from '@isa/ui/input-controls';
import { moduleMetadata, type Meta, type StoryObj } from '@storybook/angular';
import {
TextFieldClearComponent,
TextFieldComponent,
TextFieldContainerComponent,
TextFieldErrorsComponent,
} from '@isa/ui/input-controls';
const meta: Meta<TextFieldComponent> = {
interface TextFieldStoryProps {
showClear: boolean;
showError1: boolean;
showError2: boolean;
errorText1: string;
errorText2: string;
}
const meta: Meta<TextFieldStoryProps> = {
component: TextFieldComponent,
title: 'ui/input-controls/TextField',
argTypes: {},
argTypes: {
showClear: { control: 'boolean', name: 'Show Clear Button' },
showError1: { control: 'boolean', name: 'Show Error 1' },
showError2: { control: 'boolean', name: 'Show Error 2' },
errorText1: { control: 'text', name: 'Error 1 Text' },
errorText2: { control: 'text', name: 'Error 2 Text' },
},
args: {
showClear: true,
showError1: false,
showError2: false,
errorText1: 'Eingabe ungültig',
errorText2: 'Error Beispiel 2',
},
decorators: [
moduleMetadata({
imports: [
TextFieldClearComponent,
TextFieldContainerComponent,
TextFieldErrorsComponent,
],
}),
],
render: (args) => ({
props: args,
template: `
<ui-text-field-container>
<ui-text-field>
<input type="text" placeholder="Enter your name" />
<input type="text" placeholder="Enter your name" />
<ui-text-field-clear *ngIf="showClear"></ui-text-field-clear>
</ui-text-field>
<ui-text-field-errors>
<span *ngIf="showError1">{{ errorText1 }}</span>
<span *ngIf="showError2">{{ errorText2 }}</span>
</ui-text-field-errors>
</ui-text-field-container>
`,
}),
};
export default meta;
type Story = StoryObj<TextFieldComponent>;
type Story = StoryObj<TextFieldStoryProps>;
export const Default: Story = {
args: {},
args: {
showClear: true,
showError1: false,
showError2: false,
errorText1: 'Eingabe ungültig',
errorText2: 'Error Beispiel 2',
},
};

View File

@@ -8,8 +8,6 @@ import {
UiSearchBarClearComponent,
UiSearchBarComponent,
} from '@isa/ui/search-bar';
import { NgIconComponent, provideIcons } from '@ng-icons/core';
import { isaActionSearch } from '@isa/icons';
import { FormControl, FormsModule, ReactiveFormsModule } from '@angular/forms';
import { IconButtonComponent } from '@isa/ui/buttons';
@@ -29,11 +27,9 @@ const meta: Meta<UiSearchBarComponentInputs> = {
imports: [
IconButtonComponent,
UiSearchBarClearComponent,
NgIconComponent,
FormsModule,
ReactiveFormsModule,
],
providers: [provideIcons({ isaActionSearch })],
}),
],
title: 'ui/search-bar/SearchBar',
@@ -62,10 +58,10 @@ const meta: Meta<UiSearchBarComponentInputs> = {
if (args.appearance === 'main') {
button =
'<button type="submit" uiIconButton color="brand"><ng-icon name="isaActionSearch"></ng-icon></button>';
'<button type="submit" uiIconButton name="isaActionSearch" color="brand"></button>';
} else if (args.appearance === 'results') {
button =
'<button type="submit" uiIconButton prefix color="neutral"><ng-icon name="isaActionSearch"></ng-icon></button>';
'<button type="submit" uiIconButton prefix name="isaActionSearch" color="neutral"></button>';
}
return {

View File

@@ -0,0 +1,39 @@
import { type Meta, type StoryObj, argsToTemplate } from '@storybook/angular';
import { TooltipDirective } from '@isa/ui/tooltip';
const meta: Meta<TooltipDirective> = {
title: 'UI/Tooltip',
component: TooltipDirective,
argTypes: {
title: { control: 'text' },
content: { control: 'text' },
triggerOn: {
control: 'multi-select',
options: ['click', 'hover', 'focus'],
},
},
args: {
title: 'Tooltip Title',
content: 'This is the tooltip content.',
triggerOn: ['click', 'hover', 'focus'],
},
render: (args) => ({
props: args,
template: `
<button uiTooltip ${argsToTemplate(args)} >
Hover or click me
</button>
`,
}),
};
export default meta;
type Story = StoryObj<TooltipDirective>;
export const Default: Story = {
args: {
title: 'Default Tooltip',
content: 'This is the default tooltip content.',
triggerOn: ['hover', 'click'],
},
};

View File

@@ -244,14 +244,15 @@ export { ResponseArgsOfIEnumerableOfReceiptDTO } from './models/response-args-of
export { GenerateCollectiveReceiptsArgs } from './models/generate-collective-receipts-args';
export { ResponseArgsOfIEnumerableOfString } from './models/response-args-of-ienumerable-of-string';
export { DateRange } from './models/date-range';
export { ResponseArgsOfString } from './models/response-args-of-string';
export { ListResponseArgsOfReceiptItemTaskListItemDTO } from './models/list-response-args-of-receipt-item-task-list-item-dto';
export { ResponseArgsOfIEnumerableOfReceiptItemTaskListItemDTO } from './models/response-args-of-ienumerable-of-receipt-item-task-list-item-dto';
export { ListResponseArgsOfReceiptListItemDTO } from './models/list-response-args-of-receipt-list-item-dto';
export { ResponseArgsOfIEnumerableOfReceiptListItemDTO } from './models/response-args-of-ienumerable-of-receipt-list-item-dto';
export { ReceiptListItemDTO } from './models/receipt-list-item-dto';
export { ListResponseArgsOfReceiptItemListItemDTO } from './models/list-response-args-of-receipt-item-list-item-dto';
export { ResponseArgsOfIEnumerableOfReceiptItemListItemDTO } from './models/response-args-of-ienumerable-of-receipt-item-list-item-dto';
export { ReceiptItemListItemDTO } from './models/receipt-item-list-item-dto';
export { ListResponseArgsOfReceiptItemTaskListItemDTO } from './models/list-response-args-of-receipt-item-task-list-item-dto';
export { ResponseArgsOfIEnumerableOfReceiptItemTaskListItemDTO } from './models/response-args-of-ienumerable-of-receipt-item-task-list-item-dto';
export { ResponseArgsOfIEnumerableOfValueTupleOfLongAndReceiptTypeAndEntityDTOContainerOfReceiptDTO } from './models/response-args-of-ienumerable-of-value-tuple-of-long-and-receipt-type-and-entity-dtocontainer-of-receipt-dto';
export { ValueTupleOfLongAndReceiptTypeAndEntityDTOContainerOfReceiptDTO } from './models/value-tuple-of-long-and-receipt-type-and-entity-dtocontainer-of-receipt-dto';
export { ReceiptOrderItemSubsetReferenceValues } from './models/receipt-order-item-subset-reference-values';

View File

@@ -6,6 +6,7 @@ import { EntityDTOContainerOfBranchDTO } from './entity-dtocontainer-of-branch-d
import { KeyValueDTOOfStringAndString } from './key-value-dtoof-string-and-string';
import { EntityDTOContainerOfReceiptItemDTO } from './entity-dtocontainer-of-receipt-item-dto';
import { EntityDTOContainerOfLabelDTO } from './entity-dtocontainer-of-label-dto';
import { LinkedRecordDTO } from './linked-record-dto';
import { EntityDTOContainerOfOrderDTO } from './entity-dtocontainer-of-order-dto';
import { EntityDTOContainerOfPaymentDTO } from './entity-dtocontainer-of-payment-dto';
import { PaymentInfoDTO } from './payment-info-dto';
@@ -57,6 +58,11 @@ export interface ReceiptDTO extends EntityDTOBaseOfReceiptDTOAndIReceipt{
*/
label?: EntityDTOContainerOfLabelDTO;
/**
* Verknüpfte Datensätze (readonly)
*/
linkedRecords?: Array<LinkedRecordDTO>;
/**
* Bestellung
*/

View File

@@ -45,11 +45,26 @@ export interface ReceiptItemTaskListItemDTO {
*/
features?: {[key: string]: string};
/**
* Details
*/
handlingDetails?: string;
/**
* Reason / Grund
*/
handlingReason?: string;
/**
* Task ID
*/
id?: number;
/**
* Item condition / Artikelzustand
*/
itemCondition?: string;
/**
* Aufgabentyp
*/

View File

@@ -0,0 +1,9 @@
/* tslint:disable */
import { ResponseArgs } from './response-args';
export interface ResponseArgsOfString extends ResponseArgs{
/**
* Wert
*/
result?: string;
}

View File

@@ -16,10 +16,11 @@ import { ResponseArgsOfIEnumerableOfReceiptDTO } from '../models/response-args-o
import { GenerateCollectiveReceiptsArgs } from '../models/generate-collective-receipts-args';
import { ResponseArgsOfIEnumerableOfString } from '../models/response-args-of-ienumerable-of-string';
import { DateRange } from '../models/date-range';
import { ListResponseArgsOfReceiptListItemDTO } from '../models/list-response-args-of-receipt-list-item-dto';
import { QueryTokenDTO } from '../models/query-token-dto';
import { ListResponseArgsOfReceiptItemListItemDTO } from '../models/list-response-args-of-receipt-item-list-item-dto';
import { ResponseArgsOfString } from '../models/response-args-of-string';
import { ListResponseArgsOfReceiptItemTaskListItemDTO } from '../models/list-response-args-of-receipt-item-task-list-item-dto';
import { QueryTokenDTO } from '../models/query-token-dto';
import { ListResponseArgsOfReceiptListItemDTO } from '../models/list-response-args-of-receipt-list-item-dto';
import { ListResponseArgsOfReceiptItemListItemDTO } from '../models/list-response-args-of-receipt-item-list-item-dto';
import { ResponseArgsOfIEnumerableOfValueTupleOfLongAndReceiptTypeAndEntityDTOContainerOfReceiptDTO } from '../models/response-args-of-ienumerable-of-value-tuple-of-long-and-receipt-type-and-entity-dtocontainer-of-receipt-dto';
import { ReceiptOrderItemSubsetReferenceValues } from '../models/receipt-order-item-subset-reference-values';
@Injectable({
@@ -33,6 +34,8 @@ class ReceiptService extends __BaseService {
static readonly ReceiptSetReceiptItemTaskToNOKPath = '/receipt/item/task/{taskId}/nok';
static readonly ReceiptGenerateCollectiveReceiptsPath = '/receipt/collectivereceipts';
static readonly ReceiptGenerateCollectiveReceiptsSimulationSummaryPath = '/receipt/collectivereceipts/simulationsummary';
static readonly ReceiptPostRuecknahmebelegPath = '/ruecknahmebeleg/{receiptId}';
static readonly ReceiptQueryReceiptItemTasksPath = '/receipt/item/task/s';
static readonly ReceiptQueryReceiptPath = '/receipt/s';
static readonly ReceiptQueryReceiptItemPath = '/receipt/item/s';
static readonly ReceiptCreateShippingNotePath = '/receipt/shippingnote/fromorder';
@@ -40,7 +43,6 @@ class ReceiptService extends __BaseService {
static readonly ReceiptCreateInvoicePath = '/receipt/invoice/fromorder';
static readonly ReceiptCreateInvoice2Path = '/receipt/invoice/fromitems';
static readonly ReceiptCreateReturnReceiptPath = '/receipt/return-receipt';
static readonly ReceiptQueryReceiptItemTasksPath = '/receipt/item/task/s';
static readonly ReceiptReceiptItemTaskCompletedPath = '/receipt/item/task/{taskId}/completed';
static readonly ReceiptGetReceiptsByOrderItemSubsetPath = '/order/orderitem/orderitemsubset/receipts';
@@ -307,6 +309,74 @@ class ReceiptService extends __BaseService {
);
}
/**
* @param receiptId undefined
*/
ReceiptPostRuecknahmebelegResponse(receiptId: number): __Observable<__StrictHttpResponse<ResponseArgsOfString>> {
let __params = this.newParams();
let __headers = new HttpHeaders();
let __body: any = null;
let req = new HttpRequest<any>(
'POST',
this.rootUrl + `/ruecknahmebeleg/${encodeURIComponent(String(receiptId))}`,
__body,
{
headers: __headers,
params: __params,
responseType: 'json'
});
return this.http.request<any>(req).pipe(
__filter(_r => _r instanceof HttpResponse),
__map((_r) => {
return _r as __StrictHttpResponse<ResponseArgsOfString>;
})
);
}
/**
* @param receiptId undefined
*/
ReceiptPostRuecknahmebeleg(receiptId: number): __Observable<ResponseArgsOfString> {
return this.ReceiptPostRuecknahmebelegResponse(receiptId).pipe(
__map(_r => _r.body as ResponseArgsOfString)
);
}
/**
* @param queryToken undefined
*/
ReceiptQueryReceiptItemTasksResponse(queryToken: QueryTokenDTO): __Observable<__StrictHttpResponse<ListResponseArgsOfReceiptItemTaskListItemDTO>> {
let __params = this.newParams();
let __headers = new HttpHeaders();
let __body: any = null;
__body = queryToken;
let req = new HttpRequest<any>(
'POST',
this.rootUrl + `/receipt/item/task/s`,
__body,
{
headers: __headers,
params: __params,
responseType: 'json'
});
return this.http.request<any>(req).pipe(
__filter(_r => _r instanceof HttpResponse),
__map((_r) => {
return _r as __StrictHttpResponse<ListResponseArgsOfReceiptItemTaskListItemDTO>;
})
);
}
/**
* @param queryToken undefined
*/
ReceiptQueryReceiptItemTasks(queryToken: QueryTokenDTO): __Observable<ListResponseArgsOfReceiptItemTaskListItemDTO> {
return this.ReceiptQueryReceiptItemTasksResponse(queryToken).pipe(
__map(_r => _r.body as ListResponseArgsOfReceiptItemTaskListItemDTO)
);
}
/**
* Belege
* @param params The `ReceiptService.ReceiptQueryReceiptParams` containing the following parameters:
@@ -647,42 +717,6 @@ class ReceiptService extends __BaseService {
);
}
/**
* Suche nach Bestellpostenstatus-Aufgaben
* @param queryToken Suchkriterien
*/
ReceiptQueryReceiptItemTasksResponse(queryToken: QueryTokenDTO): __Observable<__StrictHttpResponse<ListResponseArgsOfReceiptItemTaskListItemDTO>> {
let __params = this.newParams();
let __headers = new HttpHeaders();
let __body: any = null;
__body = queryToken;
let req = new HttpRequest<any>(
'POST',
this.rootUrl + `/receipt/item/task/s`,
__body,
{
headers: __headers,
params: __params,
responseType: 'json'
});
return this.http.request<any>(req).pipe(
__filter(_r => _r instanceof HttpResponse),
__map((_r) => {
return _r as __StrictHttpResponse<ListResponseArgsOfReceiptItemTaskListItemDTO>;
})
);
}
/**
* Suche nach Bestellpostenstatus-Aufgaben
* @param queryToken Suchkriterien
*/
ReceiptQueryReceiptItemTasks(queryToken: QueryTokenDTO): __Observable<ListResponseArgsOfReceiptItemTaskListItemDTO> {
return this.ReceiptQueryReceiptItemTasksResponse(queryToken).pipe(
__map(_r => _r.body as ListResponseArgsOfReceiptItemTaskListItemDTO)
);
}
/**
* Aufgabe auf erledigt setzen
* @param taskId undefined

View File

@@ -11,6 +11,7 @@ import { ResponseArgs } from '../models/response-args';
import { PrintRequestOfIEnumerableOfLong } from '../models/print-request-of-ienumerable-of-long';
import { PrintRequestOfIEnumerableOfDisplayOrderDTO } from '../models/print-request-of-ienumerable-of-display-order-dto';
import { PrintRequestOfIEnumerableOfPriceQRCodeDTO } from '../models/print-request-of-ienumerable-of-price-qrcode-dto';
import { PrintRequestOfLong } from '../models/print-request-of-long';
@Injectable({
providedIn: 'root',
})
@@ -25,6 +26,8 @@ class OMSPrintService extends __BaseService {
static readonly OMSPrintReturnReceiptPath = '/print/return-receipt';
static readonly OMSPrintKleinbetragsrechnungPath = '/print/kleinbetragsrechnung';
static readonly OMSPrintKleinbetragsrechnungPdfPath = '/print/kleinbetragsrechnung/{receiptId}/pdf';
static readonly OMSPrintTolinoRetourenscheinPath = '/print/tolino-retourenschein';
static readonly OMSPrintTolinoRetourenscheinPdfPath = '/print/tolino-retourenschein/{receipItemtId}/pdf';
constructor(
config: __Configuration,
@@ -392,6 +395,78 @@ class OMSPrintService extends __BaseService {
__map(_r => _r.body as Blob)
);
}
/**
* Tolino Retourenschein
* @param data Retourenbelegpostion PKs
*/
OMSPrintTolinoRetourenscheinResponse(data: PrintRequestOfLong): __Observable<__StrictHttpResponse<ResponseArgs>> {
let __params = this.newParams();
let __headers = new HttpHeaders();
let __body: any = null;
__body = data;
let req = new HttpRequest<any>(
'POST',
this.rootUrl + `/print/tolino-retourenschein`,
__body,
{
headers: __headers,
params: __params,
responseType: 'json'
});
return this.http.request<any>(req).pipe(
__filter(_r => _r instanceof HttpResponse),
__map((_r) => {
return _r as __StrictHttpResponse<ResponseArgs>;
})
);
}
/**
* Tolino Retourenschein
* @param data Retourenbelegpostion PKs
*/
OMSPrintTolinoRetourenschein(data: PrintRequestOfLong): __Observable<ResponseArgs> {
return this.OMSPrintTolinoRetourenscheinResponse(data).pipe(
__map(_r => _r.body as ResponseArgs)
);
}
/**
* Tolino Retourenschein PDF
* @param receipItemtId Retourenbelegposition PK
*/
OMSPrintTolinoRetourenscheinPdfResponse(receipItemtId: number): __Observable<__StrictHttpResponse<Blob>> {
let __params = this.newParams();
let __headers = new HttpHeaders();
let __body: any = null;
let req = new HttpRequest<any>(
'GET',
this.rootUrl + `/print/tolino-retourenschein/${encodeURIComponent(String(receipItemtId))}/pdf`,
__body,
{
headers: __headers,
params: __params,
responseType: 'blob'
});
return this.http.request<any>(req).pipe(
__filter(_r => _r instanceof HttpResponse),
__map((_r) => {
return _r as __StrictHttpResponse<Blob>;
})
);
}
/**
* Tolino Retourenschein PDF
* @param receipItemtId Retourenbelegposition PK
*/
OMSPrintTolinoRetourenscheinPdf(receipItemtId: number): __Observable<Blob> {
return this.OMSPrintTolinoRetourenscheinPdfResponse(receipItemtId).pipe(
__map(_r => _r.body as Blob)
);
}
}
module OMSPrintService {

View File

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

View File

@@ -0,0 +1,2 @@
export * from './take-until-aborted';
export * from './take-unitl-keydown';

View File

@@ -0,0 +1,14 @@
import { filter, fromEvent, Observable, takeUntil } from 'rxjs';
export const takeUntilKeydown =
<T>(key: string) =>
(source: Observable<T>): Observable<T> => {
const keydownEvent$ = fromEvent<KeyboardEvent>(document, 'keydown').pipe(
// Filter for the specific key
filter((event) => event.key === key),
);
return source.pipe(takeUntil(keydownEvent$));
};
export const takeUntilKeydownEscape = <T>() => takeUntilKeydown<T>('Escape');

View File

@@ -0,0 +1,49 @@
import { Observable } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
/**
* Creates an Observable that emits when an AbortSignal is aborted.
*
* @param signal - The AbortSignal instance to listen to
* @returns An Observable that emits and completes when the signal is aborted
*/
export const fromAbortSignal = (signal: AbortSignal): Observable<void> => {
// If the signal is already aborted, return an Observable that immediately completes
if (signal.aborted) {
return new Observable<void>((subscriber) => {
subscriber.complete();
});
}
// Otherwise, create an Observable from the abort event
return new Observable<void>((subscriber) => {
const abortHandler = () => {
subscriber.next();
};
// Listen for the 'abort' event
signal.addEventListener('abort', abortHandler);
// Clean up the event listener when the Observable is unsubscribed
return () => {
signal.removeEventListener('abort', abortHandler);
};
});
};
/**
* Operator that completes the source Observable when the provided AbortSignal is aborted.
* Similar to takeUntil, but works with AbortSignal instead of an Observable.
*
* @param signal - The AbortSignal instance that will trigger completion when aborted
* @returns An Observable that completes when the source completes or when the signal is aborted
*/
export const takeUntilAborted =
<T>(signal: AbortSignal) =>
(source: Observable<T>): Observable<T> => {
// Convert the AbortSignal to an Observable
const aborted$ = fromAbortSignal(signal);
// Use the standard takeUntil operator with our abort Observable
return source.pipe(takeUntil(aborted$));
};

View File

@@ -1,2 +1,3 @@
export * from './lib/components';
export * from './lib/models';
export * from './lib/services';

View File

@@ -0,0 +1 @@
export * from './print-button';

View File

@@ -0,0 +1 @@
export * from './print-button.component';

View File

@@ -0,0 +1,10 @@
<button
data-what="button"
data-which="print"
class="self-start"
(click)="print()"
uiInfoButton
>
<span uiInfoButtonLabel><ng-content></ng-content></span>
<ng-icon name="isaActionPrinter" uiInfoButtonIcon></ng-icon>
</button>

View File

@@ -0,0 +1,177 @@
import { createComponentFactory, Spectator } from '@ngneat/spectator/jest';
import { PrintButtonComponent } from './print-button.component';
import { PrintService } from '../../services';
import { PrinterType, Printer } from '../../models';
import { InfoButtonComponent } from '@isa/ui/buttons';
import { NgIcon } from '@ng-icons/core';
describe('PrintButtonComponent', () => {
let spectator: Spectator<PrintButtonComponent>;
let printServiceMock: jest.Mocked<PrintService>;
// Mock printer for testing
const mockPrinter: Printer = {
key: 'printer-1',
value: 'Test Printer',
selected: true,
enabled: true,
};
// Mock print function that returns a Promise
const mockPrintFn = jest.fn().mockResolvedValue(undefined);
const createComponent = createComponentFactory({
component: PrintButtonComponent,
imports: [InfoButtonComponent, NgIcon],
providers: [
{
provide: NgIcon,
useValue: {
name: 'isaActionPrinter',
},
},
],
mocks: [PrintService],
detectChanges: false,
});
// We no longer need a separate host factory here
beforeEach(() => {
// Create the component with required inputs
spectator = createComponent({
props: {
printerType: PrinterType.LABEL,
printFn: mockPrintFn,
directPrint: undefined,
},
});
// Get the mocked print service
printServiceMock = spectator.inject(
PrintService,
) as jest.Mocked<PrintService>;
// Setup mock implementation for print service
printServiceMock.print.mockResolvedValue({ printer: mockPrinter });
// Reset the print function mock before each test
mockPrintFn.mockClear();
// Trigger change detection
spectator.detectChanges();
});
it('should create the component', () => {
expect(spectator.component).toBeTruthy();
});
it('should render a button with the correct data attributes', () => {
const button = spectator.query('button');
expect(button).toExist();
expect(button).toHaveAttribute('data-what', 'button');
expect(button).toHaveAttribute('data-which', 'print');
});
it('should render the printer icon', () => {
const icon = spectator.query('ng-icon');
expect(icon).toExist();
expect(icon).toHaveAttribute('name', 'isaActionPrinter');
});
it('should call print service with correct parameters when button is clicked', async () => {
// Act
await spectator.click('button');
// Assert
expect(printServiceMock.print).toHaveBeenCalledTimes(1);
expect(printServiceMock.print).toHaveBeenCalledWith({
printerType: PrinterType.LABEL,
printFn: mockPrintFn,
directPrint: undefined,
});
});
it('should set printing signal to true during print operation and false afterward', async () => {
// Setup spy to monitor signal changes
jest.spyOn(spectator.component.printing, 'set');
// Act
await spectator.click('button');
// Assert
expect(spectator.component.printing.set).toHaveBeenNthCalledWith(1, true);
expect(spectator.component.printing.set).toHaveBeenNthCalledWith(2, false);
});
it('should call the provided printFn via the print service', async () => {
// Setup the mock print service to actually call the provided function
printServiceMock.print.mockImplementationOnce(async ({ printFn }) => {
await printFn(mockPrinter);
return { printer: mockPrinter };
});
// Act
await spectator.click('button');
// Assert
expect(mockPrintFn).toHaveBeenCalledTimes(1);
expect(mockPrintFn).toHaveBeenCalledWith(mockPrinter);
});
it('should pass directPrint parameter correctly to print service', async () => {
// Arrange
spectator.setInput('directPrint', true);
spectator.detectChanges();
// Act
await spectator.click('button');
// Assert
expect(printServiceMock.print).toHaveBeenCalledWith(
expect.objectContaining({ directPrint: true }),
);
// Test with false value
spectator.setInput('directPrint', false);
spectator.detectChanges();
printServiceMock.print.mockClear();
await spectator.click('button');
expect(printServiceMock.print).toHaveBeenCalledWith(
expect.objectContaining({ directPrint: false }),
);
});
it('should handle different printer types correctly', async () => {
// Arrange
spectator.setInput('printerType', PrinterType.OFFICE);
spectator.detectChanges();
// Act
await spectator.click('button');
// Assert
expect(printServiceMock.print).toHaveBeenCalledWith(
expect.objectContaining({ printerType: PrinterType.OFFICE }),
);
});
it('should handle print errors and reset printing state', async () => {
// Arrange
const testError = new Error('Print failed');
printServiceMock.print.mockRejectedValueOnce(testError);
// Act
await spectator.click('button');
// Assert
// Verify that the print service was called
expect(printServiceMock.print).toHaveBeenCalled();
// Verify that the printing state was reset to false after the error
expect(spectator.component.printing()).toBe(false);
});
it('should have a content projection area', () => {
// We cannot test ng-content directly in a regular component test
// Instead, let's verify the structure that would contain the content
const buttonSpan = spectator.query('button span');
expect(buttonSpan).toExist();
expect(buttonSpan).toHaveAttribute('uiInfoButtonLabel');
});
});

View File

@@ -0,0 +1,91 @@
import {
ChangeDetectionStrategy,
Component,
inject,
input,
signal,
} from '@angular/core';
import { InfoButtonComponent } from '@isa/ui/buttons';
import { Printer, PrinterType } from '../../models';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { isaActionPrinter } from '@isa/icons';
import { PrintService } from '../../services';
import { logger } from '@isa/core/logging';
export type PrintFn = (printer: Printer) => PromiseLike<void>;
/**
* A reusable button component that provides print functionality for the application.
* It displays a button with a printer icon that initiates the printing process when clicked.
*
* The component handles the printing state and communicates with the PrintService
* to perform printing operations with the appropriate printer type.
*
* @example
* <common-print-button
* [printerType]="PrinterType.LABEL"
* [printFn]="(printer) => myPrintingAction(printer)"
* [directPrint]="true">
* Print Label
* </common-print-button>
*/
@Component({
selector: 'common-print-button',
templateUrl: './print-button.component.html',
styleUrls: ['./print-button.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [InfoButtonComponent, NgIcon],
providers: [provideIcons({ isaActionPrinter })],
})
export class PrintButtonComponent {
#logger = logger(() => ({ component: 'PrintButtonComponent' }));
#printService = inject(PrintService);
/**
* The type of printer to use for the print operation.
* Must be one of the values from the PrinterType enum.
*/
printerType = input.required<PrinterType>();
/**
* Function that will be called to execute the actual printing logic.
* This function should accept a Printer object and return a Promise.
*/
printFn = input.required<PrintFn>();
/**
* Controls whether to print directly without showing a printer selection dialog.
* - If true: Always prints directly without dialog.
* - If false: Always shows the printer selection dialog.
* - If undefined: Shows dialog only on mobile devices or when no default printer is selected.
*/
directPrint = input<boolean | undefined>();
/**
* Signal that tracks the current printing state.
* Used to manage UI state changes during the print operation.
*/
printing = signal(false);
/**
* Initiates the printing process when the button is clicked.
* Sets the printing signal to true during the operation and back to false when completed.
* Handles any errors that occur during printing by logging them to the logger.
*
* @returns A promise that resolves when the print operation completes.
*/
async print() {
this.printing.set(true);
try {
await this.#printService.print({
printerType: this.printerType(),
printFn: this.printFn(),
directPrint: this.directPrint(),
});
} catch (error) {
this.#logger.error('Print operation failed', { error });
}
this.printing.set(false);
}
}

View File

@@ -1,290 +0,0 @@
import { createComponentFactory, Spectator } from '@ngneat/spectator/jest';
import {
PrintDialogComponent,
PrinterDialogData,
} from './print-dialog.component';
import { ButtonComponent } from '@isa/ui/buttons';
import { ListboxDirective, ListboxItemDirective } from '@isa/ui/input-controls';
import { MockComponent, MockDirective } from 'ng-mocks';
import { Printer } from '../models';
import { DIALOG_DATA, DialogRef } from '@angular/cdk/dialog';
describe('PrintDialogComponent', () => {
let spectator: Spectator<PrintDialogComponent>;
let component: PrintDialogComponent;
// Mock printers for testing
const mockPrinters: Printer[] = [
{
key: 'printer1',
value: 'Printer 1',
selected: false,
enabled: true,
description: 'First test printer',
},
{
key: 'printer2',
value: 'Printer 2',
selected: true,
enabled: true,
description: 'Second test printer',
},
{
key: 'printer3',
value: 'Printer 3',
selected: false,
enabled: false,
description: 'Disabled test printer',
},
];
// Mock print function
const mockPrintFn = jest.fn().mockResolvedValue(undefined);
// Default dialog data
const defaultData: PrinterDialogData = {
printers: mockPrinters,
print: mockPrintFn,
};
// Mock DialogRef
const mockDialogRef = {
close: jest.fn(),
};
const createComponent = createComponentFactory({
component: PrintDialogComponent,
declarations: [
MockComponent(ButtonComponent),
MockDirective(ListboxDirective),
MockDirective(ListboxItemDirective),
],
providers: [
{ provide: DialogRef, useValue: mockDialogRef },
{ provide: DIALOG_DATA, useValue: defaultData },
],
detectChanges: false,
});
beforeEach(() => {
// Reset mocks
mockPrintFn.mockClear();
mockDialogRef.close.mockClear();
// Create component without providing data prop since we provide it via DIALOG_DATA
spectator = createComponent();
component = spectator.component;
spectator.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should initialize with the selected printer', () => {
// Assert
expect(component.printer()).toEqual(mockPrinters[1]); // The one with selected: true
});
it('should compute selected array correctly with a printer', () => {
// Arrange
const selectedPrinter = mockPrinters[0];
component.printer.set(selectedPrinter);
// Act
const result = component.selected();
// Assert
expect(result).toEqual([selectedPrinter]);
});
it('should compute selected array as empty when no printer is selected', () => {
// Arrange
component.printer.set(undefined);
// Act
const result = component.selected();
// Assert
expect(result).toEqual([]);
});
it('should compute canPrint as true when printer is selected and not printing', () => {
// Arrange
component.printer.set(mockPrinters[0]);
component.printing.set(false);
// Act
const result = component.canPrint();
// Assert
expect(result).toBe(true);
});
it('should compute canPrint as false when no printer is selected', () => {
// Arrange
component.printer.set(undefined);
component.printing.set(false);
// Act
const result = component.canPrint();
// Assert
expect(result).toBe(false);
});
it('should compute canPrint as false when printing is in progress', () => {
// Arrange
component.printer.set(mockPrinters[0]);
component.printing.set(true);
// Act
const result = component.canPrint();
// Assert
expect(result).toBe(false);
});
it('should compare printers by key', () => {
// Arrange
const printer1 = { ...mockPrinters[0] };
const printer2 = { ...mockPrinters[0] }; // Same key as printer1
const printer3 = { ...mockPrinters[1] }; // Different key
// Act & Assert
expect(component.compareWith(printer1, printer2)).toBe(true);
expect(component.compareWith(printer1, printer3)).toBe(false);
});
it('should select a printer and clear error', () => {
// Arrange
const initialError = new Error('Test error');
component.error.set(initialError);
const printer = mockPrinters[0];
// Act
component.select(printer);
// Assert
expect(component.printer()).toEqual(printer);
expect(component.error()).toBeUndefined();
});
it('should not print when canPrint is false', async () => {
// Arrange
component.printer.set(undefined); // Makes canPrint() false
const closeSpy = jest.spyOn(component, 'close');
// Act
await component.print();
// Assert
expect(mockPrintFn).not.toHaveBeenCalled();
expect(closeSpy).not.toHaveBeenCalled();
expect(component.printing()).toBe(false);
});
it('should print and close dialog when print succeeds', async () => {
// Arrange
const selectedPrinter = mockPrinters[0];
component.printer.set(selectedPrinter);
const closeSpy = jest.spyOn(component, 'close');
// Act
await component.print();
// Assert
// The printing flag stays true when success happens and dialog is closed
expect(component.printing()).toBe(true);
expect(mockPrintFn).toHaveBeenCalledWith(selectedPrinter);
expect(closeSpy).toHaveBeenCalledWith({ printer: selectedPrinter });
});
it('should handle print errors', async () => {
// Arrange
const selectedPrinter = mockPrinters[0];
component.printer.set(selectedPrinter);
const testError = new Error('Print failed');
mockPrintFn.mockRejectedValueOnce(testError);
const closeSpy = jest.spyOn(component, 'close');
// Act
await component.print();
// Assert
expect(component.printing()).toBe(false); // Reset to false after error
expect(mockPrintFn).toHaveBeenCalledWith(selectedPrinter);
expect(closeSpy).not.toHaveBeenCalled();
expect(component.error()).toBe(testError);
});
it('should format Error objects correctly', () => {
// Arrange
const errorMessage = 'Test error message';
const testError = new Error(errorMessage);
// Act
const result = component.formatError(testError);
// Assert
expect(result).toBe(errorMessage);
});
it('should format string errors correctly', () => {
// Arrange
const errorMessage = 'Test error message';
// Act
const result = component.formatError(errorMessage);
// Assert
expect(result).toBe(errorMessage);
});
it('should format unknown errors correctly', () => {
// Arrange
const unknownError = { something: 'wrong' };
// Act
const result = component.formatError(unknownError);
// Assert
expect(result).toBe('Unbekannter Fehler');
});
it('should show error message in template when error exists', () => {
// Arrange
const errorMessage = 'Display this error';
component.error.set(errorMessage);
spectator.detectChanges();
// Act
const errorElement = spectator.query('.text-isa-accent-red');
// Assert
expect(errorElement).toHaveText(errorMessage);
});
it('should display printers in the listbox', () => {
// Arrange & Act
spectator.detectChanges();
const listboxItems = spectator.queryAll('button[uiListboxItem]');
// Assert
expect(listboxItems.length).toBe(mockPrinters.length);
expect(listboxItems[0]).toHaveText(mockPrinters[0].value);
expect(listboxItems[1]).toHaveText(mockPrinters[1].value);
expect(listboxItems[2]).toHaveText(mockPrinters[2].value);
});
it('should call close with undefined printer when cancel button is clicked', () => {
// Arrange
const closeSpy = jest.spyOn(component, 'close');
// Act
spectator.click('button[color="secondary"]');
// Assert
expect(closeSpy).toHaveBeenCalledWith({ printer: undefined });
});
});

View File

@@ -19,7 +19,7 @@ export interface PrinterDialogData {
/** Optional error to display in the dialog */
error?: unknown;
/** Function to call when user selects a printer and clicks print */
print: (printer: Printer) => Promise<unknown>;
print: (printer: Printer) => PromiseLike<unknown>;
}
/**

View File

@@ -143,7 +143,10 @@ describe('PrintService', () => {
it('should fetch printers of the specified type', async () => {
// Act
await spectator.service.print(PrinterType.LABEL, mockPrint);
await spectator.service.print({
printerType: PrinterType.LABEL,
printFn: mockPrint,
});
// Assert
expect(printSpy).toHaveBeenCalledWith(PrinterType.LABEL);
@@ -151,7 +154,10 @@ describe('PrintService', () => {
it('should attempt direct printing on desktop when a printer is selected', async () => {
// Act
await spectator.service.print(PrinterType.LABEL, mockPrint);
await spectator.service.print({
printerType: PrinterType.LABEL,
printFn: mockPrint,
});
// Assert
expect(mockPrint).toHaveBeenCalledWith(mockPrinters[1]);
@@ -160,10 +166,10 @@ describe('PrintService', () => {
it('should return the selected printer after successful direct print', async () => {
// Act
const result = await spectator.service.print(
PrinterType.LABEL,
mockPrint,
);
const result = await spectator.service.print({
printerType: PrinterType.LABEL,
printFn: mockPrint,
});
// Assert
expect(result).toEqual({ printer: mockPrinters[1] });
@@ -175,7 +181,10 @@ describe('PrintService', () => {
mockPrint.mockRejectedValueOnce(printError);
// Act
await spectator.service.print(PrinterType.LABEL, mockPrint);
await spectator.service.print({
printerType: PrinterType.LABEL,
printFn: mockPrint,
});
// Assert
expect(mockDialogFn).toHaveBeenCalledWith({
@@ -192,7 +201,10 @@ describe('PrintService', () => {
platform.ANDROID = true;
// Act
await spectator.service.print(PrinterType.LABEL, mockPrint);
await spectator.service.print({
printerType: PrinterType.LABEL,
printFn: mockPrint,
});
// Assert
expect(mockPrint).not.toHaveBeenCalled();
@@ -204,7 +216,10 @@ describe('PrintService', () => {
platform.IOS = true;
// Act
await spectator.service.print(PrinterType.LABEL, mockPrint);
await spectator.service.print({
printerType: PrinterType.LABEL,
printFn: mockPrint,
});
// Assert
expect(mockPrint).not.toHaveBeenCalled();
@@ -221,7 +236,10 @@ describe('PrintService', () => {
printSpy.mockResolvedValueOnce(printersWithoutSelection);
// Act
await spectator.service.print(PrinterType.LABEL, mockPrint);
await spectator.service.print({
printerType: PrinterType.LABEL,
printFn: mockPrint,
});
// Assert
expect(mockPrint).not.toHaveBeenCalled();
@@ -233,7 +251,10 @@ describe('PrintService', () => {
platform.ANDROID = true; // Force dialog to show
// Act
await spectator.service.print(PrinterType.LABEL, mockPrint);
await spectator.service.print({
printerType: PrinterType.LABEL,
printFn: mockPrint,
});
// Assert
expect(mockDialogFn).toHaveBeenCalledWith({
@@ -252,10 +273,10 @@ describe('PrintService', () => {
mockDialogClosedObservable.closed = of({ printer: selectedPrinter });
// Act
const result = await spectator.service.print(
PrinterType.LABEL,
mockPrint,
);
const result = await spectator.service.print({
printerType: PrinterType.LABEL,
printFn: mockPrint,
});
// Assert
expect(result).toEqual({ printer: selectedPrinter });
@@ -267,10 +288,10 @@ describe('PrintService', () => {
mockDialogClosedObservable.closed = of(undefined);
// Act
const result = await spectator.service.print(
PrinterType.LABEL,
mockPrint,
);
const result = await spectator.service.print({
printerType: PrinterType.LABEL,
printFn: mockPrint,
});
// Assert
expect(result).toEqual({ printer: undefined });

View File

@@ -48,18 +48,27 @@ export class PrintService {
}
/**
* Initiates a print operation with platform-specific optimizations
* On desktop, attempts to print directly using the default printer if available
* Falls back to showing a printer selection dialog if needed or on mobile devices
* Initiates a print operation with platform-specific optimizations.
* On desktop, attempts to print directly using the default printer if available.
* Falls back to showing a printer selection dialog if needed or on mobile devices.
*
* @param printerType The type of printer to use (LABEL or OFFICE)
* @param printFn Function that performs the actual print operation with the selected printer
* @returns Object containing the selected printer or undefined if operation was cancelled
* @param printerType - The type of printer to use (LABEL or OFFICE).
* @param printFn - Function that performs the actual print operation with the selected printer.
* @param directPrint - (Optional) If true, forces direct print without showing the dialog.
* If false, always shows the print dialog.
* If undefined, uses direct print only on non-mobile platforms with a selected printer.
* @returns An object containing the selected printer if the operation was successful,
* or undefined if the operation was cancelled.
*/
async print(
printerType: PrinterType,
printFn: (printer: Printer) => Promise<any>,
): Promise<{ printer?: Printer }> {
async print({
printerType,
printFn,
directPrint,
}: {
printerType: PrinterType;
printFn: (printer: Printer) => PromiseLike<unknown>;
directPrint?: boolean | undefined;
}): Promise<{ printer?: Printer }> {
// Get the list of printers based on the printer type
const printers = await this.printers(printerType);
@@ -69,10 +78,12 @@ export class PrintService {
// and we can try to print directly to the selected printer.
// If it fails, we show the print dialog with the error.
const directPrintAllowed =
selectedPrinter && !(this.#platform.ANDROID || this.#platform.IOS);
directPrint === undefined
? selectedPrinter && !(this.#platform.ANDROID || this.#platform.IOS)
: directPrint;
let error: unknown | undefined = undefined;
if (directPrintAllowed) {
if (directPrintAllowed && selectedPrinter) {
try {
await printFn(selectedPrinter);
return { printer: selectedPrinter };

View File

@@ -1,7 +0,0 @@
# core-scanner
This library was generated with [Nx](https://nx.dev).
## Running unit tests
Run `nx test core-scanner` to execute the unit tests.

View File

@@ -1 +0,0 @@
export * from './lib/scanner.service';

View File

@@ -1,53 +0,0 @@
import { inject, Injectable, InjectionToken } from '@angular/core';
import { Config } from '@isa/core/config';
import { z } from 'zod';
import { configure } from 'scandit-web-datacapture-core';
import { barcodeCaptureLoader, Symbology } from 'scandit-web-datacapture-barcode';
export { Symbology };
export const SCANDIT_LICENSE = new InjectionToken<string>('ScanditLicense', {
factory() {
return inject(Config).get('licence.scandit', z.string());
},
});
export const SCANDIT_LIBRARY_LOCATION = new InjectionToken<string>('ScanditLibraryLocation', {
factory() {
return new URL('scandit', document.baseURI).toString();
},
});
@Injectable({ providedIn: 'root' })
export class ScannerService {
readonly licenseKey = inject(SCANDIT_LICENSE);
readonly libraryLocation = inject(SCANDIT_LIBRARY_LOCATION);
private configured = false;
async configure() {
if (this.configured) {
return;
}
await configure({
licenseKey: this.licenseKey,
libraryLocation: this.libraryLocation,
moduleLoaders: [barcodeCaptureLoader()],
});
this.configured = true;
}
async open(args: { symbologies: Symbology[] }): Promise<string | undefined> {
await this.configure();
return new Promise((resolve) => {
console.warn('Scanner not implemented');
setTimeout(() => {
resolve(undefined);
}, 1000);
});
}
}

View File

@@ -178,7 +178,7 @@ export const isaOtherGift =
'<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M12 6V22M12 6H8.46429C7.94332 6 7.4437 5.78929 7.07533 5.41421C6.70695 5.03914 6.5 4.53043 6.5 4C6.5 3.46957 6.70695 2.96086 7.07533 2.58579C7.4437 2.21071 7.94332 2 8.46429 2C11.2143 2 12 6 12 6ZM12 6H15.5357C16.0567 6 16.5563 5.78929 16.9247 5.41421C17.293 5.03914 17.5 4.53043 17.5 4C17.5 3.46957 17.293 2.96086 16.9247 2.58579C16.5563 2.21071 16.0567 2 15.5357 2C12.7857 2 12 6 12 6ZM20 11V18.8C20 19.9201 20 20.4802 19.782 20.908C19.5903 21.2843 19.2843 21.5903 18.908 21.782C18.4802 22 17.9201 22 16.8 22L7.2 22C6.07989 22 5.51984 22 5.09202 21.782C4.71569 21.5903 4.40973 21.2843 4.21799 20.908C4 20.4802 4 19.9201 4 18.8V11M2 7.6L2 9.4C2 9.96005 2 10.2401 2.10899 10.454C2.20487 10.6422 2.35785 10.7951 2.54601 10.891C2.75992 11 3.03995 11 3.6 11L20.4 11C20.9601 11 21.2401 11 21.454 10.891C21.6422 10.7951 21.7951 10.6422 21.891 10.454C22 10.2401 22 9.96005 22 9.4V7.6C22 7.03995 22 6.75992 21.891 6.54601C21.7951 6.35785 21.6422 6.20487 21.454 6.10899C21.2401 6 20.9601 6 20.4 6L3.6 6C3.03995 6 2.75992 6 2.54601 6.10899C2.35785 6.20487 2.20487 6.35785 2.10899 6.54601C2 6.75992 2 7.03995 2 7.6Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>';
export const isaOtherInfo =
'<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M12 16V12M12 8H12.01M22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>';
'<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <path d="M12 16V12M12 8H12.01M22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> </svg>';
export const isaSortByUpMedium =
'<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20" fill="currentColor"><path d="M10 16V4M10 4L7 7M10 4L13 7" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>';

View File

@@ -11,6 +11,7 @@
* - Schemas: Validation schemas for ensuring data integrity
* - Return Process: Question flows and validation for return processing
* - Error handling: Specialized error types for OMS operations
* - Operators: Custom RxJS operators for OMS-specific use cases
*/
export * from './lib/errors';
@@ -19,4 +20,5 @@ export * from './lib/models';
export * from './lib/helpers/return-process';
export * from './lib/schemas';
export * from './lib/services';
export * from './lib/operators';
export * from './lib/stores';

View File

@@ -9,8 +9,12 @@ import {
describe('CreateReturnProcessError', () => {
const params = {
processId: 123,
receipt: { id: 321 } as Receipt,
items: [] as ReceiptItem[],
returns: [
{
receipt: { id: 321 } as Receipt,
items: [] as ReceiptItem[],
},
],
};
it('should create an error instance with NO_RETURNABLE_ITEMS reason', () => {

View File

@@ -78,8 +78,7 @@ export class CreateReturnProcessError extends DataAccessError<'CREATE_RETURN_PRO
public readonly reason: CreateReturnProcessErrorReason,
public readonly params: {
processId: number;
receipt: Receipt;
items: ReceiptItem[];
returns: { receipt: Receipt; items: ReceiptItem[] }[];
},
) {
super('CREATE_RETURN_PROCESS', CreateReturnProcessErrorMessages[reason]);

View File

@@ -0,0 +1,73 @@
import { canReturnReceiptItem } from './can-return-receipt-item.helper';
import { ReceiptItem } from '../../models/receipt-item';
import { Product } from '../../models/product';
import { Quantity } from '../../models/quantity';
describe('canReturnReceiptItem', () => {
const product: Product = {
name: 'Test Product',
contributors: 'Author',
catalogProductNumber: '123',
ean: '1234567890123',
format: 'Hardcover',
formatDetail: 'Detail',
volume: '1',
manufacturer: 'Test Publisher',
};
const quantity: Quantity = { quantity: 1 };
const baseItem: ReceiptItem = {
id: 1,
product,
quantity,
receiptNumber: 'R-001',
actions: [],
};
it('should return false if actions property is missing', () => {
const item = { ...baseItem };
delete (item as { actions?: unknown }).actions;
expect(canReturnReceiptItem(item as ReceiptItem)).toBe(false);
});
it('should return false if canReturn action is missing', () => {
const item = { ...baseItem, actions: [{ key: 'other', value: 'true' }] };
expect(canReturnReceiptItem(item)).toBe(false);
});
it('should return false if canReturn action value is falsy', () => {
const item = { ...baseItem, actions: [{ key: 'canReturn', value: '' }] };
// coerceBooleanProperty('') returns true, so this should be true
expect(canReturnReceiptItem(item)).toBe(true);
const itemZero = {
...baseItem,
actions: [{ key: 'canReturn', value: '0' }],
};
// coerceBooleanProperty('0') returns true (string '0' is truthy)
expect(canReturnReceiptItem(itemZero)).toBe(true);
});
it('should return true if canReturn action value is truthy', () => {
const item = {
...baseItem,
actions: [{ key: 'canReturn', value: 'true' }],
};
expect(canReturnReceiptItem(item)).toBe(true);
});
it('should coerce canReturn action value to boolean', () => {
const item = { ...baseItem, actions: [{ key: 'canReturn', value: '1' }] };
expect(canReturnReceiptItem(item)).toBe(true);
const itemFalse = {
...baseItem,
actions: [{ key: 'canReturn', value: '' }],
};
expect(canReturnReceiptItem(itemFalse)).toBe(true);
const itemStringZero = {
...baseItem,
actions: [{ key: 'canReturn', value: '0' }],
};
// coerceBooleanProperty('0') returns true
expect(canReturnReceiptItem(itemStringZero)).toBe(true);
});
});

View File

@@ -0,0 +1,25 @@
import { coerceBooleanProperty } from '@angular/cdk/coercion';
import { ReceiptItem } from '../../models';
import { getReceiptItemAction } from './get-receipt-item-action.helper';
/**
* Determines if a receipt item can be returned.
*
* @param receiptItem - The receipt item to check for return eligibility. Must have an 'actions' property (array of action objects).
* @returns {boolean} True if the item has a 'canReturn' action with a truthy value (coerced to boolean), otherwise false.
*
* @remarks
* - Returns false if the 'actions' property is missing or not an array.
* - Returns false if the 'canReturn' action is not present in the actions array.
* - Uses Angular's coerceBooleanProperty to interpret the action value.
*/
export function canReturnReceiptItem(receiptItem: ReceiptItem): boolean {
if (!receiptItem.actions) {
return false;
}
const canReturnAction = getReceiptItemAction(receiptItem, 'canReturn');
if (!canReturnAction) {
return false;
}
return coerceBooleanProperty(canReturnAction.value);
}

View File

@@ -0,0 +1,55 @@
import { getReceiptItemAction } from './get-receipt-item-action.helper';
import { ReceiptItem } from '../../models/receipt-item';
import { Product } from '../../models/product';
import { Quantity } from '../../models/quantity';
describe('getReceiptItemAction', () => {
const product: Product = {
name: 'Test Product',
contributors: 'Author',
catalogProductNumber: '123',
ean: '1234567890123',
format: 'Hardcover',
formatDetail: 'Detail',
volume: '1',
manufacturer: 'Test Publisher',
};
const quantity: Quantity = { quantity: 1 };
const baseItem: ReceiptItem = {
id: 1,
product,
quantity,
receiptNumber: 'R-001',
actions: [],
};
it('should return undefined if actions property is missing', () => {
const item = { ...baseItem };
delete (item as { actions?: unknown }).actions;
expect(
getReceiptItemAction(item as ReceiptItem, 'canReturn'),
).toBeUndefined();
});
it('should return undefined if no action with the given key exists', () => {
const item = { ...baseItem, actions: [{ key: 'other', value: 'true' }] };
expect(getReceiptItemAction(item, 'canReturn')).toBeUndefined();
});
it('should return the action object if the key exists', () => {
const action = { key: 'canReturn', value: 'true' };
const item = {
...baseItem,
actions: [action, { key: 'other', value: 'false' }],
};
expect(getReceiptItemAction(item, 'canReturn')).toBe(action);
});
it('should return the first matching action if multiple exist', () => {
const action1 = { key: 'canReturn', value: 'true' };
const action2 = { key: 'canReturn', value: 'false' };
const item = { ...baseItem, actions: [action1, action2] };
expect(getReceiptItemAction(item, 'canReturn')).toBe(action1);
});
});

View File

@@ -0,0 +1,24 @@
import { KeyValueDTOOfStringAndString } from '@generated/swagger/oms-api';
import { ReceiptItem } from '../../models';
/**
* Retrieves a specific action object from a receipt item's actions array by key.
*
* @param receiptItem - The receipt item containing the actions array.
* @param actionKey - The key of the action to retrieve.
* @returns The action object with the specified key, or undefined if not found or if actions are missing.
*
* @remarks
* - Returns undefined if the 'actions' property is missing or not an array.
* - Returns undefined if no action with the given key exists.
*/
export function getReceiptItemAction(
receiptItem: ReceiptItem,
actionKey: string,
): KeyValueDTOOfStringAndString | undefined {
if ('actions' in receiptItem === false) {
return undefined;
}
return receiptItem.actions?.find((a) => a.key === actionKey);
}

View File

@@ -0,0 +1,55 @@
import { getReceiptItemProductCategory } from './get-receipt-item-product-category.helper';
import { ReceiptItem } from '../../models/receipt-item';
import { Product } from '../../models/product';
import { Quantity } from '../../models/quantity';
import { ProductCategory } from '../../questions/constants';
describe('getReceiptItemProductCategory', () => {
const product: Product = {
name: 'Test Product',
contributors: 'Author',
catalogProductNumber: '123',
ean: '1234567890123',
format: 'Hardcover',
formatDetail: 'Detail',
volume: '1',
manufacturer: 'Test Publisher',
};
const quantity: Quantity = { quantity: 1 };
const baseItem: ReceiptItem = {
id: 1,
product,
quantity,
receiptNumber: 'R-001',
features: {},
};
it('should return ProductCategory.Unknown if features property is missing', () => {
const item = { ...baseItem };
delete (item as { features?: unknown }).features;
expect(getReceiptItemProductCategory(item as ReceiptItem)).toBe(
ProductCategory.Unknown,
);
});
it('should return ProductCategory.Unknown if category is not set', () => {
const item = { ...baseItem, features: {} };
expect(getReceiptItemProductCategory(item)).toBe(ProductCategory.Unknown);
});
it('should return the category if set in features', () => {
const item = {
...baseItem,
features: { category: ProductCategory.BookCalendar },
};
expect(getReceiptItemProductCategory(item)).toBe(
ProductCategory.BookCalendar,
);
});
it('should return ProductCategory.Unknown if category is set to a falsy value', () => {
const item = { ...baseItem, features: { category: '' } };
expect(getReceiptItemProductCategory(item)).toBe(ProductCategory.Unknown);
});
});

View File

@@ -0,0 +1,23 @@
import { ReceiptItem } from '../../models';
import { ProductCategory } from '../../questions';
/**
* Retrieves the product category for a given receipt item.
*
* @param item - The receipt item to extract the product category from.
* @returns The product category if present in the item's features; otherwise, ProductCategory.Unknown.
*
* @remarks
* - Returns ProductCategory.Unknown if the 'features' property is missing or does not contain a 'category'.
* - Casts the 'category' feature to ProductCategory if present.
*/
export function getReceiptItemProductCategory(
item: ReceiptItem,
): ProductCategory {
if (!item.features) {
return ProductCategory.Unknown;
}
return (
(item.features['category'] as ProductCategory) || ProductCategory.Unknown
);
}

View File

@@ -0,0 +1,36 @@
import { getReceiptItemQuantity } from './get-receipt-item-quantity.helper';
describe('getItemQuantity', () => {
it('should return item quantity when not present in quantity map', () => {
// Arrange
const item = { id: 123, quantity: { quantity: 5 } };
// Act
const result = getReceiptItemQuantity(item);
// Assert
expect(result).toBe(5);
});
it('should return 1 as default when item quantity is 0', () => {
// Arrange
const item = { id: 123, quantity: { quantity: 0 } };
// Act
const result = getReceiptItemQuantity(item);
// Assert
expect(result).toBe(1);
});
it('should return 1 as default when item quantity is null', () => {
// Arrange
const item = { id: 123, quantity: { quantity: null as unknown as number } };
// Act
const result = getReceiptItemQuantity(item);
// Assert
expect(result).toBe(1);
});
});

View File

@@ -0,0 +1,6 @@
export function getReceiptItemQuantity(item: {
id: number;
quantity?: { quantity: number };
}): number {
return item.quantity?.quantity || 1; // Default to 1 if not specified
}

View File

@@ -0,0 +1,62 @@
import { getReceiptItemReturnedQuantity } from './get-receipt-item-returned-quantity.helper';
describe('getReceiptItemReturnedQuantity', () => {
it('should return 0 when referencedInOtherReceipts is missing', () => {
// Arrange
const item = { id: 123 };
// Act
const result = getReceiptItemReturnedQuantity(item);
// Assert
expect(result).toBe(0);
});
it('should return 0 when referencedInOtherReceipts is an empty array', () => {
// Arrange
const item = { id: 123, referencedInOtherReceipts: [] };
// Act
const result = getReceiptItemReturnedQuantity(item);
// Assert
expect(result).toBe(0);
});
it('should sum all quantities in referencedInOtherReceipts', () => {
// Arrange
const item = {
id: 123,
referencedInOtherReceipts: [
{ quantity: 2 },
{ quantity: 3 },
{ quantity: 5 },
],
};
// Act
const result = getReceiptItemReturnedQuantity(item);
// Assert
expect(result).toBe(10);
});
it('should treat null or undefined quantities as 0', () => {
// Arrange
const item = {
id: 123,
referencedInOtherReceipts: [
{ quantity: 2 },
{ quantity: null as unknown as number },
{ quantity: undefined as unknown as number },
{ quantity: 3 },
],
};
// Act
const result = getReceiptItemReturnedQuantity(item);
// Assert
expect(result).toBe(5);
});
});

View File

@@ -0,0 +1,21 @@
/**
* Calculates the total quantity of a receipt item that has already been returned.
*
* This function sums the `quantity` values from the `referencedInOtherReceipts` array,
* representing how many units of the item have been processed for return in other receipts.
* If no return history is present, it returns 0.
*
* @param item - The receipt item object containing an `id` and optional return history.
* @returns The total number of units already returned for this receipt item.
*/
export function getReceiptItemReturnedQuantity(item: {
id: number;
referencedInOtherReceipts?: { quantity: number }[];
}): number {
return (
item.referencedInOtherReceipts?.reduce(
(sum, receipt) => sum + (receipt?.quantity || 0),
0,
) || 0
); // Default to 0 if not specified
}

View File

@@ -1,10 +1,16 @@
export * from './active-return-process-questions.helper';
export * from './all-return-process-questions-answered.helper';
export * from './calculate-longest-question-depth.helper';
export * from './get-next-question.helper';
export * from './get-return-info.helper';
export * from './can-return-receipt-item.helper';
export * from './eligible-for-return.helper';
export * from './get-next-question.helper';
export * from './get-receipt-item-action.helper';
export * from './get-receipt-item-product-category.helper';
export * from './get-receipt-item-quantity.helper';
export * from './get-return-info.helper';
export * from './get-return-process-questions.helper';
export * from './return-receipt-values-mapping.helper';
export * from './return-details-mapping.helper';
export * from './get-tolino-questions.helper';
export * from './receipt-item-has-category.helper';
export * from './return-details-mapping.helper';
export * from './return-receipt-values-mapping.helper';
export * from './get-receipt-item-returned-quantity.helper';

View File

@@ -0,0 +1,62 @@
import { receiptItemHasCategory } from './receipt-item-has-category.helper';
import { ReceiptItem } from '../../models/receipt-item';
import { Product } from '../../models/product';
import { Quantity } from '../../models/quantity';
import { ProductCategory } from '../../questions/constants';
describe('receiptItemHasCategory', () => {
const product: Product = {
name: 'Test Product',
contributors: 'Author',
catalogProductNumber: '123',
ean: '1234567890123',
format: 'Hardcover',
formatDetail: 'Detail',
volume: '1',
manufacturer: 'Test Publisher',
};
const quantity: Quantity = { quantity: 1 };
const baseItem: ReceiptItem = {
id: 1,
product,
quantity,
receiptNumber: 'R-001',
features: {},
};
it('should return false if features property is missing', () => {
const item = { ...baseItem };
delete (item as { features?: unknown }).features;
expect(
receiptItemHasCategory(item as ReceiptItem, ProductCategory.BookCalendar),
).toBe(false);
});
it('should return false if category does not match', () => {
const item = {
...baseItem,
features: { category: ProductCategory.Tolino },
};
expect(receiptItemHasCategory(item, ProductCategory.BookCalendar)).toBe(
false,
);
});
it('should return true if category matches', () => {
const item = {
...baseItem,
features: { category: ProductCategory.BookCalendar },
};
expect(receiptItemHasCategory(item, ProductCategory.BookCalendar)).toBe(
true,
);
});
it('should return false if category is missing in features', () => {
const item = { ...baseItem, features: {} };
expect(receiptItemHasCategory(item, ProductCategory.BookCalendar)).toBe(
false,
);
});
});

View File

@@ -0,0 +1,24 @@
import { ReceiptItem } from '../../models';
import { ProductCategory } from '../../questions';
/**
* Checks if a receipt item has the specified product category.
*
* @param item - The receipt item to check.
* @param category - The product category to compare against.
* @returns True if the item's features contain the specified category, otherwise false.
*
* @remarks
* - Returns false if the 'features' property is missing.
* - Performs a strict equality check between the item's category and the provided category.
*/
export function receiptItemHasCategory(
item: ReceiptItem,
category: ProductCategory,
) {
if (!item.features) {
return false;
}
const itemCategory = item.features['category'];
return itemCategory === category;
}

View File

@@ -1,21 +1,23 @@
export * from "./address-type";
export * from "./buyer";
export * from "./can-return";
export * from "./eligible-for-return";
export * from "./gender";
export * from "./product";
export * from "./quantity";
export * from "./receipt-item-task-list-item";
export * from "./receipt-item";
export * from "./receipt-list-item";
export * from "./receipt-type";
export * from "./receipt";
export * from "./return-info";
export * from "./return-process-answer";
export * from "./return-process-question-key";
export * from "./return-process-question-type";
export * from "./return-process-question";
export * from "./return-process-status";
export * from "./return-process";
export * from "./shipping-type";
export * from "./task-action-type";
export * from './address-type';
export * from './buyer';
export * from './can-return';
export * from './eligible-for-return';
export * from './gender';
export * from './product';
export * from './quantity';
export * from './receipt-item-list-item';
export * from './receipt-item-task-list-item';
export * from './receipt-item';
export * from './receipt-list-item';
export * from './receipt-type';
export * from './receipt';
export * from './return-info';
export * from './return-process-answer';
export * from './return-process-question-key';
export * from './return-process-question-type';
export * from './return-process-question';
export * from './return-process-status';
export * from './return-process';
export * from './shipping-address-2';
export * from './shipping-type';
export * from './task-action-type';

View File

@@ -0,0 +1,10 @@
import { ReceiptItemListItemDTO } from '@generated/swagger/oms-api';
import { Product } from './product';
import { Quantity } from './quantity';
export interface ReceiptItemListItem extends ReceiptItemListItemDTO {
id: number;
product: Product;
quantity: Quantity;
receiptItemNumber: string;
}

View File

@@ -6,4 +6,5 @@ export interface ReceiptItem extends ReceiptItemDTO {
id: number;
product: Product;
quantity: Quantity;
receiptNumber: string;
}

View File

@@ -2,4 +2,5 @@ import { ReceiptListItemDTO } from '@generated/swagger/oms-api';
export interface ReceiptListItem extends ReceiptListItemDTO {
id: number;
receiptNumber: string;
}

View File

@@ -2,9 +2,11 @@ import { ReceiptDTO } from '@generated/swagger/oms-api';
import { EntityContainer } from '@isa/common/data-access';
import { ReceiptItem } from './receipt-item';
import { Buyer } from './buyer';
import { ShippingAddress2 } from './shipping-address-2';
export interface Receipt extends ReceiptDTO {
id: number;
items: EntityContainer<ReceiptItem>[];
buyer: Buyer;
shipping?: ShippingAddress2;
}

View File

@@ -0,0 +1,3 @@
import { ShippingAddressDTO2 } from '@generated/swagger/oms-api';
export type ShippingAddress2 = ShippingAddressDTO2;

View File

@@ -12,6 +12,7 @@ export type TaskActionTypeType =
export interface TaskActionType {
type: TaskActionTypeType;
taskId: number;
receiptItemId?: number;
updateTo?: Exclude<TaskActionTypeType, 'UNKNOWN'>;
actions?: Array<KeyValueDTOOfStringAndString>;
}

View File

@@ -0,0 +1 @@
export * from './take-until-aborted';

View File

@@ -0,0 +1,50 @@
import { Observable } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
/**
* Creates an Observable that emits when an AbortSignal is aborted.
*
* @param signal - The AbortSignal instance to listen to
* @returns An Observable that emits and completes when the signal is aborted
*/
export const fromAbortSignal = (signal: AbortSignal): Observable<void> => {
// If the signal is already aborted, return an Observable that immediately completes
if (signal.aborted) {
return new Observable<void>((subscriber) => {
subscriber.complete();
});
}
// Otherwise, create an Observable from the abort event
return new Observable<void>((subscriber) => {
const abortHandler = () => {
subscriber.next();
subscriber.complete();
};
// Listen for the 'abort' event
signal.addEventListener('abort', abortHandler);
// Clean up the event listener when the Observable is unsubscribed
return () => {
signal.removeEventListener('abort', abortHandler);
};
});
};
/**
* Operator that completes the source Observable when the provided AbortSignal is aborted.
* Similar to takeUntil, but works with AbortSignal instead of an Observable.
*
* @param signal - The AbortSignal instance that will trigger completion when aborted
* @returns An Observable that completes when the source completes or when the signal is aborted
*/
export const takeUntilAborted =
<T>(signal: AbortSignal) =>
(source: Observable<T>): Observable<T> => {
// Convert the AbortSignal to an Observable
const aborted$ = fromAbortSignal(signal);
// Use the standard takeUntil operator with our abort Observable
return source.pipe(takeUntil(aborted$));
};

View File

@@ -5,6 +5,7 @@ import { ReturnProcessQuestionKey } from '../models';
* Constants for product categories used in the return process.
*/
export const ProductCategory = {
Unknown: 'unknown',
BookCalendar: 'Buch/Kalender',
TonDatentraeger: 'Ton-/Datenträger',
SpielwarenPuzzle: 'Spielwaren/Puzzle',

View File

@@ -26,4 +26,5 @@ export const CategoryQuestions: Record<
[ProductCategory.SonstigesNonbook]: nonbookQuestions,
[ProductCategory.ElektronischeGeraete]: elektronischeGeraeteQuestions,
[ProductCategory.Tolino]: tolinoQuestions,
[ProductCategory.Unknown]: [],
};

View File

@@ -4,3 +4,4 @@ export * from './print-receipts.service';
export * from './return-process.service';
export * from './return-search.service';
export * from './return-task-list.service';
export * from './print-tolino-return-receipt.service';

View File

@@ -35,17 +35,19 @@ describe('PrintReceiptsService', () => {
it('should call the print service with the correct parameters', async () => {
const mockReturnReceiptIds = [1, 2, 3];
mockPrintService.print.mockImplementation((printerType, callback) => {
expect(printerType).toBe(PrinterType.LABEL);
const mockPrinter: Printer = {
key: 'mockPrinterKey',
value: 'Mock Printer',
selected: true,
enabled: true,
description: 'Mock printer description',
};
return callback(mockPrinter);
});
mockPrintService.print.mockImplementation(
async ({ printerType, printFn }) => {
expect(printerType).toBe(PrinterType.LABEL);
const mockPrinter: Printer = {
key: 'mockPrinterKey',
value: 'Mock Printer',
selected: true,
enabled: true,
description: 'Mock printer description',
};
return printFn(mockPrinter).then(() => ({ printer: mockPrinter }));
},
);
mockOmsPrintService.OMSPrintReturnReceipt.mockReturnValue(
of({ error: false }),
@@ -56,8 +58,10 @@ describe('PrintReceiptsService', () => {
});
expect(mockPrintService.print).toHaveBeenCalledWith(
expect.anything(),
expect.any(Function),
expect.objectContaining({
printerType: PrinterType.LABEL,
printFn: expect.any(Function),
}),
);
expect(mockOmsPrintService.OMSPrintReturnReceipt).toHaveBeenCalledWith({
printer: expect.any(String),

View File

@@ -31,13 +31,16 @@ export class PrintReceiptsService {
throw new Error('No return receipt IDs provided');
}
return this.#printService.print(PrinterType.LABEL, (printer) => {
return firstValueFrom(
this.#omsPrintService.OMSPrintReturnReceipt({
printer: printer.key,
data: returnReceiptIds,
}),
);
return this.#printService.print({
printerType: PrinterType.LABEL,
printFn: (printer) => {
return firstValueFrom(
this.#omsPrintService.OMSPrintReturnReceipt({
printer: printer.key,
data: returnReceiptIds,
}),
);
},
});
}
}

View File

@@ -0,0 +1,77 @@
import { SpectatorService, createServiceFactory } from '@ngneat/spectator/jest';
import { PrintTolinoReturnReceiptService } from './print-tolino-return-receipt.service';
import { OMSPrintService } from '@generated/swagger/print-api';
import { PrintService, Printer, PrinterType } from '@isa/common/print';
import { of } from 'rxjs';
describe('PrintTolinoReturnReceiptService', () => {
let spectator: SpectatorService<PrintTolinoReturnReceiptService>;
const createService = createServiceFactory({
service: PrintTolinoReturnReceiptService,
mocks: [OMSPrintService, PrintService],
});
let mockPrintService: jest.Mocked<PrintService>;
let mockOmsPrintService: jest.Mocked<OMSPrintService>;
beforeEach(() => {
spectator = createService();
mockPrintService = spectator.inject(PrintService);
mockOmsPrintService = spectator.inject(OMSPrintService);
});
it('should be created', () => {
expect(spectator.service).toBeTruthy();
});
describe('printTolinoReturnReceipt', () => {
it('should throw an error if no return receipt ID is provided', async () => {
await expect(
spectator.service.printTolinoReturnReceipt({
returnReceiptId: undefined as unknown as number,
}),
).rejects.toThrow('No return receipt ID provided');
});
it('should call the print service with the correct parameters', async () => {
const mockReturnReceiptId = 42;
mockPrintService.print.mockImplementation(
async ({ printerType, printFn, directPrint }) => {
expect(printerType).toBe(PrinterType.OFFICE);
expect(directPrint).toBe(false);
const mockPrinter: Printer = {
key: 'mockPrinterKey',
value: 'Mock Printer',
selected: true,
enabled: true,
description: 'Mock printer description',
};
return printFn(mockPrinter).then(() => ({ printer: mockPrinter }));
},
);
mockOmsPrintService.OMSPrintTolinoRetourenschein.mockReturnValue(
of({ error: false }),
);
await spectator.service.printTolinoReturnReceipt({
returnReceiptId: mockReturnReceiptId,
});
expect(mockPrintService.print).toHaveBeenCalledWith(
expect.objectContaining({
printerType: PrinterType.OFFICE,
printFn: expect.any(Function),
}),
);
expect(
mockOmsPrintService.OMSPrintTolinoRetourenschein,
).toHaveBeenCalledWith({
printer: expect.any(String),
data: mockReturnReceiptId,
});
});
});
});

View File

@@ -0,0 +1,44 @@
import { inject, Injectable } from '@angular/core';
import { OMSPrintService } from '@generated/swagger/print-api';
import { PrinterType, PrintService } from '@isa/common/print';
import { firstValueFrom } from 'rxjs';
@Injectable({ providedIn: 'root' })
export class PrintTolinoReturnReceiptService {
#omsPrintService = inject(OMSPrintService);
#printService = inject(PrintService);
/**
* Prints a Tolino return receipt using the office printer.
*
* @param params - The parameters for printing.
* @param params.returnReceiptId - The unique identifier of the return receipt to print.
* @returns A promise that resolves when the print job is completed.
* @throws {Error} If no return receipt ID is provided.
*
* @example
* await printTolinoReturnReceiptService.printTolinoReturnReceipt({ returnReceiptId: 123 });
*/
async printTolinoReturnReceipt({
returnReceiptId,
}: {
returnReceiptId: number;
}) {
if (!returnReceiptId) {
throw new Error('No return receipt ID provided');
}
return this.#printService.print({
printerType: PrinterType.OFFICE,
printFn: (printer) => {
return firstValueFrom(
this.#omsPrintService.OMSPrintTolinoRetourenschein({
printer: printer.key,
data: returnReceiptId,
}),
);
},
directPrint: false,
});
}
}

View File

@@ -12,6 +12,7 @@ import {
returnReceiptValuesMapping,
} from '../helpers/return-process';
import { isReturnProcessTypeGuard } from '../guards';
import { takeUntilAborted } from '@isa/common/data-access';
/**
* Service for determining if a return process can proceed based on
@@ -35,14 +36,20 @@ export class ReturnCanReturnService {
* @param returnProcess - The return process object to evaluate.
* @returns A promise resolving to a CanReturn result or undefined if the process should continue.
*/
async canReturn(returnProcess: ReturnProcess): Promise<CanReturn | undefined>;
async canReturn(
returnProcess: ReturnProcess,
abortSignal?: AbortSignal,
): Promise<CanReturn | undefined>;
/**
* Determines if a return can proceed based on mapped receipt values.
*
* @param returnValues - The mapped return receipt values.
* @returns A promise resolving to a CanReturn result.
*/
async canReturn(returnValues: ReturnReceiptValues): Promise<CanReturn>;
async canReturn(
returnValues: ReturnReceiptValues,
abortSignal?: AbortSignal,
): Promise<CanReturn>;
/**
* Determines if a return can proceed, accepting either a ReturnProcess or ReturnReceiptValues.
@@ -53,6 +60,7 @@ export class ReturnCanReturnService {
*/
async canReturn(
input: ReturnProcess | ReturnReceiptValues,
abortSignal?: AbortSignal,
): Promise<CanReturn | undefined> {
let data: ReturnReceiptValues | undefined = undefined;
@@ -66,14 +74,20 @@ export class ReturnCanReturnService {
return undefined; // Prozess soll weitergehen, daher kein Error
}
let req$ = this.#receiptService.ReceiptCanReturn(
data as ReturnReceiptValuesDTO,
);
if (abortSignal) {
req$ = req$.pipe(takeUntilAborted(abortSignal));
}
try {
return await firstValueFrom(
this.#receiptService
.ReceiptCanReturn(data as ReturnReceiptValuesDTO)
.pipe(
debounceTime(50),
map((res) => res as CanReturn),
),
req$.pipe(
debounceTime(50),
map((res) => res as CanReturn),
),
);
} catch (error) {
throw new Error(`ReceiptCanReturn failed: ${String(error)}`);

View File

@@ -1,7 +1,10 @@
import { createServiceFactory, SpectatorService } from '@ngneat/spectator/jest';
import { ReturnDetailsService } from './return-details.service';
import { ReceiptService } from '@generated/swagger/oms-api';
import { of } from 'rxjs';
import {
ReceiptService,
ResponseArgsOfReceiptDTO,
} from '@generated/swagger/oms-api';
import { of, NEVER } from 'rxjs';
import { FetchReturnDetails } from '../schemas';
import { Receipt } from '../models';
@@ -16,7 +19,7 @@ describe('ReturnDetailsService', () => {
spectator = createService();
});
it('should fetch return details successfully', (done) => {
it('should fetch return details successfully', async () => {
// Arrange
const mockParams: FetchReturnDetails = { receiptId: 123 };
const mockResponse: any = { result: { id: 123, data: 'mockData' } };
@@ -24,14 +27,12 @@ describe('ReturnDetailsService', () => {
receiptService.ReceiptGetReceipt.mockReturnValue(of(mockResponse));
// Act
spectator.service.fetchReturnDetails(mockParams).subscribe((result) => {
// Assert
expect(result).toEqual(mockResponse.result as Receipt);
done();
});
const result = await spectator.service.fetchReturnDetails(mockParams);
expect(result).toEqual(mockResponse.result as Receipt);
});
it('should throw an error if API response contains an error', (done) => {
it('should throw an error if API response contains an error', async () => {
// Arrange
const mockParams: FetchReturnDetails = { receiptId: 123 };
const mockResponse: any = { error: 'API error' };
@@ -39,26 +40,72 @@ describe('ReturnDetailsService', () => {
receiptService.ReceiptGetReceipt.mockReturnValue(of(mockResponse));
// Act
spectator.service.fetchReturnDetails(mockParams).subscribe({
error: (err) => {
// Assert
expect(err.message).toBe('Failed to fetch return details');
done();
},
});
try {
await spectator.service.fetchReturnDetails(mockParams);
} catch (err) {
expect((err as Error).message).toBe('Failed to fetch return details');
}
});
it('should throw an error if parameters are invalid', (done) => {
/**
* Should return undefined or throw if API returns an empty object.
*/
it('should handle empty API response gracefully', async () => {
// Arrange
const invalidParams: any = { receiptId: null };
const mockParams: FetchReturnDetails = { receiptId: 123 };
const mockResponse: ResponseArgsOfReceiptDTO = {
error: false,
result: undefined,
};
const receiptService = spectator.inject(ReceiptService);
receiptService.ReceiptGetReceipt.mockReturnValue(of(mockResponse));
// Act & Assert
await expect(
spectator.service.fetchReturnDetails(mockParams),
).rejects.toThrow('Failed to fetch return details');
});
/**
* Should call ReceiptGetReceipt with correct parameters.
*/
it('should call ReceiptGetReceipt with correct params', async () => {
// Arrange
const mockParams: FetchReturnDetails = { receiptId: 456 };
const mockResponse: ResponseArgsOfReceiptDTO = {
error: false,
result: { id: 456, data: 'mockData' } as unknown as Receipt,
};
const receiptService = spectator.inject(ReceiptService);
const spy = jest.spyOn(receiptService, 'ReceiptGetReceipt');
spy.mockReturnValue(of(mockResponse));
// Act
spectator.service.fetchReturnDetails(invalidParams).subscribe({
error: (err) => {
// Assert
expect(err).toBeTruthy();
done();
},
});
await spectator.service.fetchReturnDetails(mockParams);
// Assert
expect(spy).toHaveBeenCalledWith({ ...mockParams, eagerLoading: 2 });
});
/**
* Should handle observable that never emits (simulate hanging request).
*/
it('should handle observable that never emits', async () => {
// Arrange
const mockParams: FetchReturnDetails = { receiptId: 789 };
const receiptService = spectator.inject(ReceiptService);
// Simulate never emitting observable
receiptService.ReceiptGetReceipt.mockReturnValue(NEVER);
// Act & Assert
// Should timeout or hang, so we expect the promise not to resolve
// For test safety, wrap in a Promise.race with a timeout
const fetchPromise = spectator.service.fetchReturnDetails(mockParams);
const timeout = new Promise((_, reject) =>
setTimeout(() => reject(new Error('Timeout')), 100),
);
await expect(Promise.race([fetchPromise, timeout])).rejects.toThrow(
'Timeout',
);
});
});

View File

@@ -4,27 +4,43 @@ import {
FetchReturnDetailsSchema,
ReturnReceiptValues,
} from '../schemas';
import { map, Observable, throwError } from 'rxjs';
import { firstValueFrom } from 'rxjs';
import { ReceiptService } from '@generated/swagger/oms-api';
import { Receipt, ReceiptItem } from '../models';
import { CategoryQuestions } from '../questions';
import { CanReturn, Receipt, ReceiptItem, ReceiptListItem } from '../models';
import { CategoryQuestions, ProductCategory } from '../questions';
import { KeyValue } from '@angular/common';
import { ReturnCanReturnService } from './return-can-return.service';
import { takeUntilAborted } from '@isa/common/data-access';
import { z } from 'zod';
/**
* Service responsible for managing receipt return details and operations.
*
* This service provides functionality to:
* - Check if items are eligible for return
* - Fetch receipt details by receipt ID
* - Query receipts by customer email
* - Get available product categories for returns
*/
@Injectable({ providedIn: 'root' })
export class ReturnDetailsService {
#receiptService = inject(ReceiptService);
#returnCanReturnService = inject(ReturnCanReturnService);
/**
* Determines if a specific receipt item can be returned for a given category.
*
* @param params - The parameters for the return check.
* @param params.item - The receipt item to check.
* @param params.category - The product category to check against.
* @returns A promise resolving to the result of the canReturn check.
* @param abortSignal - Optional AbortSignal to cancel the request.
* @returns A promise resolving to the result of the canReturn check, containing
* eligibility status and any relevant constraints or messages.
* @throws Will throw an error if the return check fails or is aborted.
*/
async canReturn({ item, category }: { item: ReceiptItem; category: string }) {
async canReturn(
{ item, category }: { item: ReceiptItem; category: ProductCategory },
abortSignal?: AbortSignal,
): Promise<CanReturn> {
const returnReceiptValues: ReturnReceiptValues = {
quantity: item.quantity.quantity,
receiptItem: {
@@ -33,44 +49,107 @@ export class ReturnDetailsService {
category,
};
return await this.#returnCanReturnService.canReturn(returnReceiptValues);
return this.#returnCanReturnService.canReturn(
returnReceiptValues,
abortSignal,
);
}
/**
* Gets all available product categories that have defined question sets.
*
* @returns {KeyValue<string, string>[]} Array of key-value pairs representing available categories.
* This method filters out the "Unknown" category and returns all other
* categories defined in the CategoryQuestions object.
*
* @returns {KeyValue<ProductCategory, string>[]} Array of key-value pairs representing
* available categories, where the key is the ProductCategory enum value
* and the value is the string representation.
*/
availableCategories(): KeyValue<string, string>[] {
return Object.keys(CategoryQuestions).map((key) => {
return { key, value: key };
availableCategories(): KeyValue<ProductCategory, string>[] {
const categories = Object.keys(CategoryQuestions).map((key) => {
return { key: key as ProductCategory, value: key };
});
}
return categories.filter(
(category) => category.key !== ProductCategory.Unknown,
);
}
/**
* Fetches the return details for a specific receipt.
*
* This method retrieves detailed information about a receipt using its ID.
* The data is validated using Zod schema validation before making the API call.
*
* @param params - The parameters required to fetch the return details, including the receipt ID.
* @returns An observable that emits the fetched receipt details.
* @param abortSignal - Optional AbortSignal that can be used to cancel the request.
* @returns A promise that resolves to the fetched receipt details.
* @throws Will throw an error if the parameters are invalid or if the API response contains an error.
*/
fetchReturnDetails(params: FetchReturnDetails): Observable<Receipt> {
try {
const parsed = FetchReturnDetailsSchema.parse(params);
async fetchReturnDetails(
params: FetchReturnDetails,
abortSignal?: AbortSignal,
): Promise<Receipt> {
const parsed = FetchReturnDetailsSchema.parse(params);
return this.#receiptService
.ReceiptGetReceipt({ receiptId: parsed.receiptId, eagerLoading: 2 })
.pipe(
map((res) => {
if (res.error || !res.result) {
throw new Error(res.message || 'Failed to fetch return details');
}
let req$ = this.#receiptService.ReceiptGetReceipt({
receiptId: parsed.receiptId,
eagerLoading: 2,
});
return res.result as Receipt;
}),
);
} catch (error) {
return throwError(() => error);
if (abortSignal) {
req$ = req$.pipe(takeUntilAborted(abortSignal));
}
const res = await firstValueFrom(req$);
if (res.error || !res.result) {
throw new Error(res.message || 'Failed to fetch return details');
}
return res.result as Receipt;
}
/**
* Schema definition for email-based receipt query parameters.
* Validates that the email parameter is a properly formatted email address.
*/
static FetchReceiptsEmailParamsSchema = z.object({
email: z.string().email(),
});
/**
* Fetches receipts associated with a specific email address.
*
* This method queries the receipt service for receipts that match the provided email address.
* The email is validated using Zod schema validation before making the API call.
*
* @param params - The parameters containing the email to search for.
* @param params.email - Email address to search for in receipt records.
* @param abortSignal - Optional AbortSignal that can be used to cancel the request.
* @returns A promise that resolves to an array of receipt list items matching the email.
* @throws Will throw an error if the email is invalid or if the API response contains an error.
*/
async fetchReceiptsByEmail(
params: z.infer<typeof ReturnDetailsService.FetchReceiptsEmailParamsSchema>,
abortSignal?: AbortSignal,
): Promise<ReceiptListItem[]> {
const { email } =
ReturnDetailsService.FetchReceiptsEmailParamsSchema.parse(params);
let req$ = this.#receiptService.ReceiptQueryReceipt({
queryToken: {
input: { qs: email },
filter: { receipt_type: '1;128;1024' },
},
});
if (abortSignal) {
req$ = req$.pipe(takeUntilAborted(abortSignal));
}
const res = await firstValueFrom(req$);
if (res.error || !res.result) {
throw new Error(res.message || 'Failed to fetch return items by email');
}
return res.result as ReceiptListItem[];
}
}

View File

@@ -1,169 +1,82 @@
import { createServiceFactory } from '@ngneat/spectator/jest';
import { ReturnDetailsStore } from './return-details.store';
import { receiptConfig, ReturnDetailsStore } from './return-details.store';
import { ReturnDetailsService } from '../services';
import { SessionStorageProvider } from '@isa/core/storage';
import { LoggingService } from '@isa/core/logging';
import { patchState } from '@ngrx/signals';
import { AsyncResultStatus } from '@isa/common/data-access';
import { addEntity } from '@ngrx/signals/entities';
import { Receipt } from '../models';
import { of, throwError } from 'rxjs';
import { unprotected } from '@ngrx/signals/testing';
import { Receipt, ReceiptItem } from '../models';
import { addEntities } from '@ngrx/signals/entities';
import { ProductCategory } from '../questions';
describe('ReturnDetailsStore', () => {
const createService = createServiceFactory({
service: ReturnDetailsStore,
mocks: [ReturnDetailsService],
mocks: [ReturnDetailsService, SessionStorageProvider, LoggingService],
});
describe('Initialization', () => {
it('should create an instance of ReturnDetailsStore', () => {
const spectator = createService();
expect(spectator.service).toBeTruthy();
});
it('should create an instance of ReturnDetailsStore', () => {
const spectator = createService();
expect(spectator.service).toBeTruthy();
});
describe('Entity Management', () => {
describe('beforeFetch', () => {
it('should create a new entity and set status to Pending if it does not exist', () => {
const spectator = createService();
const receiptId = 123;
spectator.service.beforeFetch(receiptId);
expect(spectator.service.entityMap()[123]).toEqual({
id: receiptId,
data: undefined,
status: AsyncResultStatus.Pending,
});
});
it('should update the existing entity status to Pending', () => {
const spectator = createService();
const receiptId = 123;
const data = {};
patchState(
spectator.service as any,
addEntity({ id: receiptId, data, status: AsyncResultStatus.Idle }),
);
spectator.service.beforeFetch(receiptId);
expect(spectator.service.entityMap()[123]).toEqual({
id: receiptId,
data,
status: AsyncResultStatus.Pending,
});
});
});
describe('fetchSuccess', () => {
it('should update the entity with fetched data and set status to Success', () => {
const spectator = createService();
const receiptId = 123;
const data: Receipt = {
id: receiptId,
items: [],
buyer: { buyerNumber: '321' },
};
patchState(
spectator.service as any,
addEntity({
id: receiptId,
data: undefined,
status: AsyncResultStatus.Pending,
}),
);
spectator.service.fetchSuccess(receiptId, data);
expect(spectator.service.entityMap()[123]).toEqual({
id: receiptId,
data,
status: AsyncResultStatus.Success,
});
});
});
describe('fetchError', () => {
it('should update the entity status to Error', () => {
const spectator = createService();
const receiptId = 123;
const error = new Error('Fetch error');
patchState(
spectator.service as any,
addEntity({
id: receiptId,
data: undefined,
status: AsyncResultStatus.Pending,
}),
);
spectator.service.fetchError(receiptId, error);
const entity = spectator.service.entityMap()[123];
expect(entity).toMatchObject({
id: receiptId,
status: AsyncResultStatus.Error,
error,
});
});
});
});
describe('fetch', () => {
it('should call the service and update the store on success', () => {
describe('items', () => {
it('should return the items from the receiptsEntities', () => {
// Arrange
const spectator = createService();
const receiptId = 123;
const data: Receipt = {
id: receiptId,
items: [],
buyer: { buyerNumber: '321' },
};
spectator.service.beforeFetch(receiptId);
spectator
.inject(ReturnDetailsService)
.fetchReturnDetails.mockReturnValueOnce(of(data));
const receiptItems = [
{
id: 1,
quantity: { quantity: 1 },
features: {
category: ProductCategory.BookCalendar,
} as { [key: string]: ProductCategory },
} as ReceiptItem,
{
id: 2,
quantity: { quantity: 2 },
features: {
category: ProductCategory.ElektronischeGeraete,
} as { [key: string]: ProductCategory },
} as ReceiptItem,
{
id: 3,
quantity: { quantity: 3 },
features: {
category: ProductCategory.SpielwarenPuzzle,
} as { [key: string]: ProductCategory },
} as ReceiptItem,
{
id: 4,
quantity: { quantity: 4 },
features: {
category: ProductCategory.TonDatentraeger,
} as { [key: string]: ProductCategory },
} as ReceiptItem,
];
spectator.service.fetch({ receiptId });
const receiptsEntities = [
{
id: 1,
items: [{ data: receiptItems[0] }, { data: receiptItems[1] }],
} as Receipt,
{
id: 2,
items: [{ data: receiptItems[2] }, { data: receiptItems[3] }],
} as Receipt,
] as Receipt[];
expect(
spectator.inject(ReturnDetailsService).fetchReturnDetails,
).toHaveBeenCalledWith({
receiptId,
});
patchState(
unprotected(spectator.service),
addEntities(receiptsEntities, receiptConfig),
);
expect(spectator.service.entityMap()[123]).toEqual({
id: receiptId,
data,
status: AsyncResultStatus.Success,
});
});
// Act
const items = spectator.service.items();
it('should handle errors and update the store accordingly', () => {
const spectator = createService();
const receiptId = 123;
const error = new Error('Fetch error');
spectator.service.beforeFetch(receiptId);
spectator
.inject(ReturnDetailsService)
.fetchReturnDetails.mockReturnValueOnce(throwError(() => error));
spectator.service.fetch({ receiptId });
expect(
spectator.inject(ReturnDetailsService).fetchReturnDetails,
).toHaveBeenCalledWith({
receiptId,
});
const entity = spectator.service.entityMap()[123];
expect(entity).toMatchObject({
id: receiptId,
status: AsyncResultStatus.Error,
error,
});
// Assert
expect(items).toEqual(receiptItems);
});
});
});

View File

@@ -1,218 +1,284 @@
import { patchState, signalStore, withMethods } from '@ngrx/signals';
import { addEntity, updateEntity, withEntities } from '@ngrx/signals/entities';
import { AsyncResult, AsyncResultStatus } from '@isa/common/data-access';
import { rxMethod } from '@ngrx/signals/rxjs-interop';
import { pipe, switchMap, tap } from 'rxjs';
import { inject } from '@angular/core';
import { ReturnDetailsService } from '../services';
import { tapResponse } from '@ngrx/operators';
import { Receipt } from '../models';
import { computed, inject, resource, untracked } from '@angular/core';
import {
CanReturn,
ProductCategory,
Receipt,
ReceiptItem,
ReturnDetailsService,
} from '@isa/oms/data-access';
import {
getState,
patchState,
signalStore,
type,
withComputed,
withMethods,
withProps,
withState,
} from '@ngrx/signals';
import { setEntity, withEntities, entityConfig } from '@ngrx/signals/entities';
import {
canReturnReceiptItem,
getReceiptItemQuantity,
getReceiptItemProductCategory,
receiptItemHasCategory,
} from '../helpers/return-process';
import { SessionStorageProvider } from '@isa/core/storage';
import { logger } from '@isa/core/logging';
import { clone } from 'lodash';
/**
* Represents the result of a return operation, including the receipt data and status.
*/
export type ReturnResult = AsyncResult<Receipt | undefined> & { id: number };
interface ReturnDetailsState {
_storageId: number | undefined;
_selectedItemIds: number[];
selectedProductCategory: Record<number, ProductCategory>;
selectedQuantity: Record<number, number>;
canReturn: Record<string, CanReturn>;
}
/**
* Initial state for a return result entity, excluding the unique identifier.
*/
const initialEntity: Omit<ReturnResult, 'id'> = {
data: undefined,
status: AsyncResultStatus.Idle,
const initialState: ReturnDetailsState = {
_storageId: undefined,
_selectedItemIds: [],
selectedProductCategory: {},
selectedQuantity: {},
canReturn: {},
};
/**
* Store for managing return details using NgRx signals.
* Provides methods for fetching and updating return details.
*/
export const receiptConfig = entityConfig({
entity: type<Receipt>(),
collection: 'receipts',
});
export const ReturnDetailsStore = signalStore(
{ providedIn: 'root' },
withEntities<ReturnResult>(),
withState(initialState),
withEntities(receiptConfig),
withProps(() => ({
_logger: logger(() => ({ store: 'ReturnDetailsStore' })),
_returnDetailsService: inject(ReturnDetailsService),
_storage: inject(SessionStorageProvider),
})),
withMethods((store) => ({
/**
* Prepares the store before fetching return details by adding or updating the entity with a pending status.
* @param receiptId - The unique identifier of the receipt.
* @returns The updated or newly created entity.
*/
beforeFetch(receiptId: number) {
// Using optional chaining to safely retrieve the entity from the map
let entity: ReturnResult | undefined = store.entityMap()?.[receiptId];
if (!entity) {
entity = {
...initialEntity,
id: receiptId,
status: AsyncResultStatus.Pending,
};
patchState(store, addEntity(entity));
_storageKey: () => `ReturnDetailsStore:${store._storageId}`,
})),
withMethods((store) => ({
_storeState: () => {
const state = getState(store);
if (!store._storageId) {
return;
}
store._storage.set(store._storageKey(), state);
store._logger.debug('State stored:', () => state);
},
_restoreState: async () => {
const data = await store._storage.get(store._storageKey());
if (data) {
patchState(store, data);
store._logger.debug('State restored:', () => ({ data }));
} else {
patchState(
store,
updateEntity({
id: receiptId,
changes: { status: AsyncResultStatus.Pending },
}),
);
patchState(store, { ...initialState, _storageId: store._storageId() });
store._logger.debug('No state found, initialized with default state');
}
},
/**
* Updates the store with the fetched return details on a successful fetch operation.
* @param receiptId - The unique identifier of the receipt.
* @param data - The fetched receipt data.
*/
fetchSuccess(receiptId: number, data: Receipt) {
patchState(
store,
updateEntity({
id: receiptId,
changes: { data, status: AsyncResultStatus.Success },
}),
);
},
/**
* Updates the store with an error state if the fetch operation fails.
* @param receiptId - The unique identifier of the receipt.
* @param error - The error encountered during the fetch operation.
*/
fetchError(receiptId: number, error: unknown) {
patchState(
store,
updateEntity({
id: receiptId,
changes: { error, status: AsyncResultStatus.Error },
}),
);
},
/**
* Updates the product quantity for a specific item in a receipt.
* This method modifies the quantity feature of an item within a receipt's data.
*
* @param params - Object containing parameters for the update
* @param params.receiptId - The unique identifier of the receipt containing the item
* @param params.itemId - The unique identifier of the item to update
* @param params.quantity - The new quantity value to assign to the item (defaults to 0 if falsy)
* @returns void
*/
updateProductQuantityForItem({
receiptId,
itemId,
quantity,
}: {
receiptId: number;
itemId: number;
quantity: number;
}) {
const receipt = store.entityMap()?.[receiptId];
if (!receipt) return;
const updatedItems = receipt?.data?.items.map((item) => {
if (item.id !== itemId) {
return item;
}
return {
...item,
data: {
...item.data,
quantity: {
...item?.data?.quantity,
quantity: quantity || 0,
},
},
};
});
patchState(
store,
updateEntity({
id: receiptId,
changes: {
data: {
...receipt.data,
items: updatedItems,
} as Receipt,
},
}),
);
},
/**
* Updates the product category for a specific item in a receipt.
* This method modifies the category feature of an item within a receipt's data.
*
* @param params - Object containing parameters for the update
* @param params.receiptId - The unique identifier of the receipt containing the item
* @param params.itemId - The unique identifier of the item to update
* @param params.category - The new category value to assign to the item (defaults to 'unknown' if falsy)
* @returns void
*/
updateProductCategoryForItem({
receiptId,
itemId,
category,
}: {
receiptId: number;
itemId: number;
category: string;
}) {
const receipt = store.entityMap()?.[receiptId];
if (!receipt) return;
const updatedItems = receipt?.data?.items.map((item) => {
if (item.id !== itemId) {
return item;
}
return {
...item,
data: {
...item.data,
features: {
...item?.data?.features,
category: category || 'unknown',
},
},
};
});
patchState(
store,
updateEntity({
id: receiptId,
changes: {
data: {
...receipt.data,
items: updatedItems,
} as Receipt,
},
}),
);
},
})),
withMethods((store, returnDetailsService = inject(ReturnDetailsService)) => ({
/**
* Fetches return details for a given receipt ID.
* Updates the store with the appropriate state based on the fetch result.
* @param params - An object containing the receipt ID.
*/
fetch: rxMethod<{ receiptId: number }>(
pipe(
tap(({ receiptId }) => store.beforeFetch(receiptId)),
switchMap(({ receiptId }) =>
returnDetailsService.fetchReturnDetails({ receiptId }).pipe(
tapResponse({
next(value) {
store.fetchSuccess(receiptId, value);
},
error(error) {
store.fetchError(receiptId, error);
},
}),
),
),
),
withComputed((store) => ({
items: computed<Array<ReceiptItem>>(() =>
store
.receiptsEntities()
.map((receipt) => receipt.items)
.flat()
.map((container) => {
const item = container.data;
if (!item) {
const err = new Error('Item data is undefined');
store._logger.error('Item data is undefined', err, () => ({
item: container,
}));
throw err;
}
const itemData = clone(item);
const quantityMap = store.selectedQuantity();
if (quantityMap[itemData.id]) {
itemData.quantity = { quantity: quantityMap[itemData.id] };
} else {
const quantity = getReceiptItemQuantity(itemData);
if (!itemData.quantity) {
itemData.quantity = { quantity };
} else {
itemData.quantity.quantity = quantity;
}
}
if (!itemData.features) {
itemData.features = {};
}
itemData.features['category'] =
store.selectedProductCategory()[itemData.id] ||
getReceiptItemProductCategory(itemData);
return itemData;
}),
),
})),
withComputed((store) => ({
selectedItemIds: computed(() => {
const selectedIds = store._selectedItemIds();
const canReturn = store.canReturn();
return selectedIds.filter((id) => {
const canReturnResult = canReturn[id]?.result;
return typeof canReturnResult === 'boolean' ? canReturnResult : true;
});
}),
})),
withComputed((store) => ({
returnableItems: computed(() => {
const items = store.items();
return items.filter(canReturnReceiptItem);
}),
selectableItems: computed(() => {
const items = store.items();
const selectedProductCategory = store.selectedProductCategory();
return items.filter(canReturnReceiptItem).filter((item) => {
const category = selectedProductCategory[item.id];
return (
category || !receiptItemHasCategory(item, ProductCategory.Unknown)
);
});
}),
selectedItems: computed(() => {
const selectedIds = store.selectedItemIds();
const items = store.items();
return items.filter((item) => selectedIds.includes(item.id));
}),
})),
withMethods((store) => ({
receiptResource: (receiptId: () => number | undefined) =>
resource({
request: receiptId,
loader: async ({ abortSignal, request }) => {
if (!request) {
return undefined;
}
const receipt = await store._returnDetailsService.fetchReturnDetails(
{ receiptId: request },
abortSignal,
);
patchState(store, setEntity(receipt, receiptConfig));
store._storeState();
return receipt;
},
}),
canReturnResource: (receiptItem: () => ReceiptItem | undefined) =>
resource({
request: () => {
const item = receiptItem();
if (!item) {
return undefined;
}
return {
item: item,
category:
store.selectedProductCategory()[item.id] ||
getReceiptItemProductCategory(item),
};
},
loader: async ({ request, abortSignal }) => {
if (request === undefined) {
return undefined;
}
const key = `${request.item.id}:${request.category}`;
if (store.canReturn()[key]) {
return store.canReturn()[key];
}
const res = await store._returnDetailsService.canReturn(
request,
abortSignal,
);
patchState(store, {
canReturn: { ...store.canReturn(), [key]: res },
});
store._storeState();
return res;
},
}),
getReceipt: (receiptId: () => number) =>
computed(() => {
const id = receiptId();
const entities = store.receiptsEntityMap();
return entities[id];
}),
getItems: (receiptId: () => number) =>
computed(() => {
const items = store.items();
const id = receiptId();
return items.filter((item) => item.receipt?.id === id);
}),
getSelectableItems: (receiptId: () => number) =>
computed(() =>
store
.selectableItems()
.filter((item) => item.receipt?.id === receiptId()),
),
getItemSelected: (item: () => ReceiptItem) =>
computed(() => {
const selectedIds = store.selectedItemIds();
return selectedIds.includes(item().id);
}),
isSelectable: (receiptItem: () => ReceiptItem) =>
computed(() => {
const item = receiptItem();
const selectableItems = store.selectableItems();
return selectableItems.some((i) => i.id === item.id);
}),
getCanReturn: (item: () => ReceiptItem) =>
computed<CanReturn | undefined>(() => {
const itemData = item();
return store.canReturn()[itemData.id];
}),
})),
withMethods((store) => ({
selectStorage: (id: number) => {
untracked(() => {
patchState(store, { _storageId: id });
store._restoreState();
store._storeState();
store._logger.debug('Storage ID set:', () => ({ id }));
});
},
addSelectedItems(itemIds: number[]) {
const currentIds = store.selectedItemIds();
const newIds = Array.from(new Set([...currentIds, ...itemIds]));
patchState(store, { _selectedItemIds: newIds });
store._storeState();
},
removeSelectedItems(itemIds: number[]) {
const currentIds = store.selectedItemIds();
const newIds = currentIds.filter((id) => !itemIds.includes(id));
patchState(store, { _selectedItemIds: newIds });
store._storeState();
},
async setProductCategory(itemId: number, category: ProductCategory) {
const currentCategory = store.selectedProductCategory();
const newCategory = { ...currentCategory, [itemId]: category };
patchState(store, { selectedProductCategory: newCategory });
store._storeState();
},
setQuantity(itemId: number, quantity: number) {
const currentQuantity = store.selectedQuantity();
const newQuantity = { ...currentQuantity, [itemId]: quantity };
patchState(store, { selectedQuantity: newQuantity });
store._storeState();
},
})),
);

View File

@@ -3,8 +3,9 @@ import { ReturnProcessStore } from './return-process.store';
import { IDBStorageProvider } from '@isa/core/storage';
import { ProcessService } from '@isa/core/process';
import { patchState } from '@ngrx/signals';
import { setAllEntities } from '@ngrx/signals/entities';
import { Product, Receipt, ReturnProcess } from '../models';
import { setAllEntities, setEntity } from '@ngrx/signals/entities';
import { unprotected } from '@ngrx/signals/testing';
import { Product, ReturnProcess } from '../models';
import { CreateReturnProcessError } from '../errors/return-process';
const TEST_ITEMS: Record<number, ReturnProcess['receiptItem']> = {
@@ -17,6 +18,7 @@ const TEST_ITEMS: Record<number, ReturnProcess['receiptItem']> = {
formatDetail: 'Taschenbuch',
} as Product,
quantity: { quantity: 1 },
receiptNumber: 'R-001',
},
2: {
id: 2,
@@ -27,6 +29,7 @@ const TEST_ITEMS: Record<number, ReturnProcess['receiptItem']> = {
formatDetail: 'Buch',
} as Product,
quantity: { quantity: 1 },
receiptNumber: 'R-002',
},
3: {
id: 3,
@@ -37,6 +40,7 @@ const TEST_ITEMS: Record<number, ReturnProcess['receiptItem']> = {
formatDetail: 'Audio',
} as Product,
quantity: { quantity: 1 },
receiptNumber: 'R-003',
},
};
@@ -64,12 +68,39 @@ describe('ReturnProcessStore', () => {
const store = spectator.service;
patchState(
store as any,
unprotected(store),
setAllEntities([
{ id: 1, processId: 1, name: 'Process 1' },
{ id: 2, processId: 2, name: 'Process 2' },
{ id: 3, processId: 1, name: 'Process 3' },
]),
{
id: 1,
processId: 1,
receiptId: 1,
receiptItem: TEST_ITEMS[1],
receiptDate: '',
answers: {},
productCategory: undefined,
returnReceipt: undefined,
},
{
id: 2,
processId: 2,
receiptId: 2,
receiptItem: TEST_ITEMS[2],
receiptDate: '',
answers: {},
productCategory: undefined,
returnReceipt: undefined,
},
{
id: 3,
processId: 1,
receiptId: 3,
receiptItem: TEST_ITEMS[3],
receiptDate: '',
answers: {},
productCategory: undefined,
returnReceipt: undefined,
},
] as ReturnProcess[]),
);
store.removeAllEntitiesByProcessId(1);
@@ -82,8 +113,19 @@ describe('ReturnProcessStore', () => {
const store = spectator.service;
patchState(
store as any,
setAllEntities([{ id: 1, processId: 1, answers: {} }]),
unprotected(store),
setAllEntities([
{
id: 1,
processId: 1,
receiptId: 1,
receiptItem: TEST_ITEMS[1],
receiptDate: '',
answers: {},
productCategory: undefined,
returnReceipt: undefined,
},
] as ReturnProcess[]),
);
store.setAnswer(1, 'question1', 'answer1');
@@ -95,10 +137,20 @@ describe('ReturnProcessStore', () => {
const store = spectator.service;
patchState(
store as any,
setAllEntities([
{ id: 1, processId: 1, answers: { question1: 'answer1' } },
]),
unprotected(store),
setEntity({
id: 1,
processId: 1,
answers: { question1: 'answer1', question2: 'answer2' } as Record<
string,
unknown
>,
receiptDate: new Date().toJSON(),
receiptItem: TEST_ITEMS[1],
receiptId: 123,
productCategory: undefined,
returnReceipt: undefined,
} as ReturnProcess),
);
store.removeAnswer(1, 'question1');
@@ -113,8 +165,26 @@ describe('ReturnProcessStore', () => {
store.startProcess({
processId: 1,
receipt: {} as Receipt,
items: [TEST_ITEMS[1], TEST_ITEMS[3]],
returns: [
{
receipt: {
id: 1,
printedDate: '',
items: [],
buyer: { buyerNumber: '' },
},
items: [TEST_ITEMS[1]],
},
{
receipt: {
id: 2,
printedDate: '',
items: [],
buyer: { buyerNumber: '' },
},
items: [TEST_ITEMS[3]],
},
],
});
expect(store.entities()).toHaveLength(2);
@@ -127,8 +197,17 @@ describe('ReturnProcessStore', () => {
expect(() => {
store.startProcess({
processId: 1,
receipt: {} as Receipt,
items: [TEST_ITEMS[2]],
returns: [
{
receipt: {
id: 2,
printedDate: '',
items: [],
buyer: { buyerNumber: '' },
},
items: [TEST_ITEMS[2]], // Non-returnable item
},
],
});
}).toThrow(CreateReturnProcessError);
});
@@ -140,8 +219,17 @@ describe('ReturnProcessStore', () => {
expect(() => {
store.startProcess({
processId: 1,
receipt: {} as Receipt,
items: [TEST_ITEMS[1], TEST_ITEMS[2], TEST_ITEMS[3]],
returns: [
{
receipt: {
id: 3,
printedDate: '',
items: [],
buyer: { buyerNumber: '' },
},
items: [TEST_ITEMS[1], TEST_ITEMS[2], TEST_ITEMS[3]],
},
],
});
}).toThrow(CreateReturnProcessError);
});

View File

@@ -4,6 +4,7 @@ import {
withComputed,
withHooks,
withMethods,
withProps,
} from '@ngrx/signals';
import {
withEntities,
@@ -14,19 +15,19 @@ import { IDBStorageProvider, withStorage } from '@isa/core/storage';
import { computed, effect, inject } from '@angular/core';
import { ProcessService } from '@isa/core/process';
import { Receipt, ReceiptItem, ReturnProcess } from '../models';
import { coerceBooleanProperty } from '@angular/cdk/coercion';
import {
CreateReturnProcessError,
CreateReturnProcessErrorReason,
} from '../errors/return-process';
import { logger } from '@isa/core/logging';
import { canReturnReceiptItem } from '../helpers/return-process';
/**
* Interface representing the parameters required to start a return process.
*/
export type StartProcess = {
processId: number;
receipt: Receipt;
items: ReceiptItem[];
returns: { receipt: Receipt; items: ReceiptItem[] }[];
};
/**
@@ -57,6 +58,11 @@ export const ReturnProcessStore = signalStore(
{ providedIn: 'root' },
withStorage('return-process', IDBStorageProvider),
withEntities<ReturnProcess>(),
withProps(() => ({
_logger: logger(() => ({
store: 'ReturnProcessStore',
})),
})),
withComputed((store) => ({
nextId: computed(() => Math.max(0, ...store.ids().map(Number)) + 1),
})),
@@ -134,38 +140,42 @@ export const ReturnProcessStore = signalStore(
const entities: ReturnProcess[] = [];
const nextId = store.nextId();
const returnableItems = params.items.filter((item) =>
item.actions?.some(
(a) => a.key === 'canReturn' && coerceBooleanProperty(a.value),
),
);
const returnableItems = params.returns
.flatMap((r) => r.items)
.filter(canReturnReceiptItem);
if (returnableItems.length === 0) {
throw new CreateReturnProcessError(
const err = new CreateReturnProcessError(
CreateReturnProcessErrorReason.NO_RETURNABLE_ITEMS,
params,
);
store._logger.error(err.message, err);
throw err;
}
if (returnableItems.length !== params.items.length) {
throw new CreateReturnProcessError(
if (
returnableItems.length !== params.returns.flatMap((r) => r.items).length
) {
const err = new CreateReturnProcessError(
CreateReturnProcessErrorReason.MISMATCH_RETURNABLE_ITEMS,
params,
);
store._logger.error(err.message, err);
throw err;
}
for (let i = 0; i < params.items.length; i++) {
const item = params.items[i];
entities.push({
id: nextId + i,
processId: params.processId,
receiptId: params.receipt.id,
productCategory: item.features?.['category'],
receiptDate: params.receipt.printedDate,
receiptItem: item,
answers: {},
});
for (const { receipt, items } of params.returns) {
for (const item of items) {
entities.push({
id: nextId + entities.length,
processId: params.processId,
receiptId: receipt.id,
productCategory: item.features?.['category'],
receiptDate: receipt.printedDate,
receiptItem: item,
answers: {},
});
}
}
patchState(store, setAllEntities(entities));

View File

@@ -75,7 +75,7 @@ describe('ReturnSearchStore', () => {
error: false,
take: 10,
invalidProperties: {},
result: [{ id: 1 }],
result: [{ id: 1 } as ReceiptListItem],
};
spectator.service.beforeSearch(1);
@@ -111,7 +111,7 @@ describe('ReturnSearchStore', () => {
error: false,
take: 10,
invalidProperties: {},
result: [{ id: 1 }],
result: [{ id: 1 } as ReceiptListItem],
};
const returnSearchService = spectator.inject(ReturnSearchService);
returnSearchService.search.mockReturnValue(of(mockResponse));

View File

@@ -18,7 +18,11 @@ import { ReturnSearchService } from '../services';
import { tapResponse } from '@ngrx/operators';
import { effect, inject } from '@angular/core';
import { QueryTokenSchema } from '../schemas';
import { Callback, ListResponseArgs } from '@isa/common/data-access';
import {
Callback,
ListResponseArgs,
takeUntilKeydownEscape,
} from '@isa/common/data-access';
import { ReceiptListItem } from '../models';
import { Query } from '@isa/shared/filter';
import { SessionStorageProvider, withStorage } from '@isa/core/storage';
@@ -191,6 +195,26 @@ export const ReturnSearchStore = signalStore(
),
);
},
handleSearchCompleted(processId: number) {
const entity = store.getEntity(processId);
if (entity?.status !== ReturnSearchStatus.Pending) {
return;
}
patchState(
store,
updateEntity(
{
id: processId, // Assuming we want to update the first entity
changes: {
status: ReturnSearchStatus.Idle,
},
},
config,
),
);
},
})),
withMethods((store, returnSearchService = inject(ReturnSearchService)) => ({
/**
@@ -219,6 +243,7 @@ export const ReturnSearchStore = signalStore(
}),
)
.pipe(
takeUntilKeydownEscape(),
tapResponse(
(response) => {
store.handleSearchSuccess({ processId, response });
@@ -228,6 +253,9 @@ export const ReturnSearchStore = signalStore(
store.handleSearchError({ processId, error });
cb?.({ error });
},
() => {
store.handleSearchCompleted(processId);
},
),
),
),

View File

@@ -1,16 +1,18 @@
@let r = receipt();
<ui-item-row-data>
<ui-item-row-data-row>
<ui-item-row-data-label>Belegdatum:</ui-item-row-data-label>
<ui-item-row-data-value>
<span>{{
(receipt().printedDate | date: 'dd.MM.yyyy | hh:mm') + ' Uhr'
(receipt().printedDate | date: 'dd.MM.yyyy | HH:mm') + ' Uhr'
}}</span>
</ui-item-row-data-value>
</ui-item-row-data-row>
<ui-item-row-data-row>
<ui-item-row-data-label>Belegart:</ui-item-row-data-label>
<ui-item-row-data-value>
<span>{{ receipt().receiptType | omsReceiptTypeTranslation }}</span>
<span>{{ r.receiptType | omsReceiptTypeTranslation }}</span>
</ui-item-row-data-value>
</ui-item-row-data-row>
</ui-item-row-data>

View File

@@ -3,6 +3,9 @@ import { ChangeDetectionStrategy, Component, input } from '@angular/core';
import { Receipt } from '@isa/oms/data-access';
import { ReceiptTypeTranslationPipe } from '@isa/oms/utils/translation';
import { ItemRowDataImports } from '@isa/ui/item-rows';
export type ReceiptInput = Pick<Receipt, 'printedDate' | 'receiptType'>;
@Component({
selector: 'oms-feature-return-details-data',
templateUrl: './return-details-data.component.html',
@@ -11,5 +14,5 @@ import { ItemRowDataImports } from '@isa/ui/item-rows';
imports: [ItemRowDataImports, ReceiptTypeTranslationPipe, DatePipe],
})
export class ReturnDetailsDataComponent {
receipt = input.required<Receipt>();
receipt = input.required<ReceiptInput>();
}

View File

@@ -1,6 +1,7 @@
import { createComponentFactory, Spectator } from '@ngneat/spectator/jest';
import { ReturnDetailsHeaderComponent } from './return-details-header.component';
import { Buyer } from '@isa/oms/data-access';
import { Receipt, ReturnDetailsStore } from '@isa/oms/data-access';
import { signal } from '@angular/core';
describe('ReturnDetailsHeaderComponent', () => {
let spectator: Spectator<ReturnDetailsHeaderComponent>;
@@ -8,17 +9,32 @@ describe('ReturnDetailsHeaderComponent', () => {
component: ReturnDetailsHeaderComponent,
});
let buyerMock: Buyer;
const getReceiptMock = signal<Receipt>({
id: 12345,
items: [],
buyer: {
reference: { id: 12345 },
firstName: 'John',
lastName: 'Doe',
buyerNumber: '123456',
},
});
const storeMock = {
getReceipt: () => getReceiptMock,
};
beforeEach(() => {
buyerMock = {
buyerNumber: '12345',
};
spectator = createComponent({
props: {
buyer: buyerMock,
receiptId: 12345, // Mock receiptId for testing
},
providers: [
{
provide: ReturnDetailsStore,
useValue: storeMock,
},
],
});
});

View File

@@ -6,13 +6,13 @@ import {
input,
} from '@angular/core';
import { isaActionChevronDown, isaNavigationKunden } from '@isa/icons';
import { Buyer } from '@isa/oms/data-access';
import { InfoButtonComponent } from '@isa/ui/buttons';
import { NgIconComponent, provideIcons } from '@ng-icons/core';
import { formatName } from 'libs/oms/utils/format-name';
import { UiMenu } from '@isa/ui/menu';
import { RouterLink } from '@angular/router';
import { CdkMenuTrigger } from '@angular/cdk/menu';
import { ReturnDetailsStore } from '@isa/oms/data-access';
@Component({
selector: 'oms-feature-return-details-header',
@@ -30,7 +30,13 @@ import { CdkMenuTrigger } from '@angular/cdk/menu';
providers: [provideIcons({ isaNavigationKunden, isaActionChevronDown })],
})
export class ReturnDetailsHeaderComponent {
buyer = input.required<Buyer>();
#store = inject(ReturnDetailsStore);
receiptId = input.required<number>();
receipt = this.#store.getReceipt(this.receiptId);
buyer = computed(() => this.receipt().buyer);
referenceId = computed(() => {
const buyer = this.buyer();
@@ -38,7 +44,11 @@ export class ReturnDetailsHeaderComponent {
});
name = computed(() => {
console.log({ buyer: this.buyer() });
const buyer = this.buyer();
if (!buyer) {
return '';
}
const firstName = this.buyer()?.firstName;
const lastName = this.buyer()?.lastName;
const organisationName = this.buyer()?.organisation?.name;

View File

@@ -0,0 +1,43 @@
<ng-container uiExpandable #expandable="uiExpandable">
<oms-feature-return-details-order-group
uiExpandableTrigger
[receipt]="receipt()"
></oms-feature-return-details-order-group>
<ng-container *uiExpanded>
@if (receiptResource.isLoading()) {
<ui-progress-bar mode="indeterminate" class="w-full"></ui-progress-bar>
} @else if (receiptResource.value()) {
@let r = receiptResource.value()!;
<ng-container uiExpandable #showMore="uiExpandable">
<oms-feature-return-details-order-group-data
*uiExpanded
[receipt]="r"
></oms-feature-return-details-order-group-data>
<button
type="button"
uiTextButton
type="button"
color="strong"
size="small"
uiExpandableTrigger
>
@if (showMore.expanded()) {
<ng-icon name="isaActionMinus"></ng-icon>
Weniger anzeigen
} @else {
<ng-icon name="isaActionPlus"></ng-icon>
Bestelldetails anzeigen
}
</button>
</ng-container>
@for (item of r.items; track item.id; let last = $last) {
<oms-feature-return-details-order-group-item
class="border-b border-solid border-isa-neutral-300 last:border-none"
[item]="item.data!"
></oms-feature-return-details-order-group-item>
}
}
</ng-container>
</ng-container>

View File

@@ -0,0 +1,57 @@
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
input,
viewChild,
} from '@angular/core';
import { ReturnDetailsOrderGroupComponent } from '../return-details-order-group/return-details-order-group.component';
import { ReturnDetailsOrderGroupDataComponent } from '../return-details-order-group-data/return-details-order-group-data.component';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { isaActionPlus, isaActionMinus } from '@isa/icons';
import { ReturnDetailsOrderGroupItemComponent } from '../return-details-order-group-item/return-details-order-group-item.component';
import { ReceiptListItem, ReturnDetailsStore } from '@isa/oms/data-access';
import { ExpandableDirective, ExpandableDirectives } from '@isa/ui/expandable';
import { TextButtonComponent } from '@isa/ui/buttons';
import { ProgressBarComponent } from '@isa/ui/progress-bar';
@Component({
selector: 'oms-feature-return-details-lazy',
templateUrl: './return-details-lazy.component.html',
styleUrls: ['./return-details-lazy.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [
ReturnDetailsOrderGroupComponent,
ReturnDetailsOrderGroupDataComponent,
ReturnDetailsOrderGroupItemComponent,
NgIcon,
ExpandableDirectives,
TextButtonComponent,
ProgressBarComponent,
],
providers: [provideIcons({ isaActionPlus, isaActionMinus })],
})
export class ReturnDetailsLazyComponent {
#store = inject(ReturnDetailsStore);
receipt = input.required<ReceiptListItem>();
receiptMap = this.#store.receiptsEntityMap;
exbandable = viewChild(ExpandableDirective);
receiptId = computed(() => {
const ex = this.exbandable();
if (!ex) {
return;
}
if (!ex.expanded()) {
return;
}
return this.receipt().id;
});
receiptResource = this.#store.receiptResource(this.receiptId);
}

View File

@@ -1,49 +1,51 @@
<ui-item-row-data>
<ui-item-row-data-row>
<ui-item-row-data-label>Belegdatum:</ui-item-row-data-label>
<ui-item-row-data-value>
{{ (receipt().printedDate | date: 'dd.MM.yyyy | HH:mm') + ' Uhr' }}
</ui-item-row-data-value>
</ui-item-row-data-row>
<ui-item-row-data-row>
<ui-item-row-data-label>Belegart:</ui-item-row-data-label>
<ui-item-row-data-value>
{{ receipt().receiptType | omsReceiptTypeTranslation }}
</ui-item-row-data-value>
</ui-item-row-data-row>
<ui-item-row-data-row>
<ui-item-row-data-label>Vorgang-ID:</ui-item-row-data-label>
<ui-item-row-data-value>
{{ receipt().order?.data?.orderNumber }}
</ui-item-row-data-value>
</ui-item-row-data-row>
<ui-item-row-data-row>
<ui-item-row-data-label>Bestelldatum:</ui-item-row-data-label>
<ui-item-row-data-value>
{{
(receipt().order?.data?.orderDate | date: 'dd.MM.yyyy | HH:mm') + ' Uhr'
}}
</ui-item-row-data-value>
</ui-item-row-data-row>
@if (receipt().buyer?.address; as address) {
<ui-item-row-data-row>
<ui-item-row-data-label>Anschrift:</ui-item-row-data-label>
<ui-item-row-data-value>
<div>{{ buyerName() }}</div>
<div>{{ address.street }} {{ address.streetNumber }}</div>
<div>{{ address.zipCode }} {{ address.city }}</div>
</ui-item-row-data-value>
</ui-item-row-data-row>
}
@let r = receipt();
@if (receipt().shipping.address; as address) {
@if (r) {
<ui-item-row-data>
<ui-item-row-data-row>
<ui-item-row-data-label>Lieferanschrift:</ui-item-row-data-label>
<ui-item-row-data-label>Belegdatum:</ui-item-row-data-label>
<ui-item-row-data-value>
<div>{{ shippingName() }}</div>
<div>{{ address.street }} {{ address.streetNumber }}</div>
<div>{{ address.zipCode }} {{ address.city }}</div>
{{ (r.printedDate | date: 'dd.MM.yyyy | HH:mm') + ' Uhr' }}
</ui-item-row-data-value>
</ui-item-row-data-row>
}
</ui-item-row-data>
<ui-item-row-data-row>
<ui-item-row-data-label>Belegart:</ui-item-row-data-label>
<ui-item-row-data-value>
{{ r.receiptType | omsReceiptTypeTranslation }}
</ui-item-row-data-value>
</ui-item-row-data-row>
<ui-item-row-data-row>
<ui-item-row-data-label>Vorgang-ID:</ui-item-row-data-label>
<ui-item-row-data-value>
{{ r.order?.data?.orderNumber }}
</ui-item-row-data-value>
</ui-item-row-data-row>
<ui-item-row-data-row>
<ui-item-row-data-label>Bestelldatum:</ui-item-row-data-label>
<ui-item-row-data-value>
{{ (r.order?.data?.orderDate | date: 'dd.MM.yyyy | HH:mm') + ' Uhr' }}
</ui-item-row-data-value>
</ui-item-row-data-row>
@if (r.buyer?.address; as address) {
<ui-item-row-data-row>
<ui-item-row-data-label>Anschrift:</ui-item-row-data-label>
<ui-item-row-data-value>
<div>{{ buyerName() }}</div>
<div>{{ address.street }} {{ address.streetNumber }}</div>
<div>{{ address.zipCode }} {{ address.city }}</div>
</ui-item-row-data-value>
</ui-item-row-data-row>
}
@if (r.shipping?.address; as address) {
<ui-item-row-data-row>
<ui-item-row-data-label>Lieferanschrift:</ui-item-row-data-label>
<ui-item-row-data-value>
<div>{{ shippingName() }}</div>
<div>{{ address.street }} {{ address.streetNumber }}</div>
<div>{{ address.zipCode }} {{ address.city }}</div>
</ui-item-row-data-value>
</ui-item-row-data-row>
}
</ui-item-row-data>
}

View File

@@ -1,9 +1,19 @@
import { DatePipe } from '@angular/common';
import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core';
import {
ChangeDetectionStrategy,
Component,
computed,
input,
} from '@angular/core';
import { Receipt } from '@isa/oms/data-access';
import { ReceiptTypeTranslationPipe } from '@isa/oms/utils/translation';
import { ItemRowDataImports } from '@isa/ui/item-rows';
export type ReceiptInput = Pick<
Receipt,
'buyer' | 'shipping' | 'printedDate' | 'receiptType' | 'order'
>;
@Component({
selector: 'oms-feature-return-details-order-group-data',
templateUrl: './return-details-order-group-data.component.html',
@@ -13,15 +23,29 @@ import { ItemRowDataImports } from '@isa/ui/item-rows';
imports: [ItemRowDataImports, ReceiptTypeTranslationPipe, DatePipe],
})
export class ReturnDetailsOrderGroupDataComponent {
receipt = input.required<Receipt>();
receipt = input.required<ReceiptInput>();
buyerName = computed(() => {
const receipt = this.receipt();
return [receipt.buyer?.firstName, receipt.buyer?.lastName].filter(Boolean).join(' ');
if (!receipt) {
return '';
}
return [receipt.buyer?.firstName, receipt.buyer?.lastName]
.filter(Boolean)
.join(' ');
});
shippingName = computed(() => {
const receipt = this.receipt();
return [receipt.shipping?.firstName, receipt.shipping?.lastName].filter(Boolean).join(' ');
if (!receipt) {
return '';
}
return [receipt.shipping?.firstName, receipt.shipping?.lastName]
.filter(Boolean)
.join(' ');
});
}

View File

@@ -1,8 +1,10 @@
@if (canReturnReceiptItem()) {
<div>
@if (quantityDropdownValues().length > 1) {
<ui-dropdown
[value]="quantity()"
(valueChange)="changeProductQuantity($event)"
class="quantity-dropdown"
[disabled]="!canReturnReceiptItem()"
[value]="availableQuantity()"
(valueChange)="setQuantity($event)"
>
@for (quantity of quantityDropdownValues(); track quantity) {
<ui-dropdown-option [value]="quantity">{{
@@ -14,25 +16,29 @@
<ui-dropdown
label="Produktart"
[value]="getProductCategory()"
class="product-dropdown"
[disabled]="!canReturnReceiptItem()"
[value]="productCategory()"
(valueChange)="setProductCategory($event)"
>
@for (kv of availableCategories; track kv.key) {
<ui-dropdown-option [value]="kv.key">{{ kv.value }}</ui-dropdown-option>
}
</ui-dropdown>
</div>
@if (selectable()) {
@if (canReturnReceiptItem()) {
@if (!canReturnResource.isLoading() && selectable()) {
<ui-checkbox appearance="bullet">
<input
type="checkbox"
[checked]="selected()"
(click)="selected.set(!selected())"
[ngModel]="selected()"
(ngModelChange)="setSelected($event)"
data-what="return-item-checkbox"
[attr.data-which]="item().product.ean"
/>
</ui-checkbox>
} @else if (showProductCategoryDropdownLoading()) {
} @else if (canReturnResource.isLoading()) {
<ui-icon-button
[pending]="true"
[color]="'tertiary'"

View File

@@ -1,3 +1,29 @@
:host {
@apply flex justify-center items-center gap-4;
:has(.product-dropdown):has(.quantity-dropdown) {
.quantity-dropdown.ui-dropdown {
@apply border-r-0 pr-4;
border-top-right-radius: 0;
border-bottom-right-radius: 0;
&.ui-dropdown__accent-outline {
&.open {
@apply border-isa-accent-blue;
}
}
}
.product-dropdown.ui-dropdown {
@apply border-l-0 pl-4;
border-top-left-radius: 0;
border-bottom-left-radius: 0;
&.ui-dropdown__accent-outline {
&.open {
@apply border-isa-accent-blue;
}
}
}
}
}

View File

@@ -1,11 +1,16 @@
import { createComponentFactory, Spectator } from '@ngneat/spectator/jest';
import { MockDirective } from 'ng-mocks';
import { ReceiptItem, ReturnDetailsService } from '@isa/oms/data-access';
import {
ReceiptItem,
ReturnDetailsService,
ReturnDetailsStore,
} from '@isa/oms/data-access';
import { ProductImageDirective } from '@isa/shared/product-image';
import { ReturnDetailsOrderGroupItemControlsComponent } from '../return-details-order-group-item-controls/return-details-order-group-item-controls.component';
import { CheckboxComponent } from '@isa/ui/input-controls';
import { signal } from '@angular/core';
// Helper function to create mock ReceiptItem data
const createMockItem = (
@@ -16,6 +21,7 @@ const createMockItem = (
): ReceiptItem =>
({
id: 123,
receiptNumber: 'R-123456', // Add the required receiptNumber property
quantity: { quantity: 1 },
price: {
value: { value: 19.99, currency: 'EUR' },
@@ -40,9 +46,31 @@ describe('ReturnDetailsOrderGroupItemControlsComponent', () => {
let spectator: Spectator<ReturnDetailsOrderGroupItemControlsComponent>;
const mockItemSelectable = createMockItem('1234567890123', true);
const mockIsSelectable = signal<boolean>(true);
const mockGetItemSelectted = signal<boolean>(false);
const mockCanReturnResource = {
isLoading: signal<boolean>(true),
};
function resetMocks() {
mockIsSelectable.set(true);
mockGetItemSelectted.set(false);
mockCanReturnResource.isLoading.set(true);
}
const createComponent = createComponentFactory({
component: ReturnDetailsOrderGroupItemControlsComponent,
mocks: [ReturnDetailsService],
providers: [
{
provide: ReturnDetailsStore,
useValue: {
isSelectable: jest.fn(() => mockIsSelectable),
getItemSelected: jest.fn(() => mockGetItemSelectted),
canReturnResource: jest.fn(() => mockCanReturnResource),
},
},
],
// Spectator automatically stubs standalone dependencies like ItemRowComponent, CheckboxComponent etc.
// We don't need deep interaction, just verify the host component renders correctly.
// If specific interactions were needed, we could provide mocks or use overrideComponents.
@@ -65,12 +93,14 @@ describe('ReturnDetailsOrderGroupItemControlsComponent', () => {
spectator = createComponent({
props: {
item: mockItemSelectable, // Use signal for input
selected: false, // Use signal for model
receiptId: 123,
},
});
});
afterEach(() => {
resetMocks(); // Reset mocks after each test
});
it('should create', () => {
// Arrange
spectator.detectChanges(); // Trigger initial render
@@ -81,10 +111,9 @@ describe('ReturnDetailsOrderGroupItemControlsComponent', () => {
it('should display the checkbox when item is selectable', () => {
// Arrange
// The mock item has canReturn=true and a valid category, which should make it selectable
// after the effect executes
mockCanReturnResource.isLoading.set(false); // Simulate the resource being ready
mockIsSelectable.set(true); // Simulate the item being selectable
spectator.detectChanges();
// Assert
expect(spectator.component.selectable()).toBe(true);
const checkbox = spectator.query(CheckboxComponent);
@@ -95,13 +124,9 @@ describe('ReturnDetailsOrderGroupItemControlsComponent', () => {
});
it('should NOT display the checkbox when item is not selectable', () => {
// Arrange
// Create a non-returnable item by modifying the features.category to 'unknown'
const nonReturnableItem = createMockItem('1234567890123', true);
nonReturnableItem.features = { category: 'unknown' };
// Set the item to trigger the effects which determine selectability
spectator.setInput('item', nonReturnableItem);
mockIsSelectable.set(false); // Simulate the item not being selectable
spectator.detectChanges();
spectator.detectComponentChanges();
// Assert
expect(spectator.component.selectable()).toBe(false);
@@ -110,55 +135,6 @@ describe('ReturnDetailsOrderGroupItemControlsComponent', () => {
).not.toExist();
expect(spectator.query(CheckboxComponent)).toBeFalsy();
});
it('should update the selected model when checkbox is clicked', () => {
// Arrange
spectator.detectChanges(); // This will make the component selectable via the effect
// Use the component's method directly to toggle selection
// This is similar to what happens when a checkbox is clicked
spectator.component.selected.set(!spectator.component.selected());
spectator.detectChanges();
// Assert
expect(spectator.component.selected()).toBe(true);
});
it('should reflect the initial selected state in the checkbox', () => {
// Arrange
// First ensure the item is selectable (has a non-unknown category)
const selectableItem = createMockItem(
'1234567890123',
true,
'Test Product',
'BOOK',
);
spectator.setInput('item', selectableItem);
spectator.setInput('selected', true); // Start selected
spectator.detectChanges(); // This triggers the effects that set selectable
// Assert
expect(spectator.component.selected()).toBe(true);
expect(spectator.component.selectable()).toBe(true);
// For a checkbox, we need to check that it exists
const checkbox = spectator.query(
'input[type="checkbox"][data-what="return-item-checkbox"]',
);
expect(checkbox).toExist();
// With Spectator we can use toHaveProperty for HTML elements
expect(checkbox).toHaveProperty('checked', true);
});
it('should be true when actions include canReturn with truthy value', () => {
// Arrange
const item = createMockItem('0001', true);
spectator.setInput('item', item);
// Act
spectator.detectChanges();
// Assert
expect(spectator.component.canReturnReceiptItem()).toBe(true);
});
it('should be false when no canReturn action is present', () => {
// Arrange

View File

@@ -1,20 +1,18 @@
import { coerceBooleanProperty } from '@angular/cdk/coercion';
import { KeyValue } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
computed,
effect,
inject,
input,
model,
output,
signal,
untracked,
} from '@angular/core';
import { logger, provideLoggerContext } from '@isa/core/logging';
import { provideLoggerContext } from '@isa/core/logging';
import {
CanReturn,
canReturnReceiptItem,
getReceiptItemReturnedQuantity,
getReceiptItemProductCategory,
getReceiptItemQuantity,
ProductCategory,
ReceiptItem,
ReturnDetailsService,
ReturnDetailsStore,
@@ -25,6 +23,7 @@ import {
DropdownButtonComponent,
DropdownOptionComponent,
} from '@isa/ui/input-controls';
import { FormsModule } from '@angular/forms';
@Component({
selector: 'oms-feature-return-details-order-group-item-controls',
@@ -37,6 +36,7 @@ import {
IconButtonComponent,
DropdownButtonComponent,
DropdownOptionComponent,
FormsModule,
],
providers: [
provideLoggerContext({
@@ -45,109 +45,99 @@ import {
],
})
export class ReturnDetailsOrderGroupItemControlsComponent {
item = input.required<ReceiptItem>();
receiptId = input.required<number>();
#returnDetailsService = inject(ReturnDetailsService);
#returnDetailsStore = inject(ReturnDetailsStore);
#store = inject(ReturnDetailsStore);
#logger = logger();
item = input.required<ReceiptItem>();
selected = model(false);
selected = this.#store.getItemSelected(this.item);
availableCategories: KeyValue<string, string>[] =
this.#returnDetailsService.availableCategories();
productCategoryChanged = signal<ProductCategory>(ProductCategory.Unknown);
canReturnRequest = computed(() => {
const productCategory = this.productCategoryChanged();
if (productCategory === ProductCategory.Unknown) {
return undefined;
}
return this.item();
});
canReturnResource = this.#store.canReturnResource(this.canReturnRequest);
availableCategories = this.#returnDetailsService.availableCategories();
/**
* Computes the quantity of the current receipt item that has already been returned.
*
* This value is derived from the item's return history and is used to indicate
* how many units have already been processed for return.
*
* @returns The number of units already returned for this receipt item.
*/
returnedQuantity = computed(() => {
const item = this.item();
return getReceiptItemReturnedQuantity(item);
});
/**
* Computes the total quantity for the current receipt item.
* Represents the original quantity as recorded in the receipt.
*
* @returns The total quantity for the item.
*/
quantity = computed(() => {
return this.item()?.quantity.quantity;
const item = this.item();
return getReceiptItemQuantity(item);
});
quantityDropdownValues = signal<number[]>([]);
/**
* Computes the quantity of the item that is still available for return.
* Calculated as the difference between the total quantity and the returned quantity.
*
* @returns The number of units available to be returned.
*/
availableQuantity = computed(() => this.quantity() - this.returnedQuantity());
readonly showProductCategoryDropdownLoading = signal(false);
getProductCategory = computed(() => {
return this.item()?.features?.['category'] || 'unknown';
/**
* Generates the list of selectable quantities for the dropdown.
* The values range from 1 up to the available quantity.
*
* @returns An array of selectable quantity values.
*/
quantityDropdownValues = computed(() => {
const itemQuantity = this.availableQuantity();
return Array.from({ length: itemQuantity }, (_, i) => i + 1);
});
selectable = signal(false);
productCategory = computed(() => {
const item = this.item();
return getReceiptItemProductCategory(item);
});
canReturn = output<CanReturn | undefined>();
selectable = this.#store.isSelectable(this.item);
canReturnReceiptItem = computed(() =>
this.item()?.actions?.some(
(a) => a.key === 'canReturn' && coerceBooleanProperty(a.value),
),
);
canReturnReceiptItem = computed(() => canReturnReceiptItem(this.item()));
constructor() {
effect(() => {
const quantityDropdown = this.quantityDropdownValues();
untracked(() => {
if (quantityDropdown.length === 0) {
this.quantityDropdownValues.set(
Array.from({ length: this.quantity() }, (_, i) => i + 1),
);
}
});
});
effect(() => {
const productCategory = this.getProductCategory();
const canReturnReceiptItem = this.canReturnReceiptItem();
const isSelectable =
canReturnReceiptItem && productCategory !== 'unknown';
if (!isSelectable) {
this.selectable.set(false);
} else {
this.selectable.set(true);
}
});
setProductCategory(category: ProductCategory | undefined) {
if (!category) {
category = ProductCategory.Unknown;
}
this.#store.setProductCategory(this.item().id, category);
this.productCategoryChanged.set(category);
}
async setProductCategory(category: string | undefined) {
const itemToUpdate = {
item: this.item(),
category: category || 'unknown',
};
setQuantity(quantity: number | undefined) {
if (quantity === undefined) {
quantity = this.item().quantity.quantity;
}
this.#store.setQuantity(this.item().id, quantity);
}
try {
this.showProductCategoryDropdownLoading.set(true);
this.canReturn.emit(undefined);
this.selectable.set(false);
const canReturn =
await this.#returnDetailsService.canReturn(itemToUpdate);
this.canReturn.emit(canReturn);
this.changeProductCategory(category || 'unknown');
this.showProductCategoryDropdownLoading.set(false);
} catch (error) {
this.#logger.error('Failed to setProductCategory', error, () => ({
itemId: this.item().id,
category,
}));
this.canReturn.emit(undefined);
this.showProductCategoryDropdownLoading.set(false);
setSelected(selected: boolean) {
if (selected) {
this.#store.addSelectedItems([this.item().id]);
} else {
this.#store.removeSelectedItems([this.item().id]);
}
}
changeProductCategory(category: string) {
this.#returnDetailsStore.updateProductCategoryForItem({
receiptId: this.receiptId(),
itemId: this.item().id,
category,
});
}
changeProductQuantity(quantity: number) {
this.#returnDetailsStore.updateProductQuantityForItem({
receiptId: this.receiptId(),
itemId: this.item().id,
quantity,
});
}
}

View File

@@ -5,17 +5,16 @@
[attr.data-which]="i.product.ean"
>
<div uiItemRowProdcutImage>
<a href="#">
<img
sharedProductImage
[ean]="i.product.ean"
[imageWidth]="100"
[imageHeight]="100"
alt=""
data-what="product-image"
[attr.data-which]="i.product.ean"
/>
</a>
<img
sharedProductRouterLink
sharedProductImage
[ean]="i.product.ean"
[imageWidth]="100"
[imageHeight]="100"
alt=""
data-what="product-image"
[attr.data-which]="i.product.ean"
/>
</div>
<div class="text-isa-neutral-900 flex flex-col gap-2" uiItemRowProdcutTitle>
<h4 class="isa-text-body-2-bold">{{ i.product.contributors }}</h4>
@@ -61,14 +60,7 @@
{{ i.product.publicationDate | date: 'dd. MMM yyyy' }}
</div>
</div>
<oms-feature-return-details-order-group-item-controls
uiItemRowProductControls
(canReturn)="canReturn.set($event)"
[receiptId]="receiptId()"
(selectedChange)="selected.set($event)"
[selected]="selected()"
[item]="i"
>
<oms-feature-return-details-order-group-item-controls [item]="i">
</oms-feature-return-details-order-group-item-controls>
</ui-item-row>
@@ -80,3 +72,12 @@
<span>{{ canReturnMessage() }}</span>
</div>
}
@if (returnedQuantity() > 0 && itemQuantity() !== returnedQuantity()) {
<div
class="flex items-center self-start text-isa-neutral-600 isa-text-body-2-bold pb-6"
>
Es wurden bereits {{ returnedQuantity() }} von {{ itemQuantity() }} Artikel
zurückgegeben.
</div>
}

View File

@@ -1,110 +0,0 @@
import { byText } from '@ngneat/spectator';
import { createComponentFactory, Spectator } from '@ngneat/spectator/jest';
import { MockDirective } from 'ng-mocks';
import { ReceiptItem, ReturnDetailsService } from '@isa/oms/data-access';
import { ReturnDetailsOrderGroupItemComponent } from './return-details-order-group-item.component';
import { ProductImageDirective } from '@isa/shared/product-image';
import { ReturnDetailsOrderGroupItemControlsComponent } from '../return-details-order-group-item-controls/return-details-order-group-item-controls.component';
// Helper function to create mock ReceiptItem data
const createMockItem = (
ean: string,
canReturn: boolean,
name = 'Test Product',
category = 'BOOK', // Add default category that's not 'unknown'
): ReceiptItem =>
({
id: 123,
quantity: { quantity: 1 },
price: {
value: { value: 19.99, currency: 'EUR' },
vat: { inPercent: 19 },
},
product: {
ean: ean,
name: name,
contributors: 'Test Author',
format: 'HC',
formatDetail: 'Hardcover',
manufacturer: 'Test Publisher',
publicationDate: '2024-01-01T00:00:00Z',
catalogProductNumber: '1234567890',
volume: '1',
},
actions: [{ key: 'canReturn', value: String(canReturn) }],
features: { category: category }, // Add the features property with category
}) as ReceiptItem;
describe('ReturnDetailsOrderGroupItemComponent', () => {
let spectator: Spectator<ReturnDetailsOrderGroupItemComponent>;
const mockItemSelectable = createMockItem('1234567890123', true);
const createComponent = createComponentFactory({
component: ReturnDetailsOrderGroupItemComponent,
mocks: [ReturnDetailsService],
componentMocks: [ReturnDetailsOrderGroupItemControlsComponent],
// Spectator automatically stubs standalone dependencies like ItemRowComponent, CheckboxComponent etc.
// We don't need deep interaction, just verify the host component renders correctly.
// If specific interactions were needed, we could provide mocks or use overrideComponents.
overrideComponents: [
[
ReturnDetailsOrderGroupItemComponent,
{
remove: { imports: [ProductImageDirective] },
add: {
imports: [MockDirective(ProductImageDirective)],
},
},
],
],
detectChanges: false, // Control initial detection manually
});
beforeEach(() => {
// Default setup with a selectable item
spectator = createComponent({
props: {
item: mockItemSelectable, // Use signal for input
selected: false, // Use signal for model
receiptId: 123,
},
});
});
it('should create', () => {
// Arrange
spectator.detectChanges(); // Trigger initial render
// Assert
expect(spectator.component).toBeTruthy();
});
it('should display product details correctly', () => {
// Arrange
spectator.detectChanges();
const item = mockItemSelectable;
// Assert
expect(spectator.query(byText(item.product.contributors))).toExist();
expect(spectator.query(`[data-what="product-name"]`)).toHaveText(
item.product.name,
);
expect(spectator.query(`[data-what="product-price"]`)).toHaveText('€19.99'); // Assuming default locale formatting
expect(
spectator.query(byText(`inkl. ${item.price?.vat?.inPercent}% MwSt`)),
).toExist();
expect(spectator.query(`[data-what="product-info"]`)).toHaveText(
`${item.product.manufacturer} | ${item.product.ean}`,
);
// Date formatting depends on locale, checking for year is safer
expect(
spectator.query(byText(/Jan 2024/)), // Adjust regex based on expected format/locale
).toExist();
expect(spectator.query(`img[data-what="product-image"]`)).toHaveAttribute(
'data-which',
item.product.ean,
);
});
});

View File

@@ -1,20 +1,24 @@
import { coerceBooleanProperty } from '@angular/cdk/coercion';
import { CurrencyPipe, DatePipe, LowerCasePipe } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
input,
model,
signal,
WritableSignal,
} from '@angular/core';
import { isaActionClose, ProductFormatIconGroup } from '@isa/icons';
import { CanReturn, ReceiptItem } from '@isa/oms/data-access';
import {
getReceiptItemAction,
getReceiptItemReturnedQuantity,
getReceiptItemQuantity,
ReceiptItem,
ReturnDetailsStore,
} from '@isa/oms/data-access';
import { ProductImageDirective } from '@isa/shared/product-image';
import { ItemRowComponent } from '@isa/ui/item-rows';
import { NgIconComponent, provideIcons } from '@ng-icons/core';
import { ReturnDetailsOrderGroupItemControlsComponent } from '../return-details-order-group-item-controls/return-details-order-group-item-controls.component';
import { ProductRouterLinkDirective } from '@isa/shared/product-router-link';
@Component({
selector: 'oms-feature-return-details-order-group-item',
@@ -30,45 +34,46 @@ import { ReturnDetailsOrderGroupItemControlsComponent } from '../return-details-
CurrencyPipe,
LowerCasePipe,
ReturnDetailsOrderGroupItemControlsComponent,
ProductRouterLinkDirective,
],
providers: [provideIcons({ ...ProductFormatIconGroup, isaActionClose })],
})
export class ReturnDetailsOrderGroupItemComponent {
#store = inject(ReturnDetailsStore);
/**
* The receipt item data to display.
* Contains all information about a product including details, price, and return eligibility.
*/
item = input.required<ReceiptItem>();
/**
* The unique identifier of the receipt to which this item belongs.
* Used for making return eligibility checks against the backend API.
*/
receiptId = input.required<number>();
/**
* Two-way binding for the selection state of the item.
* Indicates whether the item is currently selected for return.
*/
selected = model(false);
selected = computed<boolean>(() => {
const selectedIds = this.#store.selectedItemIds();
const item = this.item();
return selectedIds.includes(item.id);
});
/**
* Holds the return eligibility information from the API.
* Contains both the eligibility result (boolean) and any message explaining the reason.
* This signal may be undefined if the eligibility check hasn't completed yet.
*/
canReturn: WritableSignal<CanReturn | undefined> = signal(undefined);
canReturn = this.#store.getCanReturn(this.item);
/**
* Computes whether the item can be returned.
* Prefers the endpoint result if available, otherwise checks the item's actions.
*/
canReturnReceiptItem = computed(() => {
const canReturn = this.canReturn()?.result;
const canReturnReceiptItem = this.item()?.actions?.some(
(a) => a.key === 'canReturn' && coerceBooleanProperty(a.value),
const returnableItems = this.#store.returnableItems();
const item = this.item();
return returnableItems.some(
(returnableItem) => returnableItem.id === item.id,
);
return canReturn ?? canReturnReceiptItem; // Endpoint Result (if existing) overrules item result
});
/**
@@ -76,10 +81,39 @@ export class ReturnDetailsOrderGroupItemComponent {
* Prefers the endpoint message if available, otherwise uses the item's action description.
*/
canReturnMessage = computed(() => {
const item = this.item();
const canReturnAction = getReceiptItemAction(item, 'canReturn');
if (canReturnAction?.description) {
return canReturnAction.description;
}
const canReturnMessage = this.canReturn()?.message;
const canReturnMessageOnReceiptItem = this.item()?.actions?.find(
(a) => a.key === 'canReturn',
)?.description;
return canReturnMessage ?? canReturnMessageOnReceiptItem; // Endpoint Message (if existing) overrules item message
return canReturnMessage ?? '';
});
/**
* Computes the quantity of the current receipt item that has already been returned.
*
* This value is derived using the item's return history and is used to display
* how many units of this item have been processed for return so far.
*
* @returns The number of units already returned for this receipt item.
*/
returnedQuantity = computed(() => {
const item = this.item();
return getReceiptItemReturnedQuantity(item);
});
/**
* Computes the total quantity for the current receipt item.
* Represents the original quantity of the item as recorded in the receipt.
*
* @returns The total quantity for the item.
*/
itemQuantity = computed(() => {
const item = this.item();
return getReceiptItemQuantity(item);
});
}

View File

@@ -1,12 +1,13 @@
@let r = receipt();
<ui-toolbar size="small" class="justify-self-stretch">
<div class="isa-text-body-2-bold text-isa-neutral-900">
{{ items().length }} Artikel
{{ itemCount() }} Artikel
</div>
<div class="isa-text-body-2-bold text-isa-neutral-900">
{{ receipt().printedDate | date }}
{{ r.printedDate | date }}
</div>
<div class="isa-text-body-2-regular text-isa-neutral-900">
{{ receipt().receiptNumber }}
{{ r.receiptNumber }}
</div>
<div class="flex-grow"></div>
@@ -16,7 +17,7 @@
uiTextButton
color="strong"
size="small"
(click)="selectOrUnselectAll()"
(click)="selectOrUnselectAll(); $event.stopPropagation()"
>
@if (allSelected()) {
Alles abwählen
@@ -25,4 +26,10 @@
}
</button>
}
@if (expandableTrigger?.expanded()) {
<ng-icon size="1.5rem" name="isaActionChevronUp"> </ng-icon>
} @else if (expandableTrigger) {
<ng-icon size="1.5rem" name="isaActionChevronDown"> </ng-icon>
}
</ui-toolbar>

View File

@@ -1,15 +1,29 @@
import { coerceBooleanProperty } from '@angular/cdk/coercion';
import { DatePipe } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
input,
model,
} from '@angular/core';
import { Receipt, ReceiptItem } from '@isa/oms/data-access';
import { isaActionChevronDown, isaActionChevronUp } from '@isa/icons';
import {
Receipt,
ReceiptListItem,
ReturnDetailsStore,
} from '@isa/oms/data-access';
import { TextButtonComponent } from '@isa/ui/buttons';
import {
ExpandableDirective,
ExpandableTriggerDirective,
ExpandedDirective,
} from '@isa/ui/expandable';
import { ToolbarComponent } from '@isa/ui/toolbar';
import { NgIcon, provideIcons } from '@ng-icons/core';
export type ReceiptInput =
| Pick<Receipt, 'id' | 'printedDate' | 'receiptNumber' | 'items'>
| Pick<ReceiptListItem, 'id' | 'printedDate' | 'receiptNumber' | 'items'>;
@Component({
selector: 'oms-feature-return-details-order-group',
@@ -17,37 +31,48 @@ import { ToolbarComponent } from '@isa/ui/toolbar';
styleUrls: ['./return-details-order-group.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [ToolbarComponent, TextButtonComponent, DatePipe],
imports: [ToolbarComponent, TextButtonComponent, DatePipe, NgIcon],
providers: [provideIcons({ isaActionChevronDown, isaActionChevronUp })],
})
export class ReturnDetailsOrderGroupComponent {
receipt = input.required<Receipt>();
items = input.required<ReceiptItem[]>();
#store = inject(ReturnDetailsStore);
selectedItems = model<ReceiptItem[]>([]);
selectableItems = computed(() => {
return this.items().filter(
(item) =>
item.actions?.some(
(a) => a.key === 'canReturn' && coerceBooleanProperty(a.value),
) && item?.features?.['category'] !== 'unknown',
);
expandableTrigger = inject(ExpandableTriggerDirective, {
self: true,
optional: true,
});
selectOrUnselectAll() {
const selectedItems = this.selectedItems();
const selectableItems = this.selectableItems();
if (selectedItems.length === selectableItems.length) {
this.selectedItems.set([]);
return;
}
receipt = input.required<ReceiptInput>();
this.selectedItems.set(this.selectableItems());
receiptId = computed(() => this.receipt().id);
itemCount = computed(() => {
const receipt = this.receipt();
if (typeof receipt.items === 'number') {
return receipt.items || 0;
}
return receipt.items?.length || 0;
});
items = this.#store.getItems(this.receiptId);
selectableItems = this.#store.getSelectableItems(this.receiptId);
selectOrUnselectAll() {
const selectableItems = this.selectableItems();
if (this.allSelected()) {
this.#store.removeSelectedItems(selectableItems.map((item) => item.id));
} else {
this.#store.addSelectedItems(selectableItems.map((item) => item.id));
}
}
allSelected = computed(() => {
const selectedItems = this.selectedItems();
const selectedItemIds = this.#store.selectedItemIds();
const selectableItems = this.selectableItems();
return selectedItems.length === selectableItems.length;
return selectableItems.every((item) => {
return selectedItemIds.includes(item.id);
});
});
}

View File

@@ -0,0 +1,42 @@
@let r = receipt();
<oms-feature-return-details-header
[receiptId]="r.id"
></oms-feature-return-details-header>
<ng-container uiExpandable #showMore="uiExpandable">
<oms-feature-return-details-order-group-data
*uiExpanded
[receipt]="r"
></oms-feature-return-details-order-group-data>
<oms-feature-return-details-data
*uiCollapsed
[receipt]="r"
></oms-feature-return-details-data>
<button
type="button"
class="-ml-3"
uiTextButton
type="button"
color="strong"
size="small"
uiExpandableTrigger
>
@if (showMore.expanded()) {
<ng-icon name="isaActionMinus"></ng-icon>
Weniger anzeigen
} @else {
<ng-icon name="isaActionPlus"></ng-icon>
Bestelldetails anzeigen
}
</button>
</ng-container>
<oms-feature-return-details-order-group
[receipt]="r"
></oms-feature-return-details-order-group>
@for (item of r.items; track item.id; let last = $last) {
<oms-feature-return-details-order-group-item
class="border-b border-solid border-isa-neutral-300 last:border-none"
[item]="item.data!"
></oms-feature-return-details-order-group-item>
}

View File

@@ -0,0 +1,33 @@
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
import { ReturnDetailsHeaderComponent } from '../return-details-header/return-details-header.component';
import { ReturnDetailsOrderGroupComponent } from '../return-details-order-group/return-details-order-group.component';
import { ReturnDetailsOrderGroupDataComponent } from '../return-details-order-group-data/return-details-order-group-data.component';
import { ReturnDetailsDataComponent } from '../return-details-data/return-details-data.component';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { isaActionPlus, isaActionMinus } from '@isa/icons';
import { ReturnDetailsOrderGroupItemComponent } from '../return-details-order-group-item/return-details-order-group-item.component';
import { Receipt } from '@isa/oms/data-access';
import { ExpandableDirectives } from '@isa/ui/expandable';
import { TextButtonComponent } from '@isa/ui/buttons';
@Component({
selector: 'oms-feature-return-details-static',
templateUrl: './return-details-static.component.html',
styleUrls: ['./return-details-static.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [
ReturnDetailsHeaderComponent,
ReturnDetailsOrderGroupComponent,
ReturnDetailsOrderGroupDataComponent,
ReturnDetailsDataComponent,
ReturnDetailsOrderGroupItemComponent,
NgIcon,
ExpandableDirectives,
TextButtonComponent,
],
providers: [provideIcons({ isaActionPlus, isaActionMinus })],
})
export class ReturnDetailsStaticComponent {
receipt = input.required<Receipt>();
}

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