Compare commits

...

8 Commits

Author SHA1 Message Date
Nino
a9a80a192e feature(libs-remission): Improvements and Refactoring of Remission List Component
Ref: #5340
2025-12-16 17:26:28 +01:00
Nino
3d82e7f0af feature(libs-feature-data-access): Using catchResponseArgsErrorPipe across all data-acesss services now and removed old patterns. Adjusted some tests and added Logging for every case wrapped in try catch patterns
Ref: #5340
2025-12-15 17:48:43 +01:00
Nino
3101e8e8e0 feature(oms-data-access): Removed abortSignals for POST and PUT requests
Ref: #5340
2025-12-11 17:57:55 +01:00
Nino
a3415e450d feature(checkout-data-access, remission-data-access): Removed AbortSignals for POST and PUT requests
Ref: #5340
2025-12-11 17:46:46 +01:00
Nino Righi
de3edaa0f9 Merged PR 2077: fix(checkout-data-access, checkout-reward-shopping-cart, checkout-reward-sele...
fix(checkout-data-access, checkout-reward-shopping-cart, checkout-reward-selection-dialog): Show Low Stock message inside Dialog, Adjusted Item Identifyer so that mergedItems inside reward-selection-dialog service works properly, Adjusted Error Message Logic and Quantity Select Logic based on purchasing Options for Abholung

Ref: #5523
2025-12-10 17:12:47 +00:00
Nino Righi
964a6026a0 Merged PR 2076: fix(common-data-access, crm-data-access): Improved Error handling, handling i...
fix(common-data-access, crm-data-access): Improved Error handling, handling invalidProperties errors corretly inside crm customer card area

Refs: #5528, #5529
2025-12-10 17:11:22 +00:00
Nino Righi
83ad5f526e Merged PR 2075: fix(ui-layout, ui-input-controls, shared-filter): Set overlayPositions inside...
fix(ui-layout, ui-input-controls, shared-filter): Set overlayPositions inside filter-menu-button and outsourced the logic

Ref: #5526, #5477
2025-12-10 09:50:15 +00:00
Nino Righi
ccc5285602 Merged PR 2074: fix(remission): Implementation of Abort Remission Logic
fix(remission): Implementation of Abort Remission Logic

Ref: #5489
2025-12-10 09:48:49 +00:00
54 changed files with 1838 additions and 1366 deletions

View File

@@ -1,6 +1,5 @@
import { inject, Injectable } from '@angular/core';
import { AvailabilityService as GeneratedAvailabilityService } from '@generated/swagger/availability-api';
import { ResponseArgsError, takeUntilAborted } from '@isa/common/data-access';
import { LogisticianService, Logistician } from '@isa/oms/data-access';
import { logger } from '@isa/core/logging';
// TODO: [Next Sprint - Architectural] Abstract cross-domain dependency

View File

@@ -4,12 +4,13 @@ import {
} from '@isa/checkout/data-access';
/**
* Creates a unique key for an item based on EAN, destination, and orderItemType.
* Creates a unique key for an item based on EAN, targetBranchId, and orderItemType.
* Items are only considered identical if all three match.
*/
export const getItemKey = (item: ShoppingCartItem): string => {
const ean = item.product.ean ?? 'no-ean';
const destinationId = item.destination?.data?.id ?? 'no-destination';
const targetBranchId =
item.destination?.data?.targetBranch?.id ?? 'no-target-branch-id';
const orderType = getOrderTypeFeature(item.features) ?? 'no-orderType';
return `${ean}|${destinationId}|${orderType}`;
return `${ean}|${targetBranchId}|${orderType}`;
};

View File

@@ -1,6 +1,9 @@
import { inject, Injectable } from '@angular/core';
import { StoreCheckoutBranchService } from '@generated/swagger/checkout-api';
import { ResponseArgsError, takeUntilAborted } from '@isa/common/data-access';
import {
catchResponseArgsErrorPipe,
takeUntilAborted,
} from '@isa/common/data-access';
import { logger } from '@isa/core/logging';
import { firstValueFrom } from 'rxjs';
import { Cache, CacheTimeToLive, InFlight } from '@isa/common/decorators';
@@ -14,20 +17,20 @@ export class BranchService {
@Cache({ ttl: CacheTimeToLive.fiveMinutes })
@InFlight()
async fetchBranches(abortSignal?: AbortSignal): Promise<Branch[]> {
let req$ = this.#branchService.StoreCheckoutBranchGetBranches({});
let req$ = this.#branchService
.StoreCheckoutBranchGetBranches({})
.pipe(catchResponseArgsErrorPipe());
if (abortSignal) {
req$ = req$.pipe(takeUntilAborted(abortSignal));
}
const res = await firstValueFrom(req$);
if (res.error) {
const error = new ResponseArgsError(res);
try {
const res = await firstValueFrom(req$);
return res.result as Branch[];
} catch (error) {
this.#logger.error('Failed to fetch branches', error);
throw error;
}
return res.result as Branch[];
}
}

View File

@@ -4,7 +4,7 @@ import {
PrintOrderConfirmation,
PrintOrderConfirmationSchema,
} from '../schemas';
import { ResponseArgsError } from '@isa/common/data-access';
import { catchResponseArgsErrorPipe } from '@isa/common/data-access';
import { firstValueFrom } from 'rxjs';
import { logger } from '@isa/core/logging';
@@ -18,16 +18,19 @@ export class CheckoutPrintService {
): Promise<ResponseArgs> {
const parsed = PrintOrderConfirmationSchema.parse(params);
const req$ = this.#omsPrintService.OMSPrintAbholscheinById(parsed);
const req$ = this.#omsPrintService
.OMSPrintAbholscheinById(parsed)
.pipe(catchResponseArgsErrorPipe());
const res = await firstValueFrom(req$);
if (res.error) {
const err = new ResponseArgsError(res);
this.#logger.error('Failed to print order confirmation', err);
throw err;
try {
const res = await firstValueFrom(req$);
return res;
} catch (error) {
this.#logger.error('Failed to print order confirmation', error, () => ({
printer: parsed.printer,
orderIds: parsed.data,
}));
throw error;
}
return res;
}
}

View File

@@ -7,15 +7,11 @@ import {
StoreCheckoutPayerService,
StoreCheckoutPaymentService,
DestinationDTO,
BuyerDTO,
PayerDTO,
AvailabilityDTO,
} from '@generated/swagger/checkout-api';
import {
EntityContainer,
ResponseArgsError,
takeUntilAborted,
catchResponseArgsErrorPipe,
} from '@isa/common/data-access';
import { logger } from '@isa/core/logging';
import { firstValueFrom } from 'rxjs';
@@ -29,7 +25,6 @@ import {
PaymentType,
} from '../schemas';
import {
Order,
OrderOptionsAnalysis,
CustomerTypeAnalysis,
Checkout,
@@ -155,7 +150,6 @@ export class CheckoutService {
this.#logger.debug('Creating or refreshing checkout');
const initialCheckout = await this.refreshCheckout(
validated.shoppingCartId,
abortSignal,
);
const checkoutId = initialCheckout.id;
@@ -169,7 +163,6 @@ export class CheckoutService {
await this.updateDestinationsForCustomer(
validated.shoppingCartId,
validated.customerFeatures,
abortSignal,
);
}
@@ -188,7 +181,6 @@ export class CheckoutService {
validated.shoppingCartId,
shoppingCart.items,
validated.specialComment,
abortSignal,
);
}
@@ -209,26 +201,25 @@ export class CheckoutService {
// Step 9: Set buyer on checkout
this.#logger.debug('Setting buyer on checkout');
await this.setBuyerOnCheckout(checkoutId, validated.buyer, abortSignal);
await this.setBuyerOnCheckout(checkoutId, validated.buyer);
// Step 10: Set notification channels
this.#logger.debug('Setting notification channels');
await this.setNotificationChannelsOnCheckout(
checkoutId,
validated.notificationChannels ?? 0,
abortSignal,
);
// Step 11: Set payer (conditional)
if (needsPayer && validated.payer) {
this.#logger.debug('Setting payer on checkout');
await this.setPayerOnCheckout(checkoutId, validated.payer, abortSignal);
await this.setPayerOnCheckout(checkoutId, validated.payer);
}
// Step 12: Set payment type based on order types
const paymentType = this.determinePaymentType(orderOptions);
this.#logger.debug('Setting payment type');
await this.setPaymentTypeOnCheckout(checkoutId, paymentType, abortSignal);
await this.setPaymentTypeOnCheckout(checkoutId, paymentType);
// Step 13: Update destination shipping addresses (if delivery or download)
// Refresh checkout only when we need the destinations data
@@ -240,17 +231,13 @@ export class CheckoutService {
validated.shippingAddress
) {
this.#logger.debug('Refreshing checkout to get destinations');
const checkout = await this.refreshCheckout(
validated.shoppingCartId,
abortSignal,
);
const checkout = await this.refreshCheckout(validated.shoppingCartId);
this.#logger.debug('Updating destination shipping addresses');
await this.updateDestinationShippingAddresses(
checkoutId,
checkout,
validated.shippingAddress,
abortSignal,
);
}
@@ -330,25 +317,22 @@ export class CheckoutService {
private async updateDestinationsForCustomer(
shoppingCartId: number,
customerFeatures: Record<string, string>,
abortSignal?: AbortSignal,
): Promise<void> {
let req$ =
this.#shoppingCartService.StoreCheckoutShoppingCartSetLogisticianOnDestinationsByBuyer(
{
shoppingCartId,
payload: { customerFeatures },
},
const req$ = this.#shoppingCartService
.StoreCheckoutShoppingCartSetLogisticianOnDestinationsByBuyer({
shoppingCartId,
payload: { customerFeatures },
})
.pipe(catchResponseArgsErrorPipe());
try {
await firstValueFrom(req$);
} catch (error) {
this.#logger.error(
'Failed to update destinations for customer',
error,
() => ({ shoppingCartId }),
);
if (abortSignal) {
req$ = req$.pipe(takeUntilAborted(abortSignal));
}
const res = await firstValueFrom(req$);
if (res.error) {
const error = new ResponseArgsError(res);
this.#logger.error('Failed to update destinations for customer', error);
throw error;
}
}
@@ -360,31 +344,30 @@ export class CheckoutService {
shoppingCartId: number,
items: EntityContainer<ShoppingCartItem>[],
comment: string,
abortSignal?: AbortSignal,
): Promise<void> {
// Update all items in parallel
await Promise.all(
items.map(async (item) => {
if (!item.id) return;
let req$ =
this.#shoppingCartService.StoreCheckoutShoppingCartUpdateShoppingCartItem(
{
const req$ = this.#shoppingCartService
.StoreCheckoutShoppingCartUpdateShoppingCartItem({
shoppingCartId,
shoppingCartItemId: item.id,
values: { specialComment: comment },
})
.pipe(catchResponseArgsErrorPipe());
try {
await firstValueFrom(req$);
} catch (error) {
this.#logger.error(
'Failed to set special comment on item',
error,
() => ({
shoppingCartId,
shoppingCartItemId: item.id,
values: { specialComment: comment },
},
}),
);
if (abortSignal) {
req$ = req$.pipe(takeUntilAborted(abortSignal));
}
const res = await firstValueFrom(req$);
if (res.error) {
const error = new ResponseArgsError(res);
this.#logger.error('Failed to set special comment on item');
throw error;
}
}),
@@ -394,27 +377,22 @@ export class CheckoutService {
/**
* Refreshes checkout to get latest state.
*/
private async refreshCheckout(
shoppingCartId: number,
abortSignal?: AbortSignal,
): Promise<Checkout> {
let req$ = this.#storeCheckoutService.StoreCheckoutCreateOrRefreshCheckout({
shoppingCartId,
});
private async refreshCheckout(shoppingCartId: number): Promise<Checkout> {
const req$ = this.#storeCheckoutService
.StoreCheckoutCreateOrRefreshCheckout({
shoppingCartId,
})
.pipe(catchResponseArgsErrorPipe());
if (abortSignal) {
req$ = req$.pipe(takeUntilAborted(abortSignal));
}
const res = await firstValueFrom(req$);
if (res.error) {
const error = new ResponseArgsError(res);
this.#logger.error('Failed to refresh checkout', error);
try {
const res = await firstValueFrom(req$);
return res.result as Checkout;
} catch (error) {
this.#logger.error('Failed to refresh checkout', error, () => ({
shoppingCartId,
}));
throw error;
}
return res.result as Checkout;
}
/**
@@ -472,7 +450,7 @@ export class CheckoutService {
const availabilityDTO =
AvailabilityAdapter.fromAvailabilityApi(availability);
let updateReq$ =
const updateReq$ =
this.#shoppingCartService.StoreCheckoutShoppingCartUpdateShoppingCartItemAvailability(
{
shoppingCartId,
@@ -481,10 +459,6 @@ export class CheckoutService {
},
);
if (abortSignal) {
updateReq$ = updateReq$.pipe(takeUntilAborted(abortSignal));
}
const updateRes = await firstValueFrom(updateReq$);
if (updateRes.error) {
@@ -582,7 +556,7 @@ export class CheckoutService {
};
}
let req$ =
const req$ =
this.#shoppingCartService.StoreCheckoutShoppingCartUpdateShoppingCartItem(
{
shoppingCartId,
@@ -591,10 +565,6 @@ export class CheckoutService {
},
);
if (abortSignal) {
req$ = req$.pipe(takeUntilAborted(abortSignal));
}
const res = await firstValueFrom(req$);
if (res.error) {
@@ -618,26 +588,23 @@ export class CheckoutService {
private async setBuyerOnCheckout(
checkoutId: number,
buyerDTO: Buyer,
abortSignal?: AbortSignal,
): Promise<Checkout> {
let req$ = this.#buyerService.StoreCheckoutBuyerSetBuyerPOST({
checkoutId,
buyerDTO,
});
const req$ = this.#buyerService
.StoreCheckoutBuyerSetBuyerPOST({
checkoutId,
buyerDTO,
})
.pipe(catchResponseArgsErrorPipe());
if (abortSignal) {
req$ = req$.pipe(takeUntilAborted(abortSignal));
}
const res = await firstValueFrom(req$);
if (res.error) {
const error = new ResponseArgsError(res);
this.#logger.error('Failed to set buyer', error);
try {
const res = await firstValueFrom(req$);
return res.result as Checkout;
} catch (error) {
this.#logger.error('Failed to set buyer on checkout', error, () => ({
checkoutId,
}));
throw error;
}
return res.result as Checkout;
}
/**
@@ -646,26 +613,23 @@ export class CheckoutService {
private async setPayerOnCheckout(
checkoutId: number,
payer: Payer,
abortSignal?: AbortSignal,
): Promise<Checkout> {
let req$ = this.#payerService.StoreCheckoutPayerSetPayerPOST({
checkoutId,
payerDTO: payer,
});
const req$ = this.#payerService
.StoreCheckoutPayerSetPayerPOST({
checkoutId,
payerDTO: payer,
})
.pipe(catchResponseArgsErrorPipe());
if (abortSignal) {
req$ = req$.pipe(takeUntilAborted(abortSignal));
}
const res = await firstValueFrom(req$);
if (res.error) {
const error = new ResponseArgsError(res);
this.#logger.error('Failed to set payer', error);
try {
const res = await firstValueFrom(req$);
return res.result as Checkout;
} catch (error) {
this.#logger.error('Failed to set payer on checkout', error, () => ({
checkoutId,
}));
throw error;
}
return res.result as Checkout;
}
/**
@@ -674,26 +638,25 @@ export class CheckoutService {
private async setNotificationChannelsOnCheckout(
checkoutId: number,
channels: NotificationChannel,
abortSignal?: AbortSignal,
): Promise<Checkout> {
let req$ = this.#storeCheckoutService.StoreCheckoutSetNotificationChannels({
checkoutId,
notificationChannel: channels,
});
const req$ = this.#storeCheckoutService
.StoreCheckoutSetNotificationChannels({
checkoutId,
notificationChannel: channels,
})
.pipe(catchResponseArgsErrorPipe());
if (abortSignal) {
req$ = req$.pipe(takeUntilAborted(abortSignal));
}
const res = await firstValueFrom(req$);
if (res.error) {
const error = new ResponseArgsError(res);
this.#logger.error('Failed to set notification channels', error);
try {
const res = await firstValueFrom(req$);
return res.result as Checkout;
} catch (error) {
this.#logger.error(
'Failed to set notification channels on checkout',
error,
() => ({ checkoutId }),
);
throw error;
}
return res.result as Checkout;
}
/**
@@ -702,26 +665,25 @@ export class CheckoutService {
private async setPaymentTypeOnCheckout(
checkoutId: number,
paymentType: PaymentType,
abortSignal?: AbortSignal,
): Promise<Checkout> {
let req$ = this.#paymentService.StoreCheckoutPaymentSetPaymentType({
checkoutId,
paymentType: paymentType,
});
const req$ = this.#paymentService
.StoreCheckoutPaymentSetPaymentType({
checkoutId,
paymentType: paymentType,
})
.pipe(catchResponseArgsErrorPipe());
if (abortSignal) {
req$ = req$.pipe(takeUntilAborted(abortSignal));
}
const res = await firstValueFrom(req$);
if (res.error) {
const error = new ResponseArgsError(res);
this.#logger.error('Failed to set payment type', error);
try {
const res = await firstValueFrom(req$);
return res.result as Checkout;
} catch (error) {
this.#logger.error(
'Failed to set payment type on checkout',
error,
() => ({ checkoutId, paymentType }),
);
throw error;
}
return res.result as Checkout;
}
/**
@@ -731,7 +693,6 @@ export class CheckoutService {
checkoutId: number,
checkout: Checkout,
shippingAddress: ShippingAddress,
abortSignal?: AbortSignal,
): Promise<void> {
// Get shipping destinations (target 2 or 16)
const destinations = filterDeliveryDestinations(
@@ -751,21 +712,21 @@ export class CheckoutService {
shippingAddress: { ...shippingAddress },
};
let req$ = this.#storeCheckoutService.StoreCheckoutUpdateDestination({
checkoutId,
destinationId: dest.id,
destinationDTO: updatedDestination,
});
const req$ = this.#storeCheckoutService
.StoreCheckoutUpdateDestination({
checkoutId,
destinationId: dest.id,
destinationDTO: updatedDestination,
})
.pipe(catchResponseArgsErrorPipe());
if (abortSignal) {
req$ = req$.pipe(takeUntilAborted(abortSignal));
}
const res = await firstValueFrom(req$);
if (res.error) {
const error = new ResponseArgsError(res);
this.#logger.error('Failed to update destination');
try {
await firstValueFrom(req$);
} catch (error) {
this.#logger.error('Failed to update destination', error, () => ({
checkoutId,
destinationId: dest.id,
}));
throw error;
}
}),

View File

@@ -18,7 +18,7 @@ import {
} from '../schemas';
import { RewardSelectionItem, ShoppingCart, ShoppingCartItem } from '../models';
import {
ResponseArgsError,
catchResponseArgsErrorPipe,
takeUntilAborted,
ensureCurrencyDefaults,
} from '@isa/common/data-access';
@@ -36,107 +36,105 @@ export class ShoppingCartService {
#checkoutMetadataService = inject(CheckoutMetadataService);
async createShoppingCart(): Promise<ShoppingCart> {
const req$ =
this.#storeCheckoutShoppingCartService.StoreCheckoutShoppingCartCreateShoppingCart();
const req$ = this.#storeCheckoutShoppingCartService
.StoreCheckoutShoppingCartCreateShoppingCart()
.pipe(catchResponseArgsErrorPipe());
const res = await firstValueFrom(req$);
try {
const res = await firstValueFrom(req$);
if (res.error) {
const err = new ResponseArgsError(res);
this.#logger.error('Failed to create shopping cart', err);
throw err;
this.#shoppingCartStream.pub(
ShoppingCartEvent.Created,
res.result as ShoppingCart,
'ShoppingCartService',
);
return res.result as ShoppingCart;
} catch (error) {
this.#logger.error('Failed to create shopping cart', error);
throw error;
}
this.#shoppingCartStream.pub(
ShoppingCartEvent.Created,
res.result as ShoppingCart,
'ShoppingCartService',
);
return res.result as ShoppingCart;
}
async getShoppingCart(
shoppingCartId: number,
abortSignal?: AbortSignal,
): Promise<ShoppingCart | undefined> {
let req$ =
this.#storeCheckoutShoppingCartService.StoreCheckoutShoppingCartGetShoppingCart(
{
shoppingCartId,
},
);
let req$ = this.#storeCheckoutShoppingCartService
.StoreCheckoutShoppingCartGetShoppingCart({
shoppingCartId,
})
.pipe(catchResponseArgsErrorPipe());
if (abortSignal) {
req$ = req$.pipe(takeUntilAborted(abortSignal));
}
const res = await firstValueFrom(req$);
try {
const res = await firstValueFrom(req$);
if (res.error) {
const err = new ResponseArgsError(res);
this.#logger.error('Failed to fetch shopping cart', err);
throw err;
this.#shoppingCartStream.pub(
ShoppingCartEvent.ItemUpdated,
res.result as ShoppingCart,
'ShoppingCartService',
);
return res.result as ShoppingCart;
} catch (error) {
this.#logger.error('Failed to get shopping cart', error, () => ({
shoppingCartId,
}));
throw error;
}
this.#shoppingCartStream.pub(
ShoppingCartEvent.ItemUpdated,
res.result as ShoppingCart,
'ShoppingCartService',
);
return res.result as ShoppingCart;
}
async canAddItems(
params: CanAddItemsToShoppingCartParams,
): Promise<ItemsResult[]> {
const parsed = CanAddItemsToShoppingCartParamsSchema.parse(params);
const req$ =
this.#storeCheckoutShoppingCartService.StoreCheckoutShoppingCartCanAddItems(
{
shoppingCartId: parsed.shoppingCartId,
payload: parsed.payload as ItemPayload[],
},
);
const req$ = this.#storeCheckoutShoppingCartService
.StoreCheckoutShoppingCartCanAddItems({
shoppingCartId: parsed.shoppingCartId,
payload: parsed.payload as ItemPayload[],
})
.pipe(catchResponseArgsErrorPipe());
const res = await firstValueFrom(req$);
if (res.error) {
const err = new ResponseArgsError(res);
try {
const res = await firstValueFrom(req$);
return res.result as unknown as ItemsResult[];
} catch (error) {
this.#logger.error(
'Failed to check if items can be added to shopping cart',
err,
'Failed to check if items can be added',
error,
() => ({ shoppingCartId: parsed.shoppingCartId }),
);
throw err;
throw error;
}
return res.result as unknown as ItemsResult[];
}
async addItem(params: AddItemToShoppingCartParams): Promise<ShoppingCart> {
const parsed = AddItemToShoppingCartParamsSchema.parse(params);
const req$ =
this.#storeCheckoutShoppingCartService.StoreCheckoutShoppingCartAddItemToShoppingCart(
{
shoppingCartId: parsed.shoppingCartId,
items: parsed.items as AddToShoppingCartDTO[],
},
const req$ = this.#storeCheckoutShoppingCartService
.StoreCheckoutShoppingCartAddItemToShoppingCart({
shoppingCartId: parsed.shoppingCartId,
items: parsed.items as AddToShoppingCartDTO[],
})
.pipe(catchResponseArgsErrorPipe());
try {
const res = await firstValueFrom(req$);
this.#shoppingCartStream.pub(
ShoppingCartEvent.ItemAdded,
res.result as ShoppingCart,
'ShoppingCartService',
);
const res = await firstValueFrom(req$);
if (res.error) {
const err = new ResponseArgsError(res);
this.#logger.error('Failed to add item to shopping cart', err);
throw err;
return res.result as ShoppingCart;
} catch (error) {
this.#logger.error('Failed to add item to shopping cart', error, () => ({
shoppingCartId: parsed.shoppingCartId,
}));
throw error;
}
this.#shoppingCartStream.pub(
ShoppingCartEvent.ItemAdded,
res.result as ShoppingCart,
'ShoppingCartService',
);
return res.result as ShoppingCart;
}
async updateItem(
@@ -144,60 +142,62 @@ export class ShoppingCartService {
): Promise<ShoppingCart> {
const parsed = UpdateShoppingCartItemParamsSchema.parse(params);
const req$ =
this.#storeCheckoutShoppingCartService.StoreCheckoutShoppingCartUpdateShoppingCartItem(
{
shoppingCartId: parsed.shoppingCartId,
shoppingCartItemId: parsed.shoppingCartItemId,
values: parsed.values as UpdateShoppingCartItemDTO,
},
const req$ = this.#storeCheckoutShoppingCartService
.StoreCheckoutShoppingCartUpdateShoppingCartItem({
shoppingCartId: parsed.shoppingCartId,
shoppingCartItemId: parsed.shoppingCartItemId,
values: parsed.values as UpdateShoppingCartItemDTO,
})
.pipe(catchResponseArgsErrorPipe());
try {
const res = await firstValueFrom(req$);
this.#shoppingCartStream.pub(
ShoppingCartEvent.ItemUpdated,
res.result as ShoppingCart,
'ShoppingCartService',
);
const res = await firstValueFrom(req$);
if (res.error) {
const err = new ResponseArgsError(res);
this.#logger.error('Failed to update shopping cart item', err);
throw err;
return res.result as ShoppingCart;
} catch (error) {
this.#logger.error('Failed to update shopping cart item', error, () => ({
shoppingCartId: parsed.shoppingCartId,
shoppingCartItemId: parsed.shoppingCartItemId,
}));
throw error;
}
this.#shoppingCartStream.pub(
ShoppingCartEvent.ItemUpdated,
res.result as ShoppingCart,
'ShoppingCartService',
);
return res.result as ShoppingCart;
}
async removeItem(
params: RemoveShoppingCartItemParams,
): Promise<ShoppingCart> {
const parsed = RemoveShoppingCartItemParamsSchema.parse(params);
const req$ =
this.#storeCheckoutShoppingCartService.StoreCheckoutShoppingCartUpdateShoppingCartItem(
{
shoppingCartId: parsed.shoppingCartId,
shoppingCartItemId: parsed.shoppingCartItemId,
values: {
quantity: 0,
},
const req$ = this.#storeCheckoutShoppingCartService
.StoreCheckoutShoppingCartUpdateShoppingCartItem({
shoppingCartId: parsed.shoppingCartId,
shoppingCartItemId: parsed.shoppingCartItemId,
values: {
quantity: 0,
},
})
.pipe(catchResponseArgsErrorPipe());
try {
const res = await firstValueFrom(req$);
this.#shoppingCartStream.pub(
ShoppingCartEvent.ItemRemoved,
res.result as ShoppingCart,
'ShoppingCartService',
);
const res = await firstValueFrom(req$);
if (res.error) {
const err = new ResponseArgsError(res);
this.#logger.error('Failed to remove item from shopping cart', err);
throw err;
return res.result as ShoppingCart;
} catch (error) {
this.#logger.error('Failed to remove shopping cart item', error, () => ({
shoppingCartId: parsed.shoppingCartId,
shoppingCartItemId: parsed.shoppingCartItemId,
}));
throw error;
}
this.#shoppingCartStream.pub(
ShoppingCartEvent.ItemRemoved,
res.result as ShoppingCart,
'ShoppingCartService',
);
return res.result as ShoppingCart;
}
// TODO: Code Kommentieren + Beschreiben

View File

@@ -87,7 +87,8 @@ describe('SupplierService', () => {
// Arrange
const errorResponse = {
result: null,
error: { message: 'API Error', code: 500 },
error: true,
message: 'API Error',
};
mockSupplierService.StoreCheckoutSupplierGetSuppliers.mockReturnValue(

View File

@@ -1,6 +1,9 @@
import { inject, Injectable } from '@angular/core';
import { StoreCheckoutSupplierService } from '@generated/swagger/checkout-api';
import { ResponseArgsError, takeUntilAborted } from '@isa/common/data-access';
import {
catchResponseArgsErrorPipe,
takeUntilAborted,
} from '@isa/common/data-access';
import { logger } from '@isa/core/logging';
import { firstValueFrom } from 'rxjs';
import { Cache, CacheTimeToLive, InFlight } from '@isa/common/decorators';
@@ -32,37 +35,42 @@ export class SupplierService {
async getTakeAwaySupplier(abortSignal?: AbortSignal): Promise<Supplier> {
this.#logger.debug('Fetching take away supplier');
let req$ = this.#supplierService.StoreCheckoutSupplierGetSuppliers({});
let req$ = this.#supplierService
.StoreCheckoutSupplierGetSuppliers({})
.pipe(catchResponseArgsErrorPipe());
if (abortSignal) {
req$ = req$.pipe(takeUntilAborted(abortSignal));
}
const res = await firstValueFrom(req$);
try {
const res = await firstValueFrom(req$);
if (res.error) {
const error = new ResponseArgsError(res);
this.#logger.error('Failed to fetch suppliers', error);
const takeAwaySupplier = res.result?.find(
(supplier) => supplier.supplierNumber === 'F',
);
if (!takeAwaySupplier) {
const notFoundError = new Error('Take away supplier (F) not found');
this.#logger.error(
'Take away supplier not found',
notFoundError,
() => ({
availableSuppliers: res.result?.map((s) => s.supplierNumber),
}),
);
throw notFoundError;
}
this.#logger.debug('Take away supplier fetched', () => ({
supplierId: takeAwaySupplier.id,
supplierNumber: takeAwaySupplier.supplierNumber,
}));
return takeAwaySupplier;
} catch (error) {
this.#logger.error('Failed to fetch take away supplier', error);
throw error;
}
const takeAwaySupplier = res.result?.find(
(supplier) => supplier.supplierNumber === 'F',
);
if (!takeAwaySupplier) {
const notFoundError = new Error('Take away supplier (F) not found');
this.#logger.error('Take away supplier not found', notFoundError, () => ({
availableSuppliers: res.result?.map((s) => s.supplierNumber),
}));
throw notFoundError;
}
this.#logger.debug('Take away supplier fetched', () => ({
supplierId: takeAwaySupplier.id,
supplierNumber: takeAwaySupplier.supplierNumber,
}));
return takeAwaySupplier;
}
}

View File

@@ -66,7 +66,8 @@ export class RewardShoppingCartItemQuantityControlComponent {
if (
orderType === OrderTypeFeature.Delivery ||
orderType === OrderTypeFeature.DigitalShipping ||
orderType === OrderTypeFeature.B2BShipping
orderType === OrderTypeFeature.B2BShipping ||
orderType === OrderTypeFeature.Pickup
) {
return 999;
}

View File

@@ -37,11 +37,20 @@
</div>
</div>
@if (quantityControl.maxQuantity() < 2 && !isDownload()) {
<div
class="text-isa-accent-red isa-text-body-2-bold flex flex-row items-center gap-2"
>
<ng-icon name="isaOtherInfo" size="1.5rem"></ng-icon>
<div>Geringer Bestand - Artikel holen vor Abschluss</div>
</div>
@if (!isDownload()) {
@if (showLowStockMessage()) {
<div
class="text-isa-accent-red isa-text-body-2-bold flex flex-row items-center gap-2"
>
<ng-icon name="isaOtherInfo" size="1.5rem"></ng-icon>
<div>{{ inStock() }} Exemplare sofort lieferbar</div>
</div>
} @else if (quantityControl.maxQuantity() < 2) {
<div
class="text-isa-accent-red isa-text-body-2-bold flex flex-row items-center gap-2"
>
<ng-icon name="isaOtherInfo" size="1.5rem"></ng-icon>
<div>Geringer Bestand - Artikel holen vor Abschluss</div>
</div>
}
}

View File

@@ -72,6 +72,16 @@ export class RewardShoppingCartItemComponent {
hasOrderTypeFeature(this.item().features, ['Download']),
);
isAbholung = computed(() =>
hasOrderTypeFeature(this.item().features, ['Abholung']),
);
inStock = computed(() => this.item().availability?.inStock ?? 0);
showLowStockMessage = computed(() => {
return this.isAbholung() && this.inStock() < 2;
});
async updatePurchaseOption() {
const shoppingCartItemId = this.itemId();
const shoppingCartId = this.shoppingCartId();

View File

@@ -1,3 +1,3 @@
:host {
@apply text-isa-accent-red isa-text-body-2-bold flex flex-row gap-2 items-center;
@apply text-isa-accent-red isa-text-body-2-bold flex flex-col gap-2 items-start;
}

View File

@@ -1,8 +1,10 @@
@if (store.totalLoyaltyPointsNeeded() > store.customerRewardPoints()) {
<ng-icon
class="w-6 h-6 inline-flex items-center justify-center"
size="1.5rem"
name="isaOtherInfo"
></ng-icon>
<span>Lesepunkte reichen nicht für alle Artikel</span>
<div class="flex flex-row gap-2 items-center">
<ng-icon
class="w-6 h-6 inline-flex items-center justify-center"
size="1.5rem"
name="isaOtherInfo"
></ng-icon>
<span>Lesepunkte reichen nicht für alle Artikel</span>
</div>
}

View File

@@ -41,10 +41,7 @@ export class RewardSelectionInputsComponent {
hasCorrectOrderType = computed(() => {
const item = this.rewardSelectionItem().item;
return hasOrderTypeFeature(item.features, [
OrderTypeFeature.InStore,
OrderTypeFeature.Pickup,
]);
return hasOrderTypeFeature(item.features, [OrderTypeFeature.InStore]);
});
hasStock = computed(() => {

View File

@@ -27,3 +27,16 @@
<lib-reward-selection-inputs></lib-reward-selection-inputs>
</div>
@if (showLowStockMessage()) {
<div
class="flex flex-row gap-2 items-center text-isa-accent-red isa-text-body-2-bold"
>
<ng-icon
class="w-6 h-6 inline-flex items-center justify-center"
size="1.5rem"
name="isaOtherInfo"
></ng-icon>
<span>{{ inStock() }} Exemplare sofort lieferbar</span>
</div>
}

View File

@@ -1,8 +1,18 @@
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
import {
ChangeDetectionStrategy,
Component,
computed,
input,
} from '@angular/core';
import { ProductImageDirective } from '@isa/shared/product-image';
import { ProductRouterLinkDirective } from '@isa/shared/product-router-link';
import { RewardSelectionInputsComponent } from './reward-selection-inputs/reward-selection-inputs.component';
import { RewardSelectionItem } from '@isa/checkout/data-access';
import {
hasOrderTypeFeature,
RewardSelectionItem,
} from '@isa/checkout/data-access';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { isaOtherInfo } from '@isa/icons';
@Component({
selector: 'lib-reward-selection-item',
@@ -13,8 +23,24 @@ import { RewardSelectionItem } from '@isa/checkout/data-access';
ProductImageDirective,
ProductRouterLinkDirective,
RewardSelectionInputsComponent,
NgIcon,
],
providers: [provideIcons({ isaOtherInfo })],
})
export class RewardSelectionItemComponent {
rewardSelectionItem = input.required<RewardSelectionItem>();
inStock = computed(
() => this.rewardSelectionItem().item?.availability?.inStock ?? 0,
);
isAbholung = computed(() =>
hasOrderTypeFeature(this.rewardSelectionItem()?.item?.features, [
'Abholung',
]),
);
showLowStockMessage = computed(() => {
return this.isAbholung() && this.inStock() < 2;
});
}

View File

@@ -34,8 +34,15 @@ export const catchResponseArgsErrorPipe = <T>(): OperatorFunction<T, T> =>
return throwError(() => err);
}),
mergeMap((response) => {
if (isResponseArgs(response) && response.error === true) {
return throwError(() => new ResponseArgsError(response));
if (isResponseArgs(response)) {
// Treat as error if error flag is true OR if invalidProperties has entries
const hasInvalidProps =
response.invalidProperties &&
Object.keys(response.invalidProperties).length > 0;
if (response.error === true || hasInvalidProps) {
return throwError(() => new ResponseArgsError(response));
}
}
return [response];

View File

@@ -2,6 +2,7 @@ import { Injectable, inject, resource, signal, computed } from '@angular/core';
import { logger } from '@isa/core/logging';
import { CustomerBonRedemptionFacade } from '../facades/customer-bon-redemption.facade';
import { LoyaltyBonResponse } from '@generated/swagger/crm-api';
import { ResponseArgsError } from '@isa/common/data-access';
/**
* Resource for checking/validating Bon numbers.
@@ -47,8 +48,24 @@ export class CustomerBonCheckResource {
this.#logger.debug('Bon checked', () => ({
bonNr,
found: !!response?.result,
hasInvalidProperties:
!!response?.invalidProperties &&
Object.keys(response.invalidProperties).length > 0,
}));
// Check for invalidProperties even when error is false
// Backend may return { error: false, invalidProperties: {...} } for validation issues
if (
response?.invalidProperties &&
Object.keys(response.invalidProperties).length > 0
) {
this.#logger.warn('Bon check has invalid properties', () => ({
bonNr,
invalidProperties: response.invalidProperties,
}));
throw new ResponseArgsError(response);
}
return response?.result;
},
defaultValue: undefined,

View File

@@ -1,17 +1,18 @@
import { CountryService as ApiCountryService } from '@generated/swagger/crm-api';
import { Cache, CacheTimeToLive } from '@isa/common/decorators';
import { Cache } from '@isa/common/decorators';
import { inject, Injectable } from '@angular/core';
import { Country } from '../models';
import {
catchResponseArgsErrorPipe,
ResponseArgs,
takeUntilAborted,
} from '@isa/common/data-access';
import { firstValueFrom } from 'rxjs';
import { logger } from '@isa/core/logging';
@Injectable({ providedIn: 'root' })
export class CountryService {
#apiCountryService = inject(ApiCountryService);
#logger = logger(() => ({ service: 'CountryService' }));
@Cache()
async getCountries(abortSignal?: AbortSignal): Promise<Country[]> {
@@ -23,8 +24,12 @@ export class CountryService {
req$ = req$.pipe(catchResponseArgsErrorPipe());
const res = await firstValueFrom(req$);
return res.result as Country[];
try {
const res = await firstValueFrom(req$);
return res.result as Country[];
} catch (error) {
this.#logger.error('Failed to get countries', error);
throw error;
}
}
}

View File

@@ -30,7 +30,6 @@ import {
import {
catchResponseArgsErrorPipe,
ResponseArgs,
ResponseArgsError,
takeUntilAborted,
} from '@isa/common/data-access';
import { Cache, CacheTimeToLive } from '@isa/common/decorators';
@@ -161,8 +160,15 @@ export class CrmSearchService {
})
.pipe(catchResponseArgsErrorPipe());
const res = await firstValueFrom(req$);
return res?.result;
try {
const res = await firstValueFrom(req$);
return res?.result;
} catch (error) {
this.#logger.error('Error adding customer card', error, () => ({
customerId: parsed.customerId,
}));
throw error;
}
}
async lockCard(params: LockCardInput): Promise<boolean | undefined> {
@@ -176,15 +182,15 @@ export class CrmSearchService {
})
.pipe(catchResponseArgsErrorPipe());
const res = await firstValueFrom(req$);
if (res.error) {
const err = new ResponseArgsError(res);
this.#logger.error('Lock card Failed', err);
throw err;
try {
const res = await firstValueFrom(req$);
return res?.result;
} catch (error) {
this.#logger.error('Error locking customer card', error, () => ({
cardCode: parsed.cardCode,
}));
throw error;
}
return res?.result;
}
async unlockCard(params: UnlockCardInput): Promise<boolean | undefined> {
@@ -199,15 +205,16 @@ export class CrmSearchService {
})
.pipe(catchResponseArgsErrorPipe());
const res = await firstValueFrom(req$);
if (res.error) {
const err = new ResponseArgsError(res);
this.#logger.error('Unlock card Failed', err);
throw err;
try {
const res = await firstValueFrom(req$);
return res?.result;
} catch (error) {
this.#logger.error('Error unlocking customer card', error, () => ({
customerId: parsed.customerId,
cardCode: parsed.cardCode,
}));
throw error;
}
return res?.result;
}
@Cache({ ttl: CacheTimeToLive.oneHour })
@@ -227,11 +234,10 @@ export class CrmSearchService {
try {
const res = await firstValueFrom(req$);
this.#logger.debug('Successfully fetched current booking partner store');
return res?.result;
} catch (error) {
this.#logger.error('Error fetching current booking partner store', error);
return undefined;
throw error;
}
}
@@ -250,8 +256,16 @@ export class CrmSearchService {
},
})
.pipe(catchResponseArgsErrorPipe());
const res = await firstValueFrom(req$);
return res?.result;
try {
const res = await firstValueFrom(req$);
return res?.result;
} catch (error) {
this.#logger.error('Error adding booking', error, () => ({
cardCode: parsed.cardCode,
}));
throw error;
}
}
/**
@@ -278,16 +292,12 @@ export class CrmSearchService {
try {
const res = await firstValueFrom(req$);
this.#logger.debug('Successfully checked Bon');
if (res.error) {
const err = new ResponseArgsError(res);
this.#logger.error('Bon check failed', err);
throw err;
}
return res as ResponseArgs<LoyaltyBonResponse>;
} catch (error) {
this.#logger.error('Error checking Bon', error);
this.#logger.error('Error checking Bon', error, () => ({
cardCode,
bonNr,
}));
throw error;
}
}
@@ -306,8 +316,16 @@ export class CrmSearchService {
})
.pipe(catchResponseArgsErrorPipe());
const res = await firstValueFrom(req$);
this.#logger.debug('Successfully redeemed Bon');
return res?.result ?? false;
try {
const res = await firstValueFrom(req$);
this.#logger.debug('Successfully redeemed Bon');
return res?.result ?? false;
} catch (error) {
this.#logger.error('Error redeeming Bon', error, () => ({
cardCode,
bonNr,
}));
throw error;
}
}
}

View File

@@ -114,12 +114,7 @@ export class CrmFeatureCustomerBonRedemptionComponent {
}
// Handle API errors
else if (error) {
let errorMsg = 'Bon-Validierung fehlgeschlagen';
if (error instanceof ResponseArgsError) {
errorMsg = error.message || errorMsg;
} else if (error instanceof Error) {
errorMsg = error.message;
}
const errorMsg = this.#extractErrorMessage(error);
this.store.setError(errorMsg);
}
});
@@ -224,4 +219,23 @@ export class CrmFeatureCustomerBonRedemptionComponent {
this.store.reset();
this.#bonCheckResource.reset();
}
/**
* Extract error message from various error types.
* ResponseArgsError already formats invalidProperties into a readable message.
*/
#extractErrorMessage(error: unknown): string {
const defaultMsg = 'Bon-Validierung fehlgeschlagen';
const actualError = (error as { cause?: unknown })?.cause ?? error;
if (actualError instanceof ResponseArgsError) {
return actualError.message || defaultMsg;
}
if (actualError instanceof Error) {
return actualError.message || defaultMsg;
}
return defaultMsg;
}
}

View File

@@ -9,7 +9,10 @@ import {
import { logger } from '@isa/core/logging';
import { ReceiptService } from '@generated/swagger/oms-api';
import { firstValueFrom } from 'rxjs';
import { ResponseArgsError, takeUntilAborted } from '@isa/common/data-access';
import {
catchResponseArgsErrorPipe,
takeUntilAborted,
} from '@isa/common/data-access';
import { Receipt } from '../models';
@Injectable()
@@ -74,29 +77,31 @@ export class HandleCommandService {
parsed,
}));
let req$ = this.#receiptService.ReceiptGetReceiptsByOrderItemSubset({
payload: parsed, // Payload Default from old Implementation, eagerLoading: 1 and receiptType: (1 + 64 + 128) set as Schema default
});
let req$ = this.#receiptService
.ReceiptGetReceiptsByOrderItemSubset({
payload: parsed, // Payload Default from old Implementation, eagerLoading: 1 and receiptType: (1 + 64 + 128) set as Schema default
})
.pipe(catchResponseArgsErrorPipe());
if (abortSignal) {
req$ = req$.pipe(takeUntilAborted(abortSignal));
}
const res = await firstValueFrom(req$);
try {
const res = await firstValueFrom(req$);
if (res.error) {
const err = new ResponseArgsError(res);
// Mapping Logic from old implementation
const mappedReceipts =
res?.result?.map((r) => r.item3?.data).filter((f) => !!f) ?? [];
return mappedReceipts as Receipt[];
} catch (error) {
this.#logger.error(
'Failed to fetch receipts by order item subset IDs',
err,
error,
() => ({ ids: parsed.ids }),
);
throw err;
throw error;
}
// Mapping Logic from old implementation
const mappedReceipts =
res?.result?.map((r) => r.item3?.data).filter((f) => !!f) ?? [];
return mappedReceipts as Receipt[];
}
}

View File

@@ -1,6 +1,9 @@
import { inject, Injectable } from '@angular/core';
import { LogisticianService as GeneratedLogisticianService } from '@generated/swagger/oms-api';
import { ResponseArgsError, takeUntilAborted } from '@isa/common/data-access';
import {
catchResponseArgsErrorPipe,
takeUntilAborted,
} from '@isa/common/data-access';
import { logger } from '@isa/core/logging';
import { firstValueFrom } from 'rxjs';
import { Cache, CacheTimeToLive, InFlight } from '@isa/common/decorators';
@@ -32,37 +35,38 @@ export class LogisticianService {
async getLogistician2470(abortSignal?: AbortSignal): Promise<Logistician> {
this.#logger.debug('Fetching logistician 2470');
let req$ = this.#logisticianService.LogisticianGetLogisticians({});
let req$ = this.#logisticianService
.LogisticianGetLogisticians({})
.pipe(catchResponseArgsErrorPipe());
if (abortSignal) {
req$ = req$.pipe(takeUntilAborted(abortSignal));
}
const res = await firstValueFrom(req$);
try {
const res = await firstValueFrom(req$);
if (res.error || !res.result) {
const error = new ResponseArgsError(res);
this.#logger.error('Failed to fetch logisticians', error);
const logistician = res.result?.find(
(l) => l.logisticianNumber === '2470',
);
if (!logistician) {
const notFoundError = new Error('Logistician 2470 not found');
this.#logger.error('Logistician 2470 not found', notFoundError, () => ({
availableLogisticians: res.result?.map((l) => l.logisticianNumber),
}));
throw notFoundError;
}
this.#logger.debug('Logistician 2470 fetched', () => ({
logisticianId: logistician.id,
logisticianNumber: logistician.logisticianNumber,
}));
return logistician;
} catch (error) {
this.#logger.error('Failed to fetch logistician 2470', error);
throw error;
}
const logistician = res.result.find(
(l) => l.logisticianNumber === '2470',
);
if (!logistician) {
const notFoundError = new Error('Logistician 2470 not found');
this.#logger.error('Logistician 2470 not found', notFoundError, () => ({
availableLogisticians: res.result?.map((l) => l.logisticianNumber),
}));
throw notFoundError;
}
this.#logger.debug('Logistician 2470 fetched', () => ({
logisticianId: logistician.id,
logisticianNumber: logistician.logisticianNumber,
}));
return logistician;
}
}

View File

@@ -4,7 +4,10 @@ import {
DBHOrderItemListItemDTO,
QueryTokenDTO,
} from '@generated/swagger/oms-api';
import { ResponseArgsError, takeUntilAborted } from '@isa/common/data-access';
import {
catchResponseArgsErrorPipe,
takeUntilAborted,
} from '@isa/common/data-access';
import { logger } from '@isa/core/logging';
import { firstValueFrom } from 'rxjs';
@@ -49,26 +52,26 @@ export class OpenRewardTasksService {
orderBy: [],
};
let req$ = this.#abholfachService.AbholfachWarenausgabe(payload);
let req$ = this.#abholfachService
.AbholfachWarenausgabe(payload)
.pipe(catchResponseArgsErrorPipe());
if (abortSignal) {
req$ = req$.pipe(takeUntilAborted(abortSignal));
}
const res = await firstValueFrom(req$);
try {
const res = await firstValueFrom(req$);
const tasks = res.result ?? [];
if (res.error) {
const error = new ResponseArgsError(res);
this.#logger.debug('Open reward tasks fetched', () => ({
taskCount: tasks.length,
}));
return tasks;
} catch (error) {
this.#logger.error('Failed to fetch open reward tasks', error);
throw error;
}
const tasks = res.result ?? [];
this.#logger.debug('Open reward tasks fetched', () => ({
taskCount: tasks.length,
}));
return tasks;
}
}

View File

@@ -4,7 +4,10 @@ import {
LogisticianService,
LogisticianDTO,
} from '@generated/swagger/oms-api';
import { ResponseArgsError, takeUntilAborted } from '@isa/common/data-access';
import {
catchResponseArgsErrorPipe,
takeUntilAborted,
} from '@isa/common/data-access';
import { logger } from '@isa/core/logging';
import { firstValueFrom } from 'rxjs';
import { DisplayOrder } from '../models';
@@ -36,19 +39,23 @@ export class OrderCreationService {
throw new Error(`Invalid checkoutId: ${checkoutId}`);
}
const req$ = this.#orderCheckoutService.OrderCheckoutCreateOrderPOST({
checkoutId,
});
const req$ = this.#orderCheckoutService
.OrderCheckoutCreateOrderPOST({
checkoutId,
})
.pipe(catchResponseArgsErrorPipe());
const res = await firstValueFrom(req$);
if (res.error) {
const error = new ResponseArgsError(res);
this.#logger.error('Failed to create orders', error);
try {
const res = await firstValueFrom(req$);
return res.result as DisplayOrder[];
} catch (error) {
this.#logger.error(
'Failed to create orders from checkout',
error,
() => ({ checkoutId }),
);
throw error;
}
return res.result as DisplayOrder[];
}
/**
@@ -63,25 +70,29 @@ export class OrderCreationService {
logisticianNumber = '2470',
abortSignal?: AbortSignal,
): Promise<LogisticianDTO> {
let req$ = this.#logisticianService.LogisticianGetLogisticians({});
let req$ = this.#logisticianService
.LogisticianGetLogisticians({})
.pipe(catchResponseArgsErrorPipe());
if (abortSignal) req$ = req$.pipe(takeUntilAborted(abortSignal));
const res = await firstValueFrom(req$);
try {
const res = await firstValueFrom(req$);
if (res.error) {
const error = new ResponseArgsError(res);
this.#logger.error('Failed to get logistician', error);
const logistician = res.result?.find(
(l) => l.logisticianNumber === logisticianNumber,
);
if (!logistician) {
throw new Error(`Logistician ${logisticianNumber} not found`);
}
return logistician;
} catch (error) {
this.#logger.error('Failed to get logistician', error, () => ({
logisticianNumber,
}));
throw error;
}
const logistician = res.result?.find(
(l) => l.logisticianNumber === logisticianNumber,
);
if (!logistician) {
throw new Error(`Logistician ${logisticianNumber} not found`);
}
return logistician;
}
}

View File

@@ -1,6 +1,9 @@
import { inject, Injectable } from '@angular/core';
import { OrderService } from '@generated/swagger/oms-api';
import { ResponseArgsError, takeUntilAborted } from '@isa/common/data-access';
import {
catchResponseArgsErrorPipe,
takeUntilAborted,
} from '@isa/common/data-access';
import { logger } from '@isa/core/logging';
import { firstValueFrom } from 'rxjs';
import {
@@ -25,25 +28,28 @@ export class OrderRewardCollectService {
throw error;
}
const req$ = this.#orderService.OrderLoyaltyCollect({
orderId: params.orderId,
orderItemId: params.orderItemId,
orderItemSubsetId: params.orderItemSubsetId,
data: {
collectType: params.collectType,
quantity: params.quantity,
},
});
const req$ = this.#orderService
.OrderLoyaltyCollect({
orderId: params.orderId,
orderItemId: params.orderItemId,
orderItemSubsetId: params.orderItemSubsetId,
data: {
collectType: params.collectType,
quantity: params.quantity,
},
})
.pipe(catchResponseArgsErrorPipe());
const res = await firstValueFrom(req$);
if (res.error) {
const error = new ResponseArgsError(res);
this.#logger.error('Failed to collect reward item', error);
try {
const res = await firstValueFrom(req$);
return res.result as DBHOrderItemListItem[];
} catch (error) {
this.#logger.error('Failed to collect order reward', error, () => ({
orderId: params.orderId,
orderItemSubsetId: params.orderItemSubsetId,
}));
throw error;
}
return res.result as DBHOrderItemListItem[];
}
async fetchOrderItemSubset(
@@ -57,22 +63,22 @@ export class OrderRewardCollectService {
throw error;
}
let req$ = this.#orderService.OrderGetOrderItemSubset(
params.orderItemSubsetId,
);
let req$ = this.#orderService
.OrderGetOrderItemSubset(params.orderItemSubsetId)
.pipe(catchResponseArgsErrorPipe());
if (abortSignal) {
req$ = req$.pipe(takeUntilAborted(abortSignal));
}
const res = await firstValueFrom(req$);
if (res.error) {
const error = new ResponseArgsError(res);
this.#logger.error('Failed to fetch order item subset', error);
try {
const res = await firstValueFrom(req$);
return res.result as DisplayOrderItemSubset;
} catch (error) {
this.#logger.error('Failed to fetch order item subset', error, () => ({
orderItemSubsetId: params.orderItemSubsetId,
}));
throw error;
}
return res.result as DisplayOrderItemSubset;
}
}

View File

@@ -4,7 +4,10 @@ import {
OrderDTO,
DisplayOrderDTO,
} from '@generated/swagger/oms-api';
import { ResponseArgsError, takeUntilAborted } from '@isa/common/data-access';
import {
catchResponseArgsErrorPipe,
takeUntilAborted,
} from '@isa/common/data-access';
import { logger } from '@isa/core/logging';
import { firstValueFrom } from 'rxjs';
@@ -20,21 +23,23 @@ export class OrdersService {
orderId: number,
abortSignal?: AbortSignal,
): Promise<OrderDTO | null> {
let req$ = this.#orderService.OrderGetOrder(orderId);
let req$ = this.#orderService
.OrderGetOrder(orderId)
.pipe(catchResponseArgsErrorPipe());
if (abortSignal) {
req$ = req$.pipe(takeUntilAborted(abortSignal));
}
const res = await firstValueFrom(req$);
if (res.error) {
const error = new ResponseArgsError(res);
this.#logger.error('Failed to fetch order', { orderId, error });
try {
const res = await firstValueFrom(req$);
return res.result ?? null;
} catch (error) {
this.#logger.error('Failed to get order', error, () => ({
orderId,
}));
throw error;
}
return res.result ?? null;
}
/**
@@ -66,21 +71,23 @@ export class OrdersService {
orderId: number,
abortSignal?: AbortSignal,
): Promise<DisplayOrderDTO | null> {
let req$ = this.#orderService.OrderGetDisplayOrder(orderId);
let req$ = this.#orderService
.OrderGetDisplayOrder(orderId)
.pipe(catchResponseArgsErrorPipe());
if (abortSignal) {
req$ = req$.pipe(takeUntilAborted(abortSignal));
}
const res = await firstValueFrom(req$);
if (res.error) {
const error = new ResponseArgsError(res);
this.#logger.error('Failed to fetch display order', { orderId, error });
try {
const res = await firstValueFrom(req$);
return res.result ?? null;
} catch (error) {
this.#logger.error('Failed to get display order', error, () => ({
orderId,
}));
throw error;
}
return res.result ?? null;
}
/**

View File

@@ -12,7 +12,6 @@ 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
@@ -36,20 +35,14 @@ 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,
abortSignal?: AbortSignal,
): Promise<CanReturn | undefined>;
async canReturn(returnProcess: ReturnProcess): 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,
abortSignal?: AbortSignal,
): Promise<CanReturn>;
async canReturn(returnValues: ReturnReceiptValues): Promise<CanReturn>;
/**
* Determines if a return can proceed, accepting either a ReturnProcess or ReturnReceiptValues.
@@ -60,7 +53,6 @@ export class ReturnCanReturnService {
*/
async canReturn(
input: ReturnProcess | ReturnReceiptValues,
abortSignal?: AbortSignal,
): Promise<CanReturn | undefined> {
let data: ReturnReceiptValues | undefined = undefined;
@@ -74,14 +66,10 @@ export class ReturnCanReturnService {
return undefined; // Prozess soll weitergehen, daher kein Error
}
let req$ = this.#receiptService.ReceiptCanReturn(
const req$ = this.#receiptService.ReceiptCanReturn(
data as ReturnReceiptValuesDTO,
);
if (abortSignal) {
req$ = req$.pipe(takeUntilAborted(abortSignal));
}
try {
return await firstValueFrom(
req$.pipe(

View File

@@ -32,19 +32,19 @@ export class ReturnDetailsService {
* @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.
* @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.
* @throws Will throw an error if the return check fails.
*/
async canReturn(
{
receiptItemId,
quantity,
category,
}: { receiptItemId: number; quantity: number; category: ProductCategory },
abortSignal?: AbortSignal,
): Promise<CanReturn> {
async canReturn({
receiptItemId,
quantity,
category,
}: {
receiptItemId: number;
quantity: number;
category: ProductCategory;
}): Promise<CanReturn> {
const returnReceiptValues: ReturnReceiptValues = {
quantity,
receiptItem: {
@@ -53,10 +53,7 @@ export class ReturnDetailsService {
category,
};
return this.#returnCanReturnService.canReturn(
returnReceiptValues,
abortSignal,
);
return this.#returnCanReturnService.canReturn(returnReceiptValues);
}
/**
* Gets all available product categories that have defined question sets.

View File

@@ -176,7 +176,7 @@ export const ReturnDetailsStore = signalStore(
category,
};
},
loader: async ({ params, abortSignal }) => {
loader: async ({ params }) => {
if (params === undefined) {
return undefined;
}
@@ -186,10 +186,7 @@ export const ReturnDetailsStore = signalStore(
return store.canReturn()[key];
}
const res = await store._returnDetailsService.canReturn(
params,
abortSignal,
);
const res = await store._returnDetailsService.canReturn(params);
patchState(store, {
canReturn: { ...store.canReturn(), [key]: res },
});

View File

@@ -1,6 +1,9 @@
import { inject, Injectable } from '@angular/core';
import { StockService, BranchDTO } from '@generated/swagger/inventory-api';
import { ResponseArgsError, takeUntilAborted } from '@isa/common/data-access';
import {
catchResponseArgsErrorPipe,
takeUntilAborted,
} from '@isa/common/data-access';
import { logger } from '@isa/core/logging';
import { firstValueFrom } from 'rxjs';
@@ -27,41 +30,42 @@ export class BranchService {
* @throws {Error} If branch retrieval fails
*/
async getDefaultBranch(abortSignal?: AbortSignal): Promise<BranchDTO> {
let req$ = this.#stockService.StockCurrentBranch();
let req$ = this.#stockService
.StockCurrentBranch()
.pipe(catchResponseArgsErrorPipe());
if (abortSignal) req$ = req$.pipe(takeUntilAborted(abortSignal));
const res = await firstValueFrom(req$);
try {
const res = await firstValueFrom(req$);
const branch = res.result;
if (res.error) {
const error = new ResponseArgsError(res);
if (!branch) {
const error = new Error('No branch data returned');
this.#logger.error('Failed to get default branch', error);
throw error;
}
return {
id: branch.id,
name: branch.name,
address: branch.address,
branchType: branch.branchType,
branchNumber: branch.branchNumber,
changed: branch.changed,
created: branch.created,
isDefault: branch.isDefault,
isOnline: branch.isOnline,
key: branch.key,
label: branch.label,
pId: branch.pId,
shortName: branch.shortName,
status: branch.status,
version: branch.version,
};
} catch (error) {
this.#logger.error('Failed to get default branch', error);
throw error;
}
const branch = res.result;
if (!branch) {
const error = new Error('No branch data returned');
this.#logger.error('Failed to get default branch', error);
throw error;
}
return {
id: branch.id,
name: branch.name,
address: branch.address,
branchType: branch.branchType,
branchNumber: branch.branchNumber,
changed: branch.changed,
created: branch.created,
isDefault: branch.isDefault,
isOnline: branch.isOnline,
key: branch.key,
label: branch.label,
pId: branch.pId,
shortName: branch.shortName,
status: branch.status,
version: branch.version,
};
}
}

View File

@@ -4,7 +4,10 @@ import { KeyValueStringAndString } from '../models';
import { firstValueFrom } from 'rxjs';
import { logger } from '@isa/core/logging';
import { RemissionStockService } from './remission-stock.service';
import { ResponseArgsError, takeUntilAborted } from '@isa/common/data-access';
import {
catchResponseArgsErrorPipe,
takeUntilAborted,
} from '@isa/common/data-access';
import { InFlight, Cache, CacheTimeToLive } from '@isa/common/decorators';
/**
@@ -65,26 +68,29 @@ export class RemissionProductGroupService {
stockId: assignedStock.id,
}));
let req$ = this.#remiService.RemiProductgroups({
stockId: assignedStock.id,
});
let req$ = this.#remiService
.RemiProductgroups({
stockId: assignedStock.id,
})
.pipe(catchResponseArgsErrorPipe());
if (abortSignal) {
req$ = req$.pipe(takeUntilAborted(abortSignal));
}
const res = await firstValueFrom(req$);
try {
const res = await firstValueFrom(req$);
if (res.error || !res.result) {
const error = new ResponseArgsError(res);
this.#logger.error('Failed to fetch product groups', error);
this.#logger.debug('Successfully fetched product groups', () => ({
groupCount: res.result?.length || 0,
}));
return res.result as KeyValueStringAndString[];
} catch (error) {
this.#logger.error('Failed to fetch product groups', error, () => ({
stockId: assignedStock.id,
}));
throw error;
}
this.#logger.debug('Successfully fetched product groups', () => ({
groupCount: res.result?.length || 0,
}));
return res.result as KeyValueStringAndString[];
}
}

View File

@@ -1,6 +1,9 @@
import { inject, Injectable } from '@angular/core';
import { ReturnService } from '@generated/swagger/inventory-api';
import { ResponseArgsError, takeUntilAborted } from '@isa/common/data-access';
import {
catchResponseArgsErrorPipe,
takeUntilAborted,
} from '@isa/common/data-access';
import { firstValueFrom } from 'rxjs';
import { KeyValueStringAndString } from '../models';
import { logger } from '@isa/core/logging';
@@ -74,29 +77,30 @@ export class RemissionReasonService {
stockId: assignedStock?.id,
}));
let req$ = this.#returnService.ReturnGetReturnReasons({
stockId: assignedStock?.id,
});
let req$ = this.#returnService
.ReturnGetReturnReasons({
stockId: assignedStock?.id,
})
.pipe(catchResponseArgsErrorPipe());
if (abortSignal) {
this.#logger.debug('Request configured with abort signal');
req$ = req$.pipe(takeUntilAborted(abortSignal));
}
const res = await firstValueFrom(req$);
try {
const res = await firstValueFrom(req$);
if (res.error) {
this.#logger.error(
'Failed to fetch return reasons',
new Error(res.message || 'Unknown error'),
);
throw new ResponseArgsError(res);
this.#logger.debug('Successfully fetched return reasons', () => ({
reasonCount: res.result?.length || 0,
}));
return res.result as KeyValueStringAndString[];
} catch (error) {
this.#logger.error('Failed to fetch return reasons', error, () => ({
stockId: assignedStock?.id,
}));
throw error;
}
this.#logger.debug('Successfully fetched return reasons', () => ({
reasonCount: res.result?.length || 0,
}));
return res.result as KeyValueStringAndString[];
}
}

View File

@@ -540,15 +540,15 @@ describe('RemissionReturnReceiptService', () => {
});
});
it('should handle abort signal', async () => {
it('should call API with correct parameters', async () => {
mockReturnService.ReturnCreateAndAssignPackage.mockReturnValue(
of({ result: mockReceipt, error: null }),
);
const abortController = new AbortController();
await service.assignPackage(
{ returnId: 123, receiptId: 456, packageNumber: 'PKG-789' },
abortController.signal,
);
await service.assignPackage({
returnId: 123,
receiptId: 456,
packageNumber: 'PKG-789',
});
expect(mockReturnService.ReturnCreateAndAssignPackage).toHaveBeenCalled();
});
@@ -1052,24 +1052,20 @@ describe('RemissionReturnReceiptService', () => {
});
});
it('should handle abort signal', async () => {
it('should call API with correct parameters', async () => {
// Arrange
mockReturnService.ReturnAddReturnItem.mockReturnValue(
of({ result: mockTuple, error: null }),
);
const abortController = new AbortController();
// Act
await service.addReturnItem(
{
returnId: 1,
receiptId: 2,
returnItemId: 3,
quantity: 4,
inStock: 5,
},
abortController.signal,
);
await service.addReturnItem({
returnId: 1,
receiptId: 2,
returnItemId: 3,
quantity: 4,
inStock: 5,
});
// Assert
expect(mockReturnService.ReturnAddReturnItem).toHaveBeenCalled();
@@ -1163,24 +1159,20 @@ describe('RemissionReturnReceiptService', () => {
});
});
it('should handle abort signal', async () => {
it('should call API with correct parameters', async () => {
// Arrange
mockReturnService.ReturnAddReturnSuggestion.mockReturnValue(
of({ result: mockTuple, error: null }),
);
const abortController = new AbortController();
// Act
await service.addReturnSuggestionItem(
{
returnId: 1,
receiptId: 2,
returnSuggestionId: 3,
quantity: 4,
inStock: 5,
},
abortController.signal,
);
await service.addReturnSuggestionItem({
returnId: 1,
receiptId: 2,
returnSuggestionId: 3,
quantity: 4,
inStock: 5,
});
// Assert
expect(mockReturnService.ReturnAddReturnSuggestion).toHaveBeenCalled();

View File

@@ -1,8 +1,8 @@
import { inject, Injectable } from '@angular/core';
import { ReturnService } from '@generated/swagger/inventory-api';
import {
catchResponseArgsErrorPipe,
ResponseArgs,
ResponseArgsError,
takeUntilAborted,
} from '@isa/common/data-access';
import { firstValueFrom } from 'rxjs';
@@ -102,86 +102,21 @@ export class RemissionReturnReceiptService {
req$ = req$.pipe(takeUntilAborted(abortSignal));
}
const res = await firstValueFrom(req$);
try {
const res = await firstValueFrom(req$.pipe(catchResponseArgsErrorPipe()));
if (res?.error) {
this.#logger.error(
'Failed to fetch completed returns',
new Error(res.message || 'Unknown error'),
);
throw new ResponseArgsError(res);
const returns = (res?.result as Return[]) || [];
this.#logger.debug('Successfully fetched completed returns', () => ({
returnCount: returns.length,
}));
return returns;
} catch (error) {
this.#logger.error('Failed to fetch completed returns', error);
throw error;
}
const returns = (res?.result as Return[]) || [];
this.#logger.debug('Successfully fetched completed returns', () => ({
returnCount: returns.length,
}));
return returns;
}
// /**
// * Fetches a specific remission return receipt by receipt and return IDs.
// * Validates parameters using FetchRemissionReturnReceiptSchema before making the request.
// *
// * @async
// * @param {FetchRemissionReturnParams} params - The receipt and return identifiers
// * @param {FetchRemissionReturnParams} params.receiptId - ID of the receipt to fetch
// * @param {FetchRemissionReturnParams} params.returnId - ID of the return containing the receipt
// * @param {AbortSignal} [abortSignal] - Optional signal to abort the request
// * @returns {Promise<Receipt | undefined>} The receipt object if found, undefined otherwise
// * @throws {ResponseArgsError} When the API request fails
// * @throws {z.ZodError} When parameter validation fails
// *
// * @example
// * const receipt = await service.fetchRemissionReturnReceipt({
// * receiptId: '123',
// * returnId: '456'
// * });
// */
// async fetchRemissionReturnReceipt(
// params: FetchRemissionReturnParams,
// abortSignal?: AbortSignal,
// ): Promise<Receipt | undefined> {
// this.#logger.debug('Fetching remission return receipt', () => ({ params }));
// const { receiptId, returnId } =
// FetchRemissionReturnReceiptSchema.parse(params);
// this.#logger.info('Fetching return receipt from API', () => ({
// receiptId,
// returnId,
// }));
// let req$ = this.#returnService.ReturnGetReturnReceipt({
// receiptId,
// returnId,
// eagerLoading: 2,
// });
// if (abortSignal) {
// this.#logger.debug('Request configured with abort signal');
// req$ = req$.pipe(takeUntilAborted(abortSignal));
// }
// const res = await firstValueFrom(req$);
// if (res?.error) {
// this.#logger.error(
// 'Failed to fetch return receipt',
// new Error(res.message || 'Unknown error'),
// );
// throw new ResponseArgsError(res);
// }
// const receipt = res?.result as Receipt | undefined;
// this.#logger.debug('Successfully fetched return receipt', () => ({
// found: !!receipt,
// }));
// return receipt;
// }
/**
* Fetches a remission return by its ID.
* Validates parameters using FetchReturnSchema before making the request.
@@ -218,22 +153,19 @@ export class RemissionReturnReceiptService {
req$ = req$.pipe(takeUntilAborted(abortSignal));
}
const res = await firstValueFrom(req$);
try {
const res = await firstValueFrom(req$.pipe(catchResponseArgsErrorPipe()));
if (res?.error) {
this.#logger.error(
'Failed to fetch return',
new Error(res.message || 'Unknown error'),
);
throw new ResponseArgsError(res);
const returnData = res?.result as Return | undefined;
this.#logger.debug('Successfully fetched return', () => ({
found: !!returnData,
}));
return returnData;
} catch (error) {
this.#logger.error('Failed to fetch return', error);
throw error;
}
const returnData = res?.result as Return | undefined;
this.#logger.debug('Successfully fetched return', () => ({
found: !!returnData,
}));
return returnData;
}
/**
@@ -279,34 +211,26 @@ export class RemissionReturnReceiptService {
returnGroup,
}));
let req$ = this.#returnService.ReturnCreateReturn({
const req$ = this.#returnService.ReturnCreateReturn({
data: {
supplier: { id: firstSupplier.id },
returnGroup,
},
});
if (abortSignal) {
this.#logger.debug('Request configured with abort signal');
req$ = req$.pipe(takeUntilAborted(abortSignal));
try {
const res = await firstValueFrom(req$.pipe(catchResponseArgsErrorPipe()));
const returnResponse = res as ResponseArgs<Return> | undefined;
this.#logger.debug('Successfully created return', () => ({
found: !!returnResponse,
}));
return returnResponse;
} catch (error) {
this.#logger.error('Failed to create return', error);
throw error;
}
const res = await firstValueFrom(req$);
if (res?.error) {
this.#logger.error(
'Failed to create return',
new Error(res.message || 'Unknown error'),
);
throw new ResponseArgsError(res);
}
const returnResponse = res as ResponseArgs<Return> | undefined;
this.#logger.debug('Successfully created return', () => ({
found: !!returnResponse,
}));
return returnResponse;
}
/**
@@ -345,7 +269,7 @@ export class RemissionReturnReceiptService {
receiptNumber,
}));
let req$ = this.#returnService.ReturnCreateReceipt({
const req$ = this.#returnService.ReturnCreateReceipt({
returnId,
data: {
receiptNumber,
@@ -359,27 +283,19 @@ export class RemissionReturnReceiptService {
},
});
if (abortSignal) {
this.#logger.debug('Request configured with abort signal');
req$ = req$.pipe(takeUntilAborted(abortSignal));
try {
const res = await firstValueFrom(req$.pipe(catchResponseArgsErrorPipe()));
const receiptResponse = res as ResponseArgs<Receipt> | undefined;
this.#logger.debug('Successfully created return receipt', () => ({
found: !!receiptResponse,
}));
return receiptResponse;
} catch (error) {
this.#logger.error('Failed to create return receipt', error);
throw error;
}
const res = await firstValueFrom(req$);
if (res?.error) {
this.#logger.error(
'Failed to create return receipt',
new Error(res.message || 'Unknown error'),
);
throw new ResponseArgsError(res);
}
const receiptResponse = res as ResponseArgs<Receipt> | undefined;
this.#logger.debug('Successfully created return receipt', () => ({
found: !!receiptResponse,
}));
return receiptResponse;
}
/**
@@ -402,7 +318,6 @@ export class RemissionReturnReceiptService {
*/
async assignPackage(
params: AssignPackage,
abortSignal?: AbortSignal,
): Promise<ResponseArgs<Receipt> | undefined> {
this.#logger.debug('Assign package to return receipt', () => ({ params }));
@@ -414,7 +329,7 @@ export class RemissionReturnReceiptService {
packageNumber,
}));
let req$ = this.#returnService.ReturnCreateAndAssignPackage({
const req$ = this.#returnService.ReturnCreateAndAssignPackage({
returnId,
receiptId,
data: {
@@ -422,29 +337,21 @@ export class RemissionReturnReceiptService {
},
});
if (abortSignal) {
this.#logger.debug('Request configured with abort signal');
req$ = req$.pipe(takeUntilAborted(abortSignal));
try {
const res = await firstValueFrom(req$.pipe(catchResponseArgsErrorPipe()));
const receiptWithAssignedPackageResponse = res as
| ResponseArgs<Receipt>
| undefined;
this.#logger.debug('Successfully assigned package', () => ({
found: !!receiptWithAssignedPackageResponse,
}));
return receiptWithAssignedPackageResponse;
} catch (error) {
this.#logger.error('Failed to assign package', error);
throw error;
}
const res = await firstValueFrom(req$);
if (res?.error) {
this.#logger.error(
'Failed to assign package',
new Error(res.message || 'Unknown error'),
);
throw new ResponseArgsError(res);
}
const receiptWithAssignedPackageResponse = res as
| ResponseArgs<Receipt>
| undefined;
this.#logger.debug('Successfully assigned package', () => ({
found: !!receiptWithAssignedPackageResponse,
}));
return receiptWithAssignedPackageResponse;
}
async removeReturnItemFromReturnReceipt(params: {
@@ -452,16 +359,15 @@ export class RemissionReturnReceiptService {
receiptId: number;
receiptItemId: number;
}) {
const res = await firstValueFrom(
this.#returnService.ReturnRemoveReturnItem(params),
);
if (res?.error) {
this.#logger.error(
'Failed to remove item from return receipt',
new Error(res.message || 'Unknown error'),
try {
await firstValueFrom(
this.#returnService
.ReturnRemoveReturnItem(params)
.pipe(catchResponseArgsErrorPipe()),
);
throw new ResponseArgsError(res);
} catch (error) {
this.#logger.error('Failed to remove item from return receipt', error);
throw error;
}
}
@@ -480,16 +386,17 @@ export class RemissionReturnReceiptService {
returnId: number;
receiptId: number;
}): Promise<void> {
const res = await firstValueFrom(
this.#returnService.ReturnCancelReturnReceipt(params),
);
if (res?.error) {
this.#logger.error(
'Failed to cancel return receipt',
new Error(res.message || 'Unknown error'),
try {
await firstValueFrom(
this.#returnService
.ReturnCancelReturnReceipt(params)
.pipe(catchResponseArgsErrorPipe()),
);
throw new ResponseArgsError(res);
} catch (error) {
this.#logger.error('Failed to cancel return receipt', error, () => ({
params,
}));
throw error;
}
}
@@ -502,34 +409,36 @@ export class RemissionReturnReceiptService {
* @throws {ResponseArgsError} When the API request fails
*/
async cancelReturn(params: { returnId: number }): Promise<void> {
const res = await firstValueFrom(
this.#returnService.ReturnCancelReturn(params),
);
if (res?.error) {
this.#logger.error(
'Failed to cancel return',
new Error(res.message || 'Unknown error'),
try {
await firstValueFrom(
this.#returnService
.ReturnCancelReturn(params)
.pipe(catchResponseArgsErrorPipe()),
);
throw new ResponseArgsError(res);
} catch (error) {
this.#logger.error('Failed to cancel return', error, () => ({
params,
}));
throw error;
}
}
async deleteReturnItem(params: { itemId: number }) {
this.#logger.debug('Deleting return item', () => ({ params }));
const res = await firstValueFrom(
this.#returnService.ReturnDeleteReturnItem(params),
);
if (res?.error) {
this.#logger.error(
'Failed to delete return item',
new Error(res.message || 'Unknown error'),
try {
const res = await firstValueFrom(
this.#returnService
.ReturnDeleteReturnItem(params)
.pipe(catchResponseArgsErrorPipe()),
);
throw new ResponseArgsError(res);
}
return res?.result as ReturnItem;
return res?.result as ReturnItem;
} catch (error) {
this.#logger.error('Failed to delete return item', error, () => ({
params,
}));
throw error;
}
}
async updateReturnItemImpediment(params: UpdateItemImpediment) {
@@ -537,24 +446,27 @@ export class RemissionReturnReceiptService {
const { itemId, comment } = UpdateItemImpedimentSchema.parse(params);
const res = await firstValueFrom(
this.#returnService.ReturnReturnItemImpediment({
itemId,
data: {
comment,
},
}),
);
try {
const res = await firstValueFrom(
this.#returnService
.ReturnReturnItemImpediment({
itemId,
data: {
comment,
},
})
.pipe(catchResponseArgsErrorPipe()),
);
if (res?.error) {
return res?.result as ReturnItem;
} catch (error) {
this.#logger.error(
'Failed to update return item impediment',
new Error(res.message || 'Unknown error'),
error,
() => ({ itemId, comment }),
);
throw new ResponseArgsError(res);
throw error;
}
return res?.result as ReturnItem;
}
async updateReturnSuggestionImpediment(params: UpdateItemImpediment) {
@@ -562,22 +474,26 @@ export class RemissionReturnReceiptService {
params,
}));
const { itemId, comment } = UpdateItemImpedimentSchema.parse(params);
const res = await firstValueFrom(
this.#returnService.ReturnReturnSuggestionImpediment({
itemId,
data: {
comment,
},
}),
);
if (res?.error) {
try {
const res = await firstValueFrom(
this.#returnService
.ReturnReturnSuggestionImpediment({
itemId,
data: {
comment,
},
})
.pipe(catchResponseArgsErrorPipe()),
);
return res?.result as ReturnSuggestion;
} catch (error) {
this.#logger.error(
'Failed to update return suggestion impediment',
new Error(res.message || 'Unknown error'),
error,
() => ({ itemId, comment }),
);
throw new ResponseArgsError(res);
throw error;
}
return res?.result as ReturnSuggestion;
}
async completeReturnReceipt({
@@ -588,23 +504,25 @@ export class RemissionReturnReceiptService {
receiptId: number;
}): Promise<Receipt> {
this.#logger.debug('Completing return receipt', () => ({ returnId }));
const res = await firstValueFrom(
this.#returnService.ReturnFinalizeReceipt({
try {
const res = await firstValueFrom(
this.#returnService
.ReturnFinalizeReceipt({
returnId,
receiptId,
data: {},
})
.pipe(catchResponseArgsErrorPipe()),
);
return res?.result as Receipt;
} catch (error) {
this.#logger.error('Failed to complete return receipt', error, () => ({
returnId,
receiptId,
data: {},
}),
);
if (res?.error) {
this.#logger.error(
'Failed to complete return receipt',
new Error(res.message || 'Unknown error'),
);
throw new ResponseArgsError(res);
}));
throw error;
}
return res?.result as Receipt;
}
async completeReturn(params: { returnId: number }): Promise<Return> {
@@ -612,23 +530,24 @@ export class RemissionReturnReceiptService {
returnId: params.returnId,
}));
const res = await firstValueFrom(
this.#returnService.ReturnFinalizeReturn(params),
);
if (res?.error) {
this.#logger.error(
'Failed to complete return',
new Error(res.message || 'Unknown error'),
try {
const res = await firstValueFrom(
this.#returnService
.ReturnFinalizeReturn(params)
.pipe(catchResponseArgsErrorPipe()),
);
throw new ResponseArgsError(res);
this.#logger.info('Successfully completed return', () => ({
returnId: params.returnId,
}));
return res?.result as Return;
} catch (error) {
this.#logger.error('Failed to complete return', error, () => ({
returnId: params.returnId,
}));
throw error;
}
this.#logger.info('Successfully completed return', () => ({
returnId: params.returnId,
}));
return res?.result as Return;
}
async completeReturnGroup(params: { returnGroup: string }) {
@@ -636,23 +555,24 @@ export class RemissionReturnReceiptService {
returnId: params.returnGroup,
}));
const res = await firstValueFrom(
this.#returnService.ReturnFinalizeReturnGroup(params),
);
if (res?.error) {
this.#logger.error(
'Failed to complete return group',
new Error(res.message || 'Unknown error'),
try {
const res = await firstValueFrom(
this.#returnService
.ReturnFinalizeReturnGroup(params)
.pipe(catchResponseArgsErrorPipe()),
);
throw new ResponseArgsError(res);
this.#logger.info('Successfully completed return group', () => ({
returnId: params.returnGroup,
}));
return res?.result as Return[];
} catch (error) {
this.#logger.error('Failed to complete return group', error, () => ({
returnGroup: params.returnGroup,
}));
throw error;
}
this.#logger.info('Successfully completed return group', () => ({
returnId: params.returnGroup,
}));
return res?.result as Return[];
}
async completeReturnReceiptAndReturn(params: {
@@ -703,7 +623,6 @@ export class RemissionReturnReceiptService {
*/
async addReturnItem(
params: AddReturnItem,
abortSignal?: AbortSignal,
): Promise<ReceiptReturnTuple | undefined> {
this.#logger.debug('Adding return item', () => ({ params }));
@@ -718,7 +637,7 @@ export class RemissionReturnReceiptService {
inStock,
}));
let req$ = this.#returnService.ReturnAddReturnItem({
const req$ = this.#returnService.ReturnAddReturnItem({
returnId,
receiptId,
data: {
@@ -728,27 +647,23 @@ export class RemissionReturnReceiptService {
},
});
if (abortSignal) {
this.#logger.debug('Request configured with abort signal');
req$ = req$.pipe(takeUntilAborted(abortSignal));
try {
const res = await firstValueFrom(req$.pipe(catchResponseArgsErrorPipe()));
const updatedReturn = res?.result as ReceiptReturnTuple | undefined;
this.#logger.debug('Successfully added return item', () => ({
found: !!updatedReturn,
}));
return updatedReturn;
} catch (error) {
this.#logger.error('Failed to add return item', error, () => ({
returnId,
receiptId,
returnItemId,
}));
throw error;
}
const res = await firstValueFrom(req$);
if (res?.error) {
this.#logger.error(
'Failed to add return item',
new Error(res.message || 'Unknown error'),
);
throw new ResponseArgsError(res);
}
const updatedReturn = res?.result as ReceiptReturnTuple | undefined;
this.#logger.debug('Successfully added return item', () => ({
found: !!updatedReturn,
}));
return updatedReturn;
}
/**
@@ -775,7 +690,6 @@ export class RemissionReturnReceiptService {
*/
async addReturnSuggestionItem(
params: AddReturnSuggestionItem,
abortSignal?: AbortSignal,
): Promise<ReceiptReturnSuggestionTuple | undefined> {
this.#logger.debug('Adding return suggestion item', () => ({ params }));
@@ -799,7 +713,7 @@ export class RemissionReturnReceiptService {
remainingQuantity,
}));
let req$ = this.#returnService.ReturnAddReturnSuggestion({
const req$ = this.#returnService.ReturnAddReturnSuggestion({
returnId,
receiptId,
data: {
@@ -811,29 +725,25 @@ export class RemissionReturnReceiptService {
},
});
if (abortSignal) {
this.#logger.debug('Request configured with abort signal');
req$ = req$.pipe(takeUntilAborted(abortSignal));
try {
const res = await firstValueFrom(req$.pipe(catchResponseArgsErrorPipe()));
const updatedReturnSuggestion = res?.result as
| ReceiptReturnSuggestionTuple
| undefined;
this.#logger.debug('Successfully added return suggestion item', () => ({
found: !!updatedReturnSuggestion,
}));
return updatedReturnSuggestion;
} catch (error) {
this.#logger.error('Failed to add return suggestion item', error, () => ({
returnId,
receiptId,
returnSuggestionId,
}));
throw error;
}
const res = await firstValueFrom(req$);
if (res?.error) {
this.#logger.error(
'Failed to add return suggestion item',
new Error(res.message || 'Unknown error'),
);
throw new ResponseArgsError(res);
}
const updatedReturnSuggestion = res?.result as
| ReceiptReturnSuggestionTuple
| undefined;
this.#logger.debug('Successfully added return suggestion item', () => ({
found: !!updatedReturnSuggestion,
}));
return updatedReturnSuggestion;
}
/**
@@ -853,22 +763,23 @@ export class RemissionReturnReceiptService {
* receiptId: 456,
* });
*/
async createRemission({
returnGroup,
receiptNumber,
}: {
returnGroup: string | undefined;
receiptNumber: string | undefined;
}): Promise<CreateRemission | undefined> {
async createRemission(
{
returnGroup,
receiptNumber,
}: {
returnGroup: string | undefined;
receiptNumber: string | undefined;
},
abortSignal?: AbortSignal,
): Promise<CreateRemission | undefined> {
this.#logger.debug('Create remission', () => ({
returnGroup,
receiptNumber,
}));
const createdReturn: ResponseArgs<Return> | undefined =
await this.createReturn({
returnGroup,
});
await this.createReturn({ returnGroup }, abortSignal);
if (!createdReturn || !createdReturn.result) {
this.#logger.error('Failed to create return for remission');
@@ -876,10 +787,13 @@ export class RemissionReturnReceiptService {
}
const createdReceipt: ResponseArgs<Receipt> | undefined =
await this.createReceipt({
returnId: createdReturn.result.id,
receiptNumber,
});
await this.createReceipt(
{
returnId: createdReturn.result.id,
receiptNumber,
},
abortSignal,
);
if (!createdReceipt || !createdReceipt.result) {
this.#logger.error('Failed to create return receipt');

View File

@@ -19,8 +19,8 @@ import {
import { firstValueFrom } from 'rxjs';
import {
BatchResponseArgs,
catchResponseArgsErrorPipe,
ListResponseArgs,
ResponseArgsError,
takeUntilAborted,
} from '@isa/common/data-access';
import { logger } from '@isa/core/logging';
@@ -122,16 +122,18 @@ export class RemissionSearchService {
},
});
const res = await firstValueFrom(req$);
try {
const res = await firstValueFrom(req$.pipe(catchResponseArgsErrorPipe()));
if (res.error) {
const error = new ResponseArgsError(res);
this.#logger.error('Failed to fetch required capacity', error);
this.#logger.debug('Successfully fetched required capacity');
return (res?.result ?? []) as ValueTupleOfStringAndInteger[];
} catch (error) {
this.#logger.error('Failed to fetch required capacity', error, () => ({
stockId: parsed.stockId,
supplierId: parsed.supplierId,
}));
throw error;
}
this.#logger.debug('Successfully fetched required capacity');
return (res?.result ?? []) as ValueTupleOfStringAndInteger[];
}
/**
@@ -401,14 +403,18 @@ export class RemissionSearchService {
req = req.pipe(takeUntilAborted(abortSignal));
}
const res = await firstValueFrom(req);
if (res.error) {
const error = new ResponseArgsError(res);
this.#logger.error('Failed to check item addition', error);
try {
const res = await firstValueFrom(req.pipe(catchResponseArgsErrorPipe()));
return res as BatchResponseArgs<ReturnItem>;
} catch (error) {
this.#logger.error(
'Failed to check if items can be added to remission list',
error,
() => ({ stockId: stock.id, itemCount: items.length }),
);
throw error;
}
return res as BatchResponseArgs<ReturnItem>;
}
async addToList(
@@ -437,14 +443,17 @@ export class RemissionSearchService {
})),
});
const res = await firstValueFrom(req$);
try {
const res = await firstValueFrom(req$.pipe(catchResponseArgsErrorPipe()));
if (res.error) {
const error = new ResponseArgsError(res);
this.#logger.error('Failed to add item to remission list', error);
return res.successful?.map((r) => r.value) as ReturnItem[];
} catch (error) {
this.#logger.error(
'Failed to add items to remission list',
error,
() => ({ stockId: stock.id, itemCount: items.length }),
);
throw error;
}
return res.successful?.map((r) => r.value) as ReturnItem[];
}
}

View File

@@ -87,13 +87,13 @@ describe('RemissionStockService', () => {
);
});
it('should throw ResponseArgsError when API returns no result', async () => {
it('should throw Error when API returns no result', async () => {
mockStockService.StockCurrentStock.mockReturnValue(
of({ error: false, result: undefined }),
);
await expect(service.fetchAssignedStock()).rejects.toThrow(
ResponseArgsError,
'Assigned stock has no ID',
);
});
@@ -196,16 +196,17 @@ describe('RemissionStockService', () => {
expect(mockStockService.StockInStock).toHaveBeenCalled();
});
it('should throw ResponseArgsError when API returns no result', async () => {
it('should return empty array when API returns no result', async () => {
// Arrange
mockStockService.StockInStock.mockReturnValue(
of({ error: false, result: undefined }),
);
// Act & Assert
await expect(service.fetchStockInfos(validParams)).rejects.toThrow(
ResponseArgsError,
);
// Act
const result = await service.fetchStockInfos(validParams);
// Assert
expect(result).toBe(undefined);
expect(mockStockService.StockInStock).toHaveBeenCalled();
});

View File

@@ -3,7 +3,10 @@ import { StockService } from '@generated/swagger/inventory-api';
import { firstValueFrom } from 'rxjs';
import { Stock, StockInfo } from '../models';
import { FetchStockInStock, FetchStockInStockSchema } from '../schemas';
import { ResponseArgsError, takeUntilAborted } from '@isa/common/data-access';
import {
catchResponseArgsErrorPipe,
takeUntilAborted,
} from '@isa/common/data-access';
import { logger } from '@isa/core/logging';
import { InFlight, Cache, CacheTimeToLive } from '@isa/common/decorators';
@@ -56,73 +59,74 @@ export class RemissionStockService {
@InFlight()
async fetchAssignedStock(abortSignal?: AbortSignal): Promise<Stock> {
this.#logger.info('Fetching assigned stock from API');
let req$ = this.#stockService.StockCurrentStock();
let req$ = this.#stockService
.StockCurrentStock()
.pipe(catchResponseArgsErrorPipe());
if (abortSignal) {
this.#logger.debug('Request configured with abort signal');
req$ = req$.pipe(takeUntilAborted(abortSignal));
}
const res = await firstValueFrom(req$);
try {
const res = await firstValueFrom(req$);
const result = res.result;
if (result?.id === undefined) {
const error = new Error('Assigned stock has no ID');
this.#logger.error('Invalid stock response', error);
throw error;
}
if (res.error || !res.result) {
this.#logger.error(
'Failed to fetch assigned stock',
new Error(res.message || 'Unknown error'),
);
throw new ResponseArgsError(res);
}
this.#logger.debug('Successfully fetched assigned stock', () => ({
stockId: result.id,
}));
const result = res.result;
if (result.id === undefined) {
const error = new Error('Assigned stock has no ID');
this.#logger.error('Invalid stock response', error);
// TypeScript cannot narrow StockDTO to Stock based on the id check above,
// so we use a minimal type assertion after runtime validation
return result as Stock;
} catch (error) {
this.#logger.error('Failed to fetch assigned stock', error);
throw error;
}
this.#logger.debug('Successfully fetched assigned stock', () => ({
stockId: result.id,
}));
// TypeScript cannot narrow StockDTO to Stock based on the id check above,
// so we use a minimal type assertion after runtime validation
return result as Stock;
}
async fetchStock(
branchId: number,
abortSignal?: AbortSignal,
): Promise<Stock | undefined> {
let req$ = this.#stockService.StockGetStocks();
let req$ = this.#stockService
.StockGetStocks()
.pipe(catchResponseArgsErrorPipe());
if (abortSignal) {
this.#logger.debug('Request configured with abort signal');
req$ = req$.pipe(takeUntilAborted(abortSignal));
}
const res = await firstValueFrom(req$);
try {
const res = await firstValueFrom(req$);
if (res.error || !res.result) {
this.#logger.error(
'Failed to fetch stocks',
new Error(res.message || 'Unknown error'),
);
throw new ResponseArgsError(res);
const stock = res.result?.find((s) => s.branch?.id === branchId);
if (!stock) {
return undefined;
}
if (stock.id === undefined) {
this.#logger.warn('Found stock without ID for branch', () => ({
branchId,
}));
return undefined;
}
// TypeScript cannot narrow StockDTO to Stock based on the id check above,
// so we use a minimal type assertion after runtime validation
return stock as Stock;
} catch (error) {
this.#logger.error('Failed to fetch stock', error, () => ({
branchId,
}));
throw error;
}
const stock = res.result.find((s) => s.branch?.id === branchId);
if (!stock) {
return undefined;
}
if (stock.id === undefined) {
this.#logger.warn('Found stock without ID for branch', () => ({ branchId }));
return undefined;
}
// TypeScript cannot narrow StockDTO to Stock based on the id check above,
// so we use a minimal type assertion after runtime validation
return stock as Stock;
}
/**
@@ -179,30 +183,31 @@ export class RemissionStockService {
itemCount: parsed.itemIds.length,
}));
let req$ = this.#stockService.StockInStock({
stockId: assignedStockId,
articleIds: parsed.itemIds,
});
let req$ = this.#stockService
.StockInStock({
stockId: assignedStockId,
articleIds: parsed.itemIds,
})
.pipe(catchResponseArgsErrorPipe());
if (abortSignal) {
this.#logger.debug('Request configured with abort signal');
req$ = req$.pipe(takeUntilAborted(abortSignal));
}
const res = await firstValueFrom(req$);
try {
const res = await firstValueFrom(req$);
if (res.error || !res.result) {
this.#logger.error(
'Failed to fetch stock info',
new Error(res.message || 'Unknown error'),
);
throw new ResponseArgsError(res);
this.#logger.debug('Successfully fetched stock info', () => ({
itemCount: res.result?.length || 0,
}));
return res.result as StockInfo[];
} catch (error) {
this.#logger.error('Failed to fetch stock info', error, () => ({
stockId: assignedStockId,
}));
throw error;
}
this.#logger.debug('Successfully fetched stock info', () => ({
itemCount: res.result?.length || 0,
}));
return res.result as StockInfo[];
}
}

View File

@@ -0,0 +1,185 @@
import {
effect,
Signal,
untracked,
ResourceStatus,
inject,
} from '@angular/core';
import { injectDialog } from '@isa/ui/dialog';
import { SearchItemToRemitDialogComponent } from '@isa/remission/shared/search-item-to-remit-dialog';
import { RemissionStore, RemissionItem } from '@isa/remission/data-access';
/**
* Configuration for the empty search result handler.
* Provides all necessary signals and callbacks for handling empty search scenarios.
*/
export interface EmptySearchResultHandlerConfig {
/**
* Signal returning the status of the remission resource.
*/
remissionResourceStatus: Signal<ResourceStatus>;
/**
* Signal returning the status of the stock resource.
*/
stockResourceStatus: Signal<ResourceStatus>;
/**
* Signal returning the current search term.
*/
searchTerm: Signal<string | undefined>;
/**
* Signal returning the number of search hits.
*/
hits: Signal<number>;
/**
* Signal indicating whether there is a valid search term.
*/
hasValidSearchTerm: Signal<boolean>;
/**
* Signal indicating whether the search was triggered by user interaction.
*/
searchTriggeredByUser: Signal<boolean>;
/**
* Signal indicating whether a remission has been started.
*/
remissionStarted: Signal<boolean>;
/**
* Signal indicating whether the current list type is "Abteilung".
*/
isDepartment: Signal<boolean>;
/**
* Signal returning the first item in the list (for auto-preselection).
*/
firstItem: Signal<RemissionItem | undefined>;
/**
* Callback to preselect a remission item.
*/
preselectItem: (item: RemissionItem) => void;
/**
* Callback to remit items after dialog selection.
* @param options - Options for the remit operation
*/
remitItems: (options: { addItemFlow: boolean }) => Promise<void>;
/**
* Callback to navigate to the default remission list.
*/
navigateToDefaultList: () => Promise<void>;
/**
* Callback to reload the list and return data.
*/
reloadData: () => void;
}
/**
* Creates an effect that handles scenarios where a search yields no or few results.
*
* This handler implements two behaviors:
* 1. **Auto-Preselection**: When exactly one item is found and remission is started,
* automatically preselects that item for convenience.
* 2. **Empty Search Dialog**: When no items are found after a user-initiated search,
* opens a dialog allowing the user to add items to remit.
*
* @param config - Configuration object containing all required signals and callbacks
* @returns The created effect (for potential cleanup if needed)
*
* @example
* ```typescript
* // In a component
* emptySearchEffect = injectEmptySearchResultHandler({
* remissionResourceStatus: () => this.remissionResource.status(),
* stockResourceStatus: () => this.inStockResource.status(),
* searchTerm: this.searchTerm,
* hits: this.hits,
* // ... other config
* });
* ```
*
* @remarks
* - The effect tracks `remissionResourceStatus`, `stockResourceStatus`, and `searchTerm`
* - Other signals are accessed via `untracked()` to avoid unnecessary re-evaluations
* - The dialog subscription handles async flows for adding items to remission
*/
export const injectEmptySearchResultHandler = (
config: EmptySearchResultHandlerConfig,
) => {
const store = inject(RemissionStore);
const searchItemToRemitDialog = injectDialog(
SearchItemToRemitDialogComponent,
);
return effect(() => {
const status = config.remissionResourceStatus();
const stockStatus = config.stockResourceStatus();
const searchTerm = config.searchTerm();
// Wait until both resources are resolved
if (status !== 'resolved' || stockStatus !== 'resolved') {
return;
}
untracked(() => {
const hits = config.hits();
// Early return conditions - only proceed if:
// - No hits (hits === 0, so !!hits is false)
// - Valid search term exists
// - Search was triggered by user
if (
!!hits ||
!searchTerm ||
!config.hasValidSearchTerm() ||
!config.searchTriggeredByUser()
) {
// #5338 - Auto-select item if exactly one hit after search
if (hits === 1 && config.remissionStarted()) {
store.clearSelectedItems();
const firstItem = config.firstItem();
if (firstItem) {
config.preselectItem(firstItem);
}
}
return;
}
// Open dialog to allow user to add items when search returns no results
searchItemToRemitDialog({
data: {
searchTerm,
},
}).closed.subscribe(async (result) => {
store.clearSelectedItems();
if (result) {
if (config.remissionStarted()) {
// Select all items from dialog result
for (const item of result) {
if (item?.id) {
store.selectRemissionItem(item.id, item);
}
}
// Remit the selected items
await config.remitItems({ addItemFlow: true });
} else if (config.isDepartment()) {
// Navigate to default list if in department mode without active remission
await config.navigateToDefaultList();
return;
}
}
// Always reload data after dialog closes
config.reloadData();
});
});
});
};

View File

@@ -0,0 +1,6 @@
export { RemissionActionComponent } from './remission-action.component';
export {
RemissionActionService,
RemitItemsContext,
RemitItemsOptions,
} from './remission-action.service';

View File

@@ -0,0 +1,21 @@
@if (remissionStarted()) {
<ui-stateful-button
(clicked)="remitItems()"
(action)="remitItems()"
[(state)]="actionService.state"
defaultContent="Remittieren"
defaultWidth="13rem"
[errorContent]="actionService.error()"
errorWidth="32rem"
errorAction="Erneut versuchen"
successContent="Hinzugefügt"
successWidth="20rem"
size="large"
color="brand"
[pending]="actionService.inProgress()"
[disabled]="isDisabled()"
data-what="button"
data-which="remit-items"
>
</ui-stateful-button>
}

View File

@@ -0,0 +1,124 @@
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
input,
output,
} from '@angular/core';
import { StatefulButtonComponent } from '@isa/ui/buttons';
import {
RemissionStore,
RemissionItem,
RemissionListType,
} from '@isa/remission/data-access';
import {
RemissionActionService,
RemitItemsContext,
RemitItemsOptions,
} from './remission-action.service';
/**
* RemissionActionComponent
*
* Standalone component that encapsulates the "Remittieren" (remit) button
* and its associated logic. Manages the remit workflow including:
* - Displaying the stateful button with appropriate states
* - Triggering the remit action
* - Handling loading, success, and error states
*
* @remarks
* This component requires the RemissionActionService to be provided,
* either by itself or by a parent component.
*
* @example
* ```html
* <remi-feature-remission-action
* [getAvailableStockForItem]="getAvailableStockForItem"
* [selectedRemissionListType]="selectedRemissionListType()"
* [disabled]="removeItemInProgress()"
* (actionCompleted)="onActionCompleted()"
* />
* ```
*/
@Component({
selector: 'remi-feature-remission-action',
templateUrl: './remission-action.component.html',
styleUrl: './remission-action.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [StatefulButtonComponent],
providers: [RemissionActionService],
})
export class RemissionActionComponent {
readonly #store = inject(RemissionStore);
readonly actionService = inject(RemissionActionService);
/**
* Function to get available stock for a remission item.
* Required for calculating quantities during remit.
*/
getAvailableStockForItem = input.required<(item: RemissionItem) => number>();
/**
* The currently selected remission list type.
* Required for determining item types during remit.
*/
selectedRemissionListType = input.required<RemissionListType>();
/**
* Additional disabled state from parent component.
* Combined with internal disabled logic.
*/
disabled = input<boolean>(false);
/**
* Emitted when the remit action is completed (success or error).
* Parent component should use this to reload list data.
*/
actionCompleted = output<void>();
/**
* Computed signal indicating whether a remission has been started.
* The button is only visible when remission is started.
*/
remissionStarted = computed(() => this.#store.remissionStarted());
/**
* Computed signal indicating whether there are selected items.
*/
hasSelectedItems = computed(
() => Object.keys(this.#store.selectedItems()).length > 0,
);
/**
* Computed signal for the combined disabled state.
* Button is disabled when:
* - No items are selected
* - An external disabled condition is true
* - A remit operation is in progress
*/
isDisabled = computed(
() =>
!this.hasSelectedItems() ||
this.disabled() ||
this.actionService.inProgress(),
);
/**
* Handles the remit button click.
* Delegates to the action service and emits completion event.
*
* @param options - Options for the remit operation
*/
async remitItems(
options: RemitItemsOptions = { addItemFlow: false },
): Promise<void> {
const context: RemitItemsContext = {
getAvailableStockForItem: this.getAvailableStockForItem(),
selectedRemissionListType: this.selectedRemissionListType(),
};
await this.actionService.remitItems(context, options);
this.actionCompleted.emit();
}
}

View File

@@ -0,0 +1,217 @@
import { inject, Injectable, signal } from '@angular/core';
import { logger } from '@isa/core/logging';
import {
RemissionStore,
RemissionReturnReceiptService,
RemissionListType,
RemissionResponseArgsErrorMessage,
RemissionItem,
getStockToRemit,
getItemType,
} from '@isa/remission/data-access';
import { StatefulButtonState } from '@isa/ui/buttons';
import { injectFeedbackErrorDialog } from '@isa/ui/dialog';
import { firstValueFrom } from 'rxjs';
/**
* Configuration options for the remit items operation.
*/
export interface RemitItemsOptions {
/** Whether this operation is part of an add-item flow (e.g., from search dialog) */
addItemFlow: boolean;
}
/**
* Context required for remitting items.
* Provides stock information lookup for calculating quantities.
*/
export interface RemitItemsContext {
/** Function to get available stock for a remission item */
getAvailableStockForItem: (item: RemissionItem) => number;
/** The currently selected remission list type */
selectedRemissionListType: RemissionListType;
}
/**
* Service responsible for handling the remission action workflow.
* Manages the state and logic for remitting selected items.
*
* This service encapsulates:
* - State management for the remit button (progress, error, success states)
* - The remitItems business logic
* - Error handling and user feedback
* - Navigation after successful remission
*
* @remarks
* This service should be provided at the component level, not root level,
* as it maintains UI state specific to a single remission action context.
*
* @example
* ```typescript
* // In component providers
* providers: [RemissionActionService]
*
* // Usage
* readonly actionService = inject(RemissionActionService);
* await this.actionService.remitItems(context);
* ```
*/
@Injectable()
export class RemissionActionService {
readonly #store = inject(RemissionStore);
readonly #remissionReturnReceiptService = inject(
RemissionReturnReceiptService,
);
readonly #errorDialog = injectFeedbackErrorDialog();
readonly #logger = logger(() => ({
service: 'RemissionActionService',
}));
/**
* Signal representing the current state of the remit button.
*/
readonly state = signal<StatefulButtonState>('default');
/**
* Signal containing the current error message, if any.
*/
readonly error = signal<string | null>(null);
/**
* Signal indicating whether a remit operation is currently in progress.
*/
readonly inProgress = signal(false);
/**
* Computed signal indicating whether there are selected items in the store.
*/
get hasSelectedItems(): boolean {
return Object.keys(this.#store.selectedItems()).length > 0;
}
/**
* Computed signal indicating whether a remission has been started.
*/
get remissionStarted(): boolean {
return this.#store.remissionStarted();
}
/**
* Initiates the process to remit selected items.
*
* If remission is already started, items are added directly to the remission.
* Handles the full workflow including:
* - Preventing duplicate operations
* - Processing each selected item
* - Error handling with user feedback
* - State management for UI feedback
*
* @param context - Context providing stock information and list type
* @param options - Options for the remit operation
* @returns A promise that resolves when the operation is complete
*/
async remitItems(
context: RemitItemsContext,
options: RemitItemsOptions = { addItemFlow: false },
): Promise<void> {
if (this.inProgress()) {
return;
}
this.inProgress.set(true);
try {
await this.#processSelectedItems(context, options);
this.state.set('success');
} catch (error) {
await this.#handleRemitItemsError(error);
}
this.#store.clearSelectedItems();
this.inProgress.set(false);
}
/**
* Processes all selected items for remission.
* @param context - Context providing stock information and list type
* @param options - Options for the remit operation
*/
async #processSelectedItems(
context: RemitItemsContext,
options: RemitItemsOptions,
): Promise<void> {
// #5273, #5280 Fix - Bei gestarteter Remission dürfen Items die über den AddItemDialog
// hinzugefügt und direkt remittiert werden, nur als ReturnItem (statt ReturnSuggestion)
// zum WBS hinzugefügt werden
const remissionListType = options.addItemFlow
? RemissionListType.Pflicht
: context.selectedRemissionListType;
const selected = this.#store.selectedItems();
const quantities = this.#store.selectedQuantity();
for (const [remissionItemId, item] of Object.entries(selected)) {
const returnId = this.#store.returnId();
const receiptId = this.#store.receiptId();
const remissionItemIdNumber = Number(remissionItemId);
const quantity = quantities[remissionItemIdNumber];
const inStock = context.getAvailableStockForItem(item);
const stockToRemit = getStockToRemit({
remissionItem: item,
remissionListType,
availableStock: inStock,
});
const quantityToRemit = quantity ?? stockToRemit;
if (returnId && receiptId) {
await this.#remissionReturnReceiptService.remitItem({
itemId: remissionItemIdNumber,
addItem: {
returnId,
receiptId,
quantity: quantityToRemit,
inStock,
impedimentComment: stockToRemit > quantity ? 'Restmenge' : '',
remainingQuantity:
isNaN(quantity) || inStock - quantity <= 0
? undefined
: inStock - quantity,
},
type: getItemType(item, remissionListType),
});
}
}
}
/**
* Handles errors that occur during the remission of items.
* Logs the error, displays an error dialog, and updates state.
*
* @param error - The error object caught during the remission process
*/
async #handleRemitItemsError(error: unknown): Promise<void> {
this.#logger.error('Failed to remit items', error as Error);
const errorMessage =
(error as { error?: { message?: string }; message?: string })?.error
?.message ??
(error as { message?: string })?.message ??
'Artikel konnten nicht remittiert werden';
this.error.set(errorMessage);
await firstValueFrom(
this.#errorDialog({
data: {
errorMessage,
},
}).closed,
);
if (errorMessage === RemissionResponseArgsErrorMessage.AlreadyCompleted) {
this.#store.clearState();
}
this.state.set('error');
}
}

View File

@@ -59,23 +59,10 @@
[class.scroll-top-button-spacing-bottom]="remissionStarted()"
></utils-scroll-top-button>
@if (remissionStarted()) {
<ui-stateful-button
class="flex flex-col self-end fixed bottom-6 mr-6"
(clicked)="remitItems()"
(action)="remitItems()"
[(state)]="remitItemsState"
defaultContent="Remittieren"
defaultWidth="13rem"
[errorContent]="remitItemsError()"
errorWidth="32rem"
errorAction="Erneut versuchen"
successContent="Hinzugefügt"
successWidth="20rem"
size="large"
color="brand"
[pending]="remitItemsInProgress()"
[disabled]="!hasSelectedItems() || removeItemInProgress()"
>
</ui-stateful-button>
}
<remi-feature-remission-action
class="flex flex-col self-end fixed bottom-6 mr-6"
[getAvailableStockForItem]="getAvailableStockForItem.bind(this)"
[selectedRemissionListType]="selectedRemissionListType()"
[disabled]="removeItemInProgress()"
(actionCompleted)="reloadListAndReturnData()"
></remi-feature-remission-action>

View File

@@ -3,10 +3,9 @@ import {
Component,
inject,
computed,
effect,
untracked,
signal,
linkedSignal,
viewChild,
} from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import {
@@ -29,12 +28,9 @@ import {
createRemissionProductGroupResource,
} from './resources';
import { injectRemissionListType } from './injects/inject-remission-list-type';
import { injectEmptySearchResultHandler } from './injects/inject-empty-search-result-handler';
import { RemissionListItemComponent } from './remission-list-item/remission-list-item.component';
import {
IconButtonComponent,
StatefulButtonComponent,
StatefulButtonState,
} from '@isa/ui/buttons';
import { IconButtonComponent } from '@isa/ui/buttons';
import {
ReturnItem,
StockInfo,
@@ -42,22 +38,16 @@ import {
RemissionStore,
RemissionItem,
calculateAvailableStock,
RemissionReturnReceiptService,
getStockToRemit,
RemissionListType,
RemissionResponseArgsErrorMessage,
UpdateItem,
orderByListItems,
getItemType,
getStockToRemit,
} from '@isa/remission/data-access';
import { injectDialog, injectFeedbackErrorDialog } from '@isa/ui/dialog';
import { SearchItemToRemitDialogComponent } from '@isa/remission/shared/search-item-to-remit-dialog';
import { RemissionReturnCardComponent } from './remission-return-card/remission-return-card.component';
import { logger } from '@isa/core/logging';
import { RemissionListDepartmentElementsComponent } from './remission-list-department-elements/remission-list-department-elements.component';
import { injectTabId } from '@isa/core/tabs';
import { RemissionListEmptyStateComponent } from './remission-list-empty-state/remission-list-empty-state.component';
import { firstValueFrom } from 'rxjs';
import { RemissionActionComponent } from './remission-action';
function querySettingsFactory() {
return inject(ActivatedRoute).snapshot.data['querySettings'];
@@ -96,7 +86,7 @@ function querySettingsFactory() {
RemissionListSelectComponent,
RemissionListItemComponent,
IconButtonComponent,
StatefulButtonComponent,
RemissionActionComponent,
RemissionListDepartmentElementsComponent,
RemissionListEmptyStateComponent,
ScrollTopButtonComponent,
@@ -125,8 +115,10 @@ export class RemissionListComponent {
*/
activatedTabId = injectTabId();
searchItemToRemitDialog = injectDialog(SearchItemToRemitDialogComponent);
errorDialog = injectFeedbackErrorDialog();
/**
* Reference to the RemissionActionComponent for triggering remit actions.
*/
remissionAction = viewChild(RemissionActionComponent);
/**
* FilterService instance for managing filter state and queries.
@@ -140,20 +132,6 @@ export class RemissionListComponent {
*/
#store = inject(RemissionStore);
/**
* RemissionReturnReceiptService instance for handling return receipt operations.
* @private
*/
#remissionReturnReceiptService = inject(RemissionReturnReceiptService);
/**
* Logger instance for logging component events and errors.
* @private
*/
#logger = logger(() => ({
component: 'RemissionListComponent',
}));
/**
* Restores scroll position when navigating back to this component.
*/
@@ -294,32 +272,6 @@ export class RemissionListComponent {
*/
remissionStarted = computed(() => this.#store.remissionStarted());
/**
* Computed signal indicating whether there are selected items in the remission store.
* @returns True if there are selected items, false otherwise.
*/
hasSelectedItems = computed(() => {
return Object.keys(this.#store.selectedItems()).length > 0;
});
/**
* Signal for the current remission list type.
* @returns The current RemissionListType.
*/
remitItemsState = signal<StatefulButtonState>('default');
/**
* Signal for any error messages related to remission items.
* @returns Error message string or null if no error.
*/
remitItemsError = signal<string | null>(null);
/**
* Signal indicating whether remission items are currently being processed.
* @returns True if in progress, false otherwise.
*/
remitItemsInProgress = signal(false);
/**
* Commits the current filter state and triggers a new search.
*
@@ -414,132 +366,35 @@ export class RemissionListComponent {
});
/**
* Effect that handles scenarios where a search yields no results.
* If the search was user-initiated and returned no hits, it opens a dialog
* to allow the user to add a new item to remit.
* If only one hit is found and a remission is started, it selects that item automatically.
* This effect runs whenever the remission or stock resource status changes,
* or when the search term changes.
* It ensures that the user is prompted appropriately based on their actions and the current state of the remission process.
* It also checks if the remission is started or if the list type is 'Abteilung' to determine navigation behavior.
* @see {@link
* https://angular.dev/guide/effects} for more information on Angular effects.
* @remarks This effect uses `untracked` to avoid unnecessary re-evaluations
* when accessing certain signals.
* Computed signal returning the first item in the list.
* Used for auto-preselection when exactly one item is found.
*/
emptySearchResultEffect = effect(() => {
const status = this.remissionResource.status();
const stockStatus = this.inStockResource.status();
const searchTerm: string | undefined = this.searchTerm();
#firstItem = computed(() => this.items()[0]);
if (status !== 'resolved' || stockStatus !== 'resolved') {
return;
}
untracked(() => {
const hits = this.hits();
// #5338 - Select item automatically if only one hit after search
if (
!!hits ||
!searchTerm ||
!this.hasValidSearchTerm() ||
!this.searchTriggeredByUser()
) {
if (hits === 1 && this.remissionStarted()) {
this.#store.clearSelectedItems();
this.preselectRemissionItem(this.items()[0]);
}
return;
}
this.searchItemToRemitDialog({
data: {
searchTerm,
},
}).closed.subscribe(async (result) => {
this.#store.clearSelectedItems();
if (result) {
if (this.remissionStarted()) {
for (const item of result) {
if (item?.id) {
this.#store.selectRemissionItem(item.id, item);
}
}
await this.remitItems({ addItemFlow: true });
} else if (this.isDepartment()) {
return await this.navigateToDefaultRemissionList();
}
}
this.reloadListAndReturnData();
});
});
});
// TODO: Improvement - In Separate Komponente zusammen mit Remi-Button Auslagern
/**
* Initiates the process to remit selected items.
* If remission is already started, items are added directly to the remission.
* If not, navigates to the default remission list.
* @param options - Options for remitting items, including whether it's part of an add-item flow.
* @returns A promise that resolves when the operation is complete.
* Effect that handles scenarios where a search yields no or few results.
* - Auto-preselects item when exactly one hit is found
* - Opens dialog to add items when no results are found
*
* @see injectEmptySearchResultHandler for implementation details
*/
async remitItems(options: { addItemFlow: boolean } = { addItemFlow: false }) {
if (this.remitItemsInProgress()) {
return;
}
this.remitItemsInProgress.set(true);
try {
// #5273, #5280 Fix - Bei gestarteter Remission dürfen Items die über den AddItemDialog hinzugefügt und direkt remittiert werden, nur als ReturnItem (statt ReturnSuggestion) zum WBS hinzugefügt werden
const remissionListType = options.addItemFlow
? RemissionListType.Pflicht
: this.selectedRemissionListType();
const selected = this.#store.selectedItems();
const quantities = this.#store.selectedQuantity();
for (const [remissionItemId, item] of Object.entries(selected)) {
const returnId = this.#store.returnId();
const receiptId = this.#store.receiptId();
const remissionItemIdNumber = Number(remissionItemId);
const quantity = quantities[remissionItemIdNumber];
const inStock = this.getAvailableStockForItem(item);
const stockToRemit = getStockToRemit({
remissionItem: item,
remissionListType,
availableStock: inStock,
});
const quantityToRemit = quantity ?? stockToRemit;
if (returnId && receiptId) {
await this.#remissionReturnReceiptService.remitItem({
itemId: remissionItemIdNumber,
addItem: {
returnId,
receiptId,
quantity: quantityToRemit,
inStock,
impedimentComment: stockToRemit > quantity ? 'Restmenge' : '',
remainingQuantity:
isNaN(quantity) || inStock - quantity <= 0
? undefined
: inStock - quantity,
},
type: getItemType(item, remissionListType),
});
}
}
this.remitItemsState.set('success');
this.reloadListAndReturnData();
} catch (error) {
await this.handleRemitItemsError(error);
}
this.#store.clearSelectedItems();
this.remitItemsInProgress.set(false);
}
emptySearchResultEffect = injectEmptySearchResultHandler({
remissionResourceStatus: computed(() => this.remissionResource.status()),
stockResourceStatus: computed(() => this.inStockResource.status()),
searchTerm: this.searchTerm,
hits: this.hits,
hasValidSearchTerm: this.hasValidSearchTerm,
searchTriggeredByUser: this.searchTriggeredByUser,
remissionStarted: this.remissionStarted,
isDepartment: this.isDepartment,
firstItem: this.#firstItem,
preselectItem: (item) => this.preselectRemissionItem(item),
remitItems: async (options) => {
await this.remissionAction()?.remitItems(options);
},
navigateToDefaultList: () => this.navigateToDefaultRemissionList(),
reloadData: () => this.reloadListAndReturnData(),
});
/**
* Reloads the remission list and return data.
@@ -572,41 +427,6 @@ export class RemissionListComponent {
}
}
/**
* Handles errors that occur during the remission of items.
* Logs the error, displays an error dialog, and reloads the list and return data.
* If the error indicates that the remission is already completed, it clears the remission state.
* Sets the stateful button to 'error' to indicate the failure.
* @param error - The error object caught during the remission process.
* @returns A promise that resolves when the error handling is complete.
*/
async handleRemitItemsError(error: any) {
this.#logger.error('Failed to remit items', error);
const errorMessage =
error?.error?.message ??
error?.message ??
'Artikel konnten nicht remittiert werden';
this.remitItemsError.set(errorMessage);
await firstValueFrom(
this.errorDialog({
data: {
errorMessage,
},
}).closed,
);
if (errorMessage === RemissionResponseArgsErrorMessage.AlreadyCompleted) {
this.#store.clearState();
}
this.reloadListAndReturnData();
this.remitItemsState.set('error'); // Stateful-Button auf Error setzen
}
/**
* Navigates to the default remission list based on the current activated tab ID.
* This method is used to redirect the user to the remission list after completing or starting a remission.

View File

@@ -63,6 +63,18 @@
}
</div>
}
<button
type="button"
uiButton
color="tertiary"
size="large"
(click)="abortRemission()"
class="fixed right-[15rem] bottom-6"
>
Warenbegleitschein abbrechen
</button>
@if (!returnLoading() && !returnData()?.completed) {
<lib-remission-return-receipt-complete
[returnId]="returnId()"

View File

@@ -17,6 +17,7 @@ import {
getPackageNumbersFromReturn,
getReceiptItemsFromReturn,
getReceiptNumberFromReturn,
RemissionStore,
} from '@isa/remission/data-access';
import { EmptyStateComponent } from '@isa/ui/empty-state';
import { EMPTY_WBS_DESCRIPTION, EMPTY_WBS_TITLE } from './constants';
@@ -24,6 +25,8 @@ import {
RemissionReturnReceiptActionsComponent,
RemissionReturnReceiptCompleteComponent,
} from '@isa/remission/shared/return-receipt-actions';
import { Router } from '@angular/router';
import { injectTabId } from '@isa/core/tabs';
@Component({
selector: 'remi-remission-return-receipt-details',
@@ -53,6 +56,15 @@ export class RemissionReturnReceiptDetailsComponent {
/** Angular Location service for navigation */
location = inject(Location);
/** Remission store for managing remission state */
#store = inject(RemissionStore);
/** Angular Router for navigation */
#router = inject(Router);
/** Injects the current activated tab ID as a signal. */
#tabId = injectTabId();
/**
* Required input for the return ID.
* Automatically coerced to a number from string input.
@@ -111,4 +123,9 @@ export class RemissionReturnReceiptDetailsComponent {
const returnData = this.returnData();
return getPackageNumbersFromReturn(returnData!) !== '';
});
async abortRemission() {
this.#store.clearState();
await this.#router.navigate(['/', this.#tabId(), 'remission']);
}
}

View File

@@ -20,11 +20,13 @@
cdkConnectedOverlay
[cdkConnectedOverlayOrigin]="trigger"
[cdkConnectedOverlayOpen]="open()"
[cdkConnectedOverlayPositions]="overlayPositions"
[cdkConnectedOverlayHasBackdrop]="true"
cdkConnectedOverlayBackdropClass="cdk-overlay-transparent-backdrop"
[cdkConnectedOverlayScrollStrategy]="scrollStrategy"
[cdkConnectedOverlayOffsetX]="-10"
[cdkConnectedOverlayOffsetY]="18"
[cdkConnectedOverlayFlexibleDimensions]="true"
[cdkConnectedOverlayGrowAfterOpen]="true"
[cdkConnectedOverlayPush]="true"
cdkConnectedOverlayWidth="18.375rem"
(backdropClick)="toggle()"
>

View File

@@ -2,13 +2,13 @@ import { Overlay, OverlayModule } from '@angular/cdk/overlay';
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
input,
model,
output,
} from '@angular/core';
import { IconButtonComponent } from '@isa/ui/buttons';
import { DROPDOWN_OVERLAY_POSITIONS } from '@isa/ui/layout';
import { FilterMenuComponent } from './filter-menu.component';
import { FilterService } from '../../core';
@@ -30,6 +30,9 @@ export class FilterMenuButtonComponent {
selectedFilters = this.#filter.selectedFilterCount;
/** Standard overlay positions for the filter menu panel */
readonly overlayPositions = DROPDOWN_OVERLAY_POSITIONS;
/**
* Tracks the open state of the filter menu.
*/

View File

@@ -10,7 +10,9 @@
[cdkConnectedOverlayDisableClose]="false"
cdkConnectedOverlayBackdropClass="cdk-overlay-transparent-backdrop"
[cdkConnectedOverlayMinWidth]="overlayMinWidth"
[cdkConnectedOverlayLockPosition]="true"
[cdkConnectedOverlayFlexibleDimensions]="true"
[cdkConnectedOverlayGrowAfterOpen]="true"
[cdkConnectedOverlayPush]="true"
[cdkConnectedOverlayScrollStrategy]="blockScrollStrategy"
(backdropClick)="close()"
(detach)="isOpen.set(false)"

View File

@@ -13,9 +13,7 @@ import {
signal,
viewChild,
} from '@angular/core';
import { isEqual } from 'lodash';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
import { NgIconComponent, provideIcons } from '@ng-icons/core';
import { isaActionChevronUp, isaActionChevronDown } from '@isa/icons';
@@ -23,14 +21,15 @@ import { ActiveDescendantKeyManager } from '@angular/cdk/a11y';
import {
CdkConnectedOverlay,
CdkOverlayOrigin,
ConnectedPosition,
ScrollStrategyOptions,
} from '@angular/cdk/overlay';
import { DropdownAppearance } from './dropdown.types';
import { DropdownService } from './dropdown.service';
import { CloseOnScrollDirective } from '@isa/ui/layout';
import {
CloseOnScrollDirective,
DROPDOWN_OVERLAY_POSITIONS,
} from '@isa/ui/layout';
import { logger } from '@isa/core/logging';
import { DropdownOptionComponent } from './dropdown-option.component';
import { DropdownFilterComponent } from './dropdown-filter.component';
import { DROPDOWN_HOST, DropdownHost } from './dropdown-host';
@@ -99,80 +98,8 @@ export class DropdownButtonComponent<T>
return this.#scrollStrategy.block();
}
/** Offset in pixels between the trigger and the overlay panel */
readonly #overlayOffset = 12;
/**
* Position priority for the overlay panel.
* Order: bottom-left, bottom-right, top-left, top-right,
* right-top, right-bottom, left-top, left-bottom
*/
readonly overlayPositions: ConnectedPosition[] = [
// Bottom left
{
originX: 'start',
originY: 'bottom',
overlayX: 'start',
overlayY: 'top',
offsetY: this.#overlayOffset,
},
// Bottom right
{
originX: 'end',
originY: 'bottom',
overlayX: 'end',
overlayY: 'top',
offsetY: this.#overlayOffset,
},
// Top left
{
originX: 'start',
originY: 'top',
overlayX: 'start',
overlayY: 'bottom',
offsetY: -this.#overlayOffset,
},
// Top right
{
originX: 'end',
originY: 'top',
overlayX: 'end',
overlayY: 'bottom',
offsetY: -this.#overlayOffset,
},
// Right top
{
originX: 'end',
originY: 'top',
overlayX: 'start',
overlayY: 'top',
offsetX: this.#overlayOffset,
},
// Right bottom
{
originX: 'end',
originY: 'bottom',
overlayX: 'start',
overlayY: 'bottom',
offsetX: this.#overlayOffset,
},
// Left top
{
originX: 'start',
originY: 'top',
overlayX: 'end',
overlayY: 'top',
offsetX: -this.#overlayOffset,
},
// Left bottom
{
originX: 'start',
originY: 'bottom',
overlayX: 'end',
overlayY: 'bottom',
offsetX: -this.#overlayOffset,
},
];
/** Standard overlay positions for the dropdown panel */
readonly overlayPositions = DROPDOWN_OVERLAY_POSITIONS;
appearance = input<DropdownAppearance>(DropdownAppearance.AccentOutline);

View File

@@ -2,3 +2,4 @@ export * from './lib/breakpoint.directive';
export * from './lib/breakpoint';
export * from './lib/close-on-scroll.directive';
export * from './lib/in-viewport.directive';
export * from './lib/overlay-positions';

View File

@@ -0,0 +1,100 @@
import { ConnectedPosition } from '@angular/cdk/overlay';
/** Default offset in pixels between the trigger and the overlay panel */
export const OVERLAY_OFFSET = 12;
/**
* Creates standard dropdown overlay positions with configurable offset.
*
* Position priority for overlay panels:
* CDK tries positions in order and picks the first one that fits in the viewport.
*
* With `flexibleDimensions=true` and `push=true`, the overlay will:
* 1. Try positions in order
* 2. Constrain size to fit in viewport (flexibleDimensions)
* 3. Push into viewport if needed (push)
*
* Priority order (most to least preferred):
* 1. Below the trigger (default, most natural for dropdowns)
* 2. Above the trigger (when no space below)
* 3. To the right (when no vertical space)
* 4. To the left (last resort)
*
* @param offset - Offset in pixels between trigger and overlay (default: 12)
* @returns Array of ConnectedPosition configurations
*/
export const createOverlayPositions = (
offset: number = OVERLAY_OFFSET,
): ConnectedPosition[] => [
// Priority 1: Below trigger, left-aligned (default/preferred)
{
originX: 'start',
originY: 'bottom',
overlayX: 'start',
overlayY: 'top',
offsetY: offset,
},
// Priority 2: Below trigger, right-aligned
{
originX: 'end',
originY: 'bottom',
overlayX: 'end',
overlayY: 'top',
offsetY: offset,
},
// Priority 3: Above trigger, left-aligned
{
originX: 'start',
originY: 'top',
overlayX: 'start',
overlayY: 'bottom',
offsetY: -offset,
},
// Priority 4: Above trigger, right-aligned
{
originX: 'end',
originY: 'top',
overlayX: 'end',
overlayY: 'bottom',
offsetY: -offset,
},
// Priority 5: Right of trigger, top-aligned
{
originX: 'end',
originY: 'top',
overlayX: 'start',
overlayY: 'top',
offsetX: offset,
},
// Priority 6: Right of trigger, bottom-aligned
{
originX: 'end',
originY: 'bottom',
overlayX: 'start',
overlayY: 'bottom',
offsetX: offset,
},
// Priority 7: Left of trigger, top-aligned
{
originX: 'start',
originY: 'top',
overlayX: 'end',
overlayY: 'top',
offsetX: -offset,
},
// Priority 8: Left of trigger, bottom-aligned
{
originX: 'start',
originY: 'bottom',
overlayX: 'end',
overlayY: 'bottom',
offsetX: -offset,
},
];
/**
* Standard dropdown overlay positions with default offset (12px).
* Use this for most dropdown/menu components.
*/
export const DROPDOWN_OVERLAY_POSITIONS: ConnectedPosition[] =
createOverlayPositions(OVERLAY_OFFSET);