mirror of
https://dev.azure.com/hugendubel/ISA/_git/ISA-Frontend
synced 2025-12-28 22:42:11 +01:00
Merge branch 'develop' into release/4.0
This commit is contained in:
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@@ -30,6 +30,9 @@
|
||||
}
|
||||
],
|
||||
"github.copilot.chat.codeGeneration.instructions": [
|
||||
{
|
||||
"file": ".vscode/llms/angular.txt"
|
||||
},
|
||||
{
|
||||
"file": "docs/tech-stack.md"
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>`,
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -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: {},
|
||||
};
|
||||
@@ -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',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
39
apps/isa-app/stories/ui/tooltip/tooltip.stories.ts
Normal file
39
apps/isa-app/stories/ui/tooltip/tooltip.stories.ts
Normal 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'],
|
||||
},
|
||||
};
|
||||
@@ -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';
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
/* tslint:disable */
|
||||
import { ResponseArgs } from './response-args';
|
||||
export interface ResponseArgsOfString extends ResponseArgs{
|
||||
|
||||
/**
|
||||
* Wert
|
||||
*/
|
||||
result?: string;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from './lib/errors';
|
||||
export * from './lib/models';
|
||||
export * from './lib/operators';
|
||||
|
||||
2
libs/common/data-access/src/lib/operators/index.ts
Normal file
2
libs/common/data-access/src/lib/operators/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './take-until-aborted';
|
||||
export * from './take-unitl-keydown';
|
||||
@@ -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');
|
||||
@@ -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$));
|
||||
};
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from './lib/components';
|
||||
export * from './lib/models';
|
||||
export * from './lib/services';
|
||||
|
||||
1
libs/common/print/src/lib/components/index.ts
Normal file
1
libs/common/print/src/lib/components/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './print-button';
|
||||
@@ -0,0 +1 @@
|
||||
export * from './print-button.component';
|
||||
@@ -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>
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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.
|
||||
@@ -1 +0,0 @@
|
||||
export * from './lib/scanner.service';
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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>';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -6,4 +6,5 @@ export interface ReceiptItem extends ReceiptItemDTO {
|
||||
id: number;
|
||||
product: Product;
|
||||
quantity: Quantity;
|
||||
receiptNumber: string;
|
||||
}
|
||||
|
||||
@@ -2,4 +2,5 @@ import { ReceiptListItemDTO } from '@generated/swagger/oms-api';
|
||||
|
||||
export interface ReceiptListItem extends ReceiptListItemDTO {
|
||||
id: number;
|
||||
receiptNumber: string;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
import { ShippingAddressDTO2 } from '@generated/swagger/oms-api';
|
||||
|
||||
export type ShippingAddress2 = ShippingAddressDTO2;
|
||||
@@ -12,6 +12,7 @@ export type TaskActionTypeType =
|
||||
export interface TaskActionType {
|
||||
type: TaskActionTypeType;
|
||||
taskId: number;
|
||||
receiptItemId?: number;
|
||||
updateTo?: Exclude<TaskActionTypeType, 'UNKNOWN'>;
|
||||
actions?: Array<KeyValueDTOOfStringAndString>;
|
||||
}
|
||||
|
||||
1
libs/oms/data-access/src/lib/operators/index.ts
Normal file
1
libs/oms/data-access/src/lib/operators/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './take-until-aborted';
|
||||
50
libs/oms/data-access/src/lib/operators/take-until-aborted.ts
Normal file
50
libs/oms/data-access/src/lib/operators/take-until-aborted.ts
Normal 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$));
|
||||
};
|
||||
@@ -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',
|
||||
|
||||
@@ -26,4 +26,5 @@ export const CategoryQuestions: Record<
|
||||
[ProductCategory.SonstigesNonbook]: nonbookQuestions,
|
||||
[ProductCategory.ElektronischeGeraete]: elektronischeGeraeteQuestions,
|
||||
[ProductCategory.Tolino]: tolinoQuestions,
|
||||
[ProductCategory.Unknown]: [],
|
||||
};
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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)}`);
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
})),
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>();
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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(' ');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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'"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user