Merged PR 2000: open tasks

Related work items: #5309
This commit is contained in:
Lorenz Hilpert
2025-11-06 10:01:41 +00:00
committed by Nino Righi
parent 1d4c900d3a
commit 89b3d9aa60
136 changed files with 5088 additions and 4798 deletions

View File

@@ -1,6 +1,6 @@
import {
getOrderTypeFeature,
OrderType,
OrderTypeFeature,
ShoppingCartItem,
} from '@isa/checkout/data-access';
import {
@@ -57,47 +57,47 @@ export class GetAvailabilityParamsAdapter {
];
switch (orderType) {
case OrderType.InStore:
case OrderTypeFeature.InStore:
if (!targetBranch) {
return undefined;
}
return {
orderType: OrderType.InStore,
orderType: OrderTypeFeature.InStore,
branchId: targetBranch,
itemsIds: baseItems.map((item) => item.itemId), // Note: itemsIds is array of numbers
};
case OrderType.Pickup:
case OrderTypeFeature.Pickup:
if (!targetBranch) {
return undefined;
}
return {
orderType: OrderType.Pickup,
orderType: OrderTypeFeature.Pickup,
branchId: targetBranch,
items: baseItems,
};
case OrderType.Delivery:
case OrderTypeFeature.Delivery:
return {
orderType: OrderType.Delivery,
orderType: OrderTypeFeature.Delivery,
items: baseItems,
};
case OrderType.DigitalShipping:
case OrderTypeFeature.DigitalShipping:
return {
orderType: OrderType.DigitalShipping,
orderType: OrderTypeFeature.DigitalShipping,
items: baseItems,
};
case OrderType.B2BShipping:
case OrderTypeFeature.B2BShipping:
return {
orderType: OrderType.B2BShipping,
orderType: OrderTypeFeature.B2BShipping,
items: baseItems,
};
case OrderType.Download:
case OrderTypeFeature.Download:
return {
orderType: OrderType.Download,
orderType: OrderTypeFeature.Download,
items: baseItems.map((item) => ({
itemId: item.itemId,
ean: item.ean,
@@ -141,7 +141,7 @@ export class GetAvailabilityParamsAdapter {
// Build single-item params based on order type
switch (orderType) {
case OrderType.InStore:
case OrderTypeFeature.InStore:
if (!targetBranch) {
return undefined;
}
@@ -150,7 +150,7 @@ export class GetAvailabilityParamsAdapter {
branchId: targetBranch,
itemId: itemObj.itemId,
};
case OrderType.Pickup:
case OrderTypeFeature.Pickup:
if (!targetBranch) {
return undefined;
}
@@ -160,10 +160,10 @@ export class GetAvailabilityParamsAdapter {
item: itemObj,
};
case OrderType.Delivery:
case OrderType.DigitalShipping:
case OrderType.B2BShipping:
case OrderType.Download:
case OrderTypeFeature.Delivery:
case OrderTypeFeature.DigitalShipping:
case OrderTypeFeature.B2BShipping:
case OrderTypeFeature.Download:
return {
orderType,
item: itemObj,

View File

@@ -1,3 +1,3 @@
export * from './availability-type';
export * from './availability';
export * from './order-type';
export * from './order-type-feature';

View File

@@ -0,0 +1 @@
export { OrderTypeFeature } from '@isa/common/data-access';

View File

@@ -1 +0,0 @@
export { OrderType } from '@isa/common/data-access';

View File

@@ -1,5 +1,5 @@
import z from 'zod';
import { OrderType } from '../models';
import { OrderTypeFeature } from '../models';
import { PriceSchema } from '@isa/common/data-access';
// TODO: [Schema Refactoring - Critical Priority] Eliminate single-item schema duplication
@@ -35,7 +35,12 @@ const ItemSchema = z.object({
itemId: z.coerce.number().int().positive().describe('Unique item identifier'),
ean: z.string().describe('European Article Number barcode'),
price: PriceSchema.describe('Item price information').optional(),
quantity: z.coerce.number().int().positive().default(1).describe('Quantity of items to check availability for'),
quantity: z.coerce
.number()
.int()
.positive()
.default(1)
.describe('Quantity of items to check availability for'),
});
// Download items don't require quantity (always 1)
@@ -45,44 +50,76 @@ const DownloadItemSchema = z.object({
price: PriceSchema.describe('Item price information').optional(),
});
const ItemsSchema = z.array(ItemSchema).min(1).describe('List of items to check availability for');
const DownloadItemsSchema = z.array(DownloadItemSchema).min(1).describe('List of download items to check availability for');
const ItemsSchema = z
.array(ItemSchema)
.min(1)
.describe('List of items to check availability for');
const DownloadItemsSchema = z
.array(DownloadItemSchema)
.min(1)
.describe('List of download items to check availability for');
// In-Store availability (Rücklage) - requires branch context
export const GetInStoreAvailabilityParamsSchema = z.object({
orderType: z.literal(OrderType.InStore).describe('Order type specifying in-store availability check'),
branchId: z.coerce.number().int().positive().describe('Branch identifier for in-store availability').optional(),
itemsIds: z.array(z.coerce.number().int().positive()).min(1).describe('List of item identifiers to check in-store availability'),
orderType: z
.literal(OrderTypeFeature.InStore)
.describe('Order type specifying in-store availability check'),
branchId: z.coerce
.number()
.int()
.positive()
.describe('Branch identifier for in-store availability')
.optional(),
itemsIds: z
.array(z.coerce.number().int().positive())
.min(1)
.describe('List of item identifiers to check in-store availability'),
});
// Pickup availability (Abholung) - requires branch context
export const GetPickupAvailabilityParamsSchema = z.object({
orderType: z.literal(OrderType.Pickup).describe('Order type specifying pickup availability check'),
branchId: z.coerce.number().int().positive().describe('Branch identifier where items will be picked up'),
orderType: z
.literal(OrderTypeFeature.Pickup)
.describe('Order type specifying pickup availability check'),
branchId: z.coerce
.number()
.int()
.positive()
.describe('Branch identifier where items will be picked up'),
items: ItemsSchema,
});
// Standard delivery availability (Versand)
export const GetDeliveryAvailabilityParamsSchema = z.object({
orderType: z.literal(OrderType.Delivery).describe('Order type specifying standard delivery availability check'),
orderType: z
.literal(OrderTypeFeature.Delivery)
.describe('Order type specifying standard delivery availability check'),
items: ItemsSchema,
});
// DIG delivery availability (DIG-Versand) - for webshop customers
export const GetDigDeliveryAvailabilityParamsSchema = z.object({
orderType: z.literal(OrderType.DigitalShipping).describe('Order type specifying DIG delivery availability check for webshop customers'),
orderType: z
.literal(OrderTypeFeature.DigitalShipping)
.describe(
'Order type specifying DIG delivery availability check for webshop customers',
),
items: ItemsSchema,
});
// B2B delivery availability (B2B-Versand) - uses default branch
export const GetB2bDeliveryAvailabilityParamsSchema = z.object({
orderType: z.literal(OrderType.B2BShipping).describe('Order type specifying B2B delivery availability check'),
orderType: z
.literal(OrderTypeFeature.B2BShipping)
.describe('Order type specifying B2B delivery availability check'),
items: ItemsSchema,
});
// Download availability - quantity always 1
export const GetDownloadAvailabilityParamsSchema = z.object({
orderType: z.literal(OrderType.Download).describe('Order type specifying download availability check'),
orderType: z
.literal(OrderTypeFeature.Download)
.describe('Order type specifying download availability check'),
items: DownloadItemsSchema,
});
@@ -125,34 +162,61 @@ export type GetDownloadAvailabilityParams = z.infer<
// Single-item schemas use the same structure but accept a single item instead of an array
const SingleInStoreAvailabilityParamsSchema = z.object({
orderType: z.literal(OrderType.InStore).describe('Order type specifying in-store availability check'),
branchId: z.coerce.number().int().positive().describe('Branch identifier for in-store availability').optional(),
itemId: z.number().int().positive().describe('Unique item identifier to check in-store availability'),
orderType: z
.literal(OrderTypeFeature.InStore)
.describe('Order type specifying in-store availability check'),
branchId: z.coerce
.number()
.int()
.positive()
.describe('Branch identifier for in-store availability')
.optional(),
itemId: z
.number()
.int()
.positive()
.describe('Unique item identifier to check in-store availability'),
});
const SinglePickupAvailabilityParamsSchema = z.object({
orderType: z.literal(OrderType.Pickup).describe('Order type specifying pickup availability check'),
branchId: z.coerce.number().int().positive().describe('Branch identifier where item will be picked up'),
orderType: z
.literal(OrderTypeFeature.Pickup)
.describe('Order type specifying pickup availability check'),
branchId: z.coerce
.number()
.int()
.positive()
.describe('Branch identifier where item will be picked up'),
item: ItemSchema,
});
const SingleDeliveryAvailabilityParamsSchema = z.object({
orderType: z.literal(OrderType.Delivery).describe('Order type specifying standard delivery availability check'),
orderType: z
.literal(OrderTypeFeature.Delivery)
.describe('Order type specifying standard delivery availability check'),
item: ItemSchema,
});
const SingleDigDeliveryAvailabilityParamsSchema = z.object({
orderType: z.literal(OrderType.DigitalShipping).describe('Order type specifying DIG delivery availability check for webshop customers'),
orderType: z
.literal(OrderTypeFeature.DigitalShipping)
.describe(
'Order type specifying DIG delivery availability check for webshop customers',
),
item: ItemSchema,
});
const SingleB2bDeliveryAvailabilityParamsSchema = z.object({
orderType: z.literal(OrderType.B2BShipping).describe('Order type specifying B2B delivery availability check'),
orderType: z
.literal(OrderTypeFeature.B2BShipping)
.describe('Order type specifying B2B delivery availability check'),
item: ItemSchema,
});
const SingleDownloadAvailabilityParamsSchema = z.object({
orderType: z.literal(OrderType.Download).describe('Order type specifying download availability check'),
orderType: z
.literal(OrderTypeFeature.Download)
.describe('Order type specifying download availability check'),
item: DownloadItemSchema,
});

View File

@@ -22,19 +22,22 @@ export class ShoppingCartFacade {
return this.#shoppingCartService.createShoppingCart();
}
getShoppingCart(shoppingCartId: number, abortSignal?: AbortSignal) {
return this.#shoppingCartService.getShoppingCart(
async getShoppingCart(shoppingCartId: number, abortSignal?: AbortSignal) {
const sc = await this.#shoppingCartService.getShoppingCart(
shoppingCartId,
abortSignal,
);
return sc;
}
removeItem(params: RemoveShoppingCartItemParams) {
return this.#shoppingCartService.removeItem(params);
async removeItem(params: RemoveShoppingCartItemParams) {
const sc = await this.#shoppingCartService.removeItem(params);
return sc;
}
updateItem(params: UpdateShoppingCartItemParams) {
return this.#shoppingCartService.updateItem(params);
async updateItem(params: UpdateShoppingCartItemParams) {
const sc = await this.#shoppingCartService.updateItem(params);
return sc;
}
/**

View File

@@ -1,12 +1,15 @@
import { OrderType } from '../models';
import { OrderTypeFeature } from '@isa/common/data-access';
export function getOrderTypeFeature(
features: Record<string, string> = {},
): OrderType | undefined {
): OrderTypeFeature | undefined {
const orderType = features['orderType'];
if (orderType && Object.values(OrderType).includes(orderType as OrderType)) {
return orderType as OrderType;
if (
orderType &&
Object.values(OrderTypeFeature).includes(orderType as OrderTypeFeature)
) {
return orderType as OrderTypeFeature;
}
return undefined;
}

View File

@@ -1,110 +0,0 @@
import { DisplayOrderItem } from '@isa/oms/data-access';
import { OrderType } from '../models';
/**
* Represents a group of order items sharing the same branch (for pickup/in-store)
* or all items for delivery types that don't have branch-specific grouping.
*/
export type OrderItemBranchGroup = {
/** Branch ID (undefined for delivery types without branch grouping) */
branchId?: number;
/** Branch name (undefined for delivery types without branch grouping) */
branchName?: string;
/** Array of items in this branch group */
items: DisplayOrderItem[];
};
/**
* Order types that require grouping by branch/filiale.
* Other order types (Versand, DIG-Versand, etc.) are grouped together without branch subdivision.
*/
const ORDER_TYPES_WITH_BRANCH_GROUPING = [
OrderType.Pickup,
OrderType.InStore,
] as const;
/**
* Sorts branch groups by branch ID (ascending).
*/
const sortByBranchId = (
entries: [number, DisplayOrderItem[]][],
): [number, DisplayOrderItem[]][] => {
return [...entries].sort(([a], [b]) => a - b);
};
/**
* Groups display order items by their target branch.
* Only applies branch-level grouping for Abholung (Pickup) and Rücklage (InStore) order types.
* For other delivery types (Versand, DIG-Versand, etc.), returns a single group with all items.
*
* Uses item.order.targetBranch for grouping since items inherit their parent order's branch information.
*
* @param orderType - The order type to determine if branch grouping is needed
* @param items - Array of DisplayOrderItem objects to group
* @returns Array of OrderItemBranchGroup objects, each containing items for a specific branch
*
* @example
* ```typescript
* // For Abholung (pickup) items from different branches
* const pickupItems = [
* { id: 1, order: { targetBranch: { id: 1, name: 'München' } } },
* { id: 2, order: { targetBranch: { id: 2, name: 'Berlin' } } }
* ];
* const groups = groupDisplayOrderItemsByBranch('Abholung', pickupItems);
* // [
* // { branchId: 1, branchName: 'München', items: [item1] },
* // { branchId: 2, branchName: 'Berlin', items: [item2] }
* // ]
*
* // For Versand (delivery) items
* const deliveryItems = [
* { id: 1, ... },
* { id: 2, ... }
* ];
* const groups = groupDisplayOrderItemsByBranch('Versand', deliveryItems);
* // [
* // { branchId: undefined, branchName: undefined, items: [item1, item2] }
* // ]
* ```
*/
export function groupDisplayOrderItemsByBranch(
orderType: OrderType | string,
items: DisplayOrderItem[],
): OrderItemBranchGroup[] {
const needsBranchGrouping =
orderType === OrderType.Pickup || orderType === OrderType.InStore;
if (!needsBranchGrouping) {
// For delivery types without branch grouping, return single group with all items
return [
{
branchId: undefined,
branchName: undefined,
items,
},
];
}
// For Abholung/Rücklage, group by item.order.targetBranch.id
const branchGroups = items.reduce((map, item) => {
const branchId = item.order?.targetBranch?.id ?? 0;
if (!map.has(branchId)) {
map.set(branchId, []);
}
map.get(branchId)!.push(item);
return map;
}, new Map<number, DisplayOrderItem[]>());
// Convert Map to array of OrderItemBranchGroup, sorted by branch ID
return sortByBranchId(Array.from(branchGroups.entries())).map(
([branchId, branchItems]) => {
const branch = branchItems[0]?.order?.targetBranch;
return {
branchId: branchId || undefined,
branchName: branch?.name,
items: branchItems,
};
},
);
}

View File

@@ -1,48 +0,0 @@
import { DisplayOrder, DisplayOrderItem } from '@isa/oms/data-access';
import { getOrderTypeFeature } from './get-order-type-feature.helper';
import { OrderType } from '../models';
/**
* Groups display order items by their delivery type (item.features.orderType).
*
* Unlike groupDisplayOrdersByDeliveryType which groups entire orders,
* this groups individual items since items within one order can have different delivery types.
*
* @param orders - Array of DisplayOrder objects containing items to group
* @returns Map where keys are order types (Abholung, Rücklage, Versand, etc.)
* and values are arrays of items with that type
*
* @example
* ```typescript
* const orders = [
* {
* id: 1,
* features: { orderType: 'Abholung' },
* items: [
* { id: 1, features: { orderType: 'Abholung' }, ... },
* { id: 2, features: { orderType: 'Rücklage' }, ... }
* ]
* }
* ];
*
* const grouped = groupDisplayOrderItemsByDeliveryType(orders);
* // Map {
* // 'Abholung' => [item1],
* // 'Rücklage' => [item2]
* // }
* ```
*/
export function groupDisplayOrderItemsByDeliveryType(
orders: DisplayOrder[],
): Map<OrderType | string, DisplayOrderItem[]> {
const allItems = orders.flatMap((order) => order.items ?? []);
return allItems.reduce((map, item) => {
const orderType = getOrderTypeFeature(item.features) ?? 'Unbekannt';
if (!map.has(orderType)) {
map.set(orderType, []);
}
map.get(orderType)!.push(item);
return map;
}, new Map<OrderType | string, DisplayOrderItem[]>());
}

View File

@@ -1,114 +1,115 @@
import { DisplayOrder, DisplayOrderItem } from '@isa/oms/data-access';
import { OrderType } from '../models';
/**
* Represents a group of orders sharing the same branch (for pickup/in-store)
* or all orders for delivery types that don't have branch-specific grouping.
*/
export type OrderBranchGroup = {
/** Branch ID (undefined for delivery types without branch grouping) */
branchId?: number;
/** Branch name (undefined for delivery types without branch grouping) */
branchName?: string;
/** Array of orders in this branch group */
orders: DisplayOrder[];
/** Flattened array of all items from all orders in this group */
allItems: DisplayOrderItem[];
};
/**
* Order types that require grouping by branch/filiale.
* Other order types (Versand, DIG-Versand, etc.) are grouped together without branch subdivision.
*/
const ORDER_TYPES_WITH_BRANCH_GROUPING = [
OrderType.Pickup,
OrderType.InStore,
] as const;
/**
* Sorts orders by branch ID (ascending).
*/
const sortByBranchId = (
entries: [number, DisplayOrder[]][],
): [number, DisplayOrder[]][] => {
return [...entries].sort(([a], [b]) => a - b);
};
/**
* Groups display orders by their target branch.
* Only applies branch-level grouping for Abholung (Pickup) and Rücklage (InStore) order types.
* For other delivery types (Versand, DIG-Versand, etc.), returns a single group with all orders.
*
* @param orderType - The order type to determine if branch grouping is needed
* @param orders - Array of DisplayOrder objects to group
* @returns Array of OrderBranchGroup objects, each containing orders and items for a specific branch
*
* @example
* ```typescript
* // For Abholung (pickup) orders
* const pickupOrders = [
* { targetBranch: { id: 1, name: 'München' }, items: [item1, item2] },
* { targetBranch: { id: 2, name: 'Berlin' }, items: [item3] }
* ];
* const groups = groupDisplayOrdersByBranch('Abholung', pickupOrders);
* // [
* // { branchId: 1, branchName: 'München', orders: [...], allItems: [item1, item2] },
* // { branchId: 2, branchName: 'Berlin', orders: [...], allItems: [item3] }
* // ]
*
* // For Versand (delivery) orders
* const deliveryOrders = [
* { items: [item1] },
* { items: [item2, item3] }
* ];
* const groups = groupDisplayOrdersByBranch('Versand', deliveryOrders);
* // [
* // { branchId: undefined, branchName: undefined, orders: [...], allItems: [item1, item2, item3] }
* // ]
* ```
*/
export function groupDisplayOrdersByBranch(
orderType: OrderType | string,
orders: DisplayOrder[],
): OrderBranchGroup[] {
const needsBranchGrouping =
orderType === OrderType.Pickup || orderType === OrderType.InStore;
if (!needsBranchGrouping) {
// For delivery types without branch grouping, return single group with all orders
const allItems = orders.flatMap((order) => order.items ?? []);
return [
{
branchId: undefined,
branchName: undefined,
orders,
allItems,
},
];
}
// For Abholung/Rücklage, group by targetBranch.id
const branchGroups = orders.reduce((map, order) => {
const branchId = order.targetBranch?.id ?? 0;
if (!map.has(branchId)) {
map.set(branchId, []);
}
map.get(branchId)!.push(order);
return map;
}, new Map<number, DisplayOrder[]>());
// Convert Map to array of OrderBranchGroup, sorted by branch ID
return sortByBranchId(Array.from(branchGroups.entries())).map(
([branchId, branchOrders]) => {
const branch = branchOrders[0]?.targetBranch;
const allItems = branchOrders.flatMap((order) => order.items ?? []);
return {
branchId: branchId || undefined,
branchName: branch?.name,
orders: branchOrders,
allItems,
};
},
);
}
import { DisplayOrder, DisplayOrderItem } from '@isa/oms/data-access';
import { OrderTypeFeature } from '../models';
/**
* Represents a group of orders sharing the same branch (for pickup/in-store)
* or all orders for delivery types that don't have branch-specific grouping.
*/
export type OrderBranchGroup = {
/** Branch ID (undefined for delivery types without branch grouping) */
branchId?: number;
/** Branch name (undefined for delivery types without branch grouping) */
branchName?: string;
/** Array of orders in this branch group */
orders: DisplayOrder[];
/** Flattened array of all items from all orders in this group */
allItems: DisplayOrderItem[];
};
/**
* Order types that require grouping by branch/filiale.
* Other order types (Versand, DIG-Versand, etc.) are grouped together without branch subdivision.
*/
const ORDER_TYPES_WITH_BRANCH_GROUPING = [
OrderTypeFeature.Pickup,
OrderTypeFeature.InStore,
] as const;
/**
* Sorts orders by branch ID (ascending).
*/
const sortByBranchId = (
entries: [number, DisplayOrder[]][],
): [number, DisplayOrder[]][] => {
return [...entries].sort(([a], [b]) => a - b);
};
/**
* Groups display orders by their target branch.
* Only applies branch-level grouping for Abholung (Pickup) and Rücklage (InStore) order types.
* For other delivery types (Versand, DIG-Versand, etc.), returns a single group with all orders.
*
* @param orderType - The order type to determine if branch grouping is needed
* @param orders - Array of DisplayOrder objects to group
* @returns Array of OrderBranchGroup objects, each containing orders and items for a specific branch
*
* @example
* ```typescript
* // For Abholung (pickup) orders
* const pickupOrders = [
* { targetBranch: { id: 1, name: 'München' }, items: [item1, item2] },
* { targetBranch: { id: 2, name: 'Berlin' }, items: [item3] }
* ];
* const groups = groupDisplayOrdersByBranch('Abholung', pickupOrders);
* // [
* // { branchId: 1, branchName: 'München', orders: [...], allItems: [item1, item2] },
* // { branchId: 2, branchName: 'Berlin', orders: [...], allItems: [item3] }
* // ]
*
* // For Versand (delivery) orders
* const deliveryOrders = [
* { items: [item1] },
* { items: [item2, item3] }
* ];
* const groups = groupDisplayOrdersByBranch('Versand', deliveryOrders);
* // [
* // { branchId: undefined, branchName: undefined, orders: [...], allItems: [item1, item2, item3] }
* // ]
* ```
*/
export function groupDisplayOrdersByBranch(
orderType: OrderTypeFeature | string,
orders: DisplayOrder[],
): OrderBranchGroup[] {
const needsBranchGrouping =
orderType === OrderTypeFeature.Pickup ||
orderType === OrderTypeFeature.InStore;
if (!needsBranchGrouping) {
// For delivery types without branch grouping, return single group with all orders
const allItems = orders.flatMap((order) => order.items ?? []);
return [
{
branchId: undefined,
branchName: undefined,
orders,
allItems,
},
];
}
// For Abholung/Rücklage, group by targetBranch.id
const branchGroups = orders.reduce((map, order) => {
const branchId = order.targetBranch?.id ?? 0;
if (!map.has(branchId)) {
map.set(branchId, []);
}
map.get(branchId)!.push(order);
return map;
}, new Map<number, DisplayOrder[]>());
// Convert Map to array of OrderBranchGroup, sorted by branch ID
return sortByBranchId(Array.from(branchGroups.entries())).map(
([branchId, branchOrders]) => {
const branch = branchOrders[0]?.targetBranch;
const allItems = branchOrders.flatMap((order) => order.items ?? []);
return {
branchId: branchId || undefined,
branchName: branch?.name,
orders: branchOrders,
allItems,
};
},
);
}

View File

@@ -0,0 +1,409 @@
import { describe, it, expect } from 'vitest';
import {
groupItemsByDeliveryDestination,
SHIPPING_ORDER_TYPE_FEATURES,
BRANCH_ORDER_TYPE_FEATURES,
} from './group-items-by-delivery-destination.helper';
import {
DisplayOrderDTO,
DisplayOrderItemDTO,
BranchDTO,
DisplayAddresseeDTO,
} from '@generated/swagger/oms-api';
import { OrderTypeFeature } from '@isa/common/data-access';
describe('groupItemsByDeliveryDestination', () => {
it('should return empty array when orders array is empty', () => {
const result = groupItemsByDeliveryDestination([]);
expect(result).toHaveLength(0);
});
it('should return empty array when orders is undefined', () => {
const result = groupItemsByDeliveryDestination(undefined);
expect(result).toHaveLength(0);
});
it('should return empty array when orders is null', () => {
const result = groupItemsByDeliveryDestination(null);
expect(result).toHaveLength(0);
});
it('should skip orders without items', () => {
const orders: DisplayOrderDTO[] = [
{
id: '1',
items: [],
features: { orderType: OrderTypeFeature.Delivery },
} as DisplayOrderDTO,
];
const result = groupItemsByDeliveryDestination(orders);
expect(result).toHaveLength(0);
});
it('should skip items without valid order type', () => {
const orders: DisplayOrderDTO[] = [
{
id: '1',
items: [
{ id: 'item1', features: {} } as DisplayOrderItemDTO,
],
features: {},
} as DisplayOrderDTO,
];
const result = groupItemsByDeliveryDestination(orders);
expect(result).toHaveLength(0);
});
it('should group single order with single item', () => {
const orders: DisplayOrderDTO[] = [
{
id: '1',
items: [
{
id: 'item1',
features: { orderType: OrderTypeFeature.Delivery },
} as DisplayOrderItemDTO,
],
features: { orderType: OrderTypeFeature.Delivery },
} as DisplayOrderDTO,
];
const result = groupItemsByDeliveryDestination(orders);
expect(result).toHaveLength(1);
expect(result[0].orderType).toBe(OrderTypeFeature.Delivery);
expect(result[0].orderTypeIcon).toBe('isaDeliveryVersand');
expect(result[0].items).toHaveLength(1);
expect(result[0].items[0].id).toBe('item1');
});
it('should group items by shipping address for delivery orders', () => {
const address1: DisplayAddresseeDTO = {
firstName: 'John',
lastName: 'Doe',
gender: 0,
address: {
street: 'Main Street',
streetNumber: '123',
city: 'Berlin',
zipCode: '10115',
},
} as DisplayAddresseeDTO;
const address2: DisplayAddresseeDTO = {
firstName: 'Jane',
lastName: 'Smith',
gender: 0,
address: {
street: 'Second Street',
streetNumber: '456',
city: 'Munich',
zipCode: '80331',
},
} as DisplayAddresseeDTO;
const orders: DisplayOrderDTO[] = [
{
id: '1',
items: [
{
id: 'item1',
features: { orderType: OrderTypeFeature.Delivery },
} as DisplayOrderItemDTO,
{
id: 'item2',
features: { orderType: OrderTypeFeature.Delivery },
} as DisplayOrderItemDTO,
],
features: { orderType: OrderTypeFeature.Delivery },
shippingAddress: address1,
} as DisplayOrderDTO,
{
id: '2',
items: [
{
id: 'item3',
features: { orderType: OrderTypeFeature.Delivery },
} as DisplayOrderItemDTO,
],
features: { orderType: OrderTypeFeature.Delivery },
shippingAddress: address2,
} as DisplayOrderDTO,
];
const result = groupItemsByDeliveryDestination(orders);
expect(result).toHaveLength(2);
const group1 = result.find(
(g) => g.shippingAddress?.firstName === 'John' && g.shippingAddress?.lastName === 'Doe',
);
expect(group1).toBeDefined();
expect(group1!.items).toHaveLength(2);
expect(group1!.orderType).toBe(OrderTypeFeature.Delivery);
expect(group1!.shippingAddress).toEqual(address1);
const group2 = result.find(
(g) => g.shippingAddress?.firstName === 'Jane' && g.shippingAddress?.lastName === 'Smith',
);
expect(group2).toBeDefined();
expect(group2!.items).toHaveLength(1);
expect(group2!.orderType).toBe(OrderTypeFeature.Delivery);
expect(group2!.shippingAddress).toEqual(address2);
});
it('should group items by target branch for pickup orders', () => {
const branch1: BranchDTO = {
id: 'branch1',
name: 'Branch 1',
} as BranchDTO;
const branch2: BranchDTO = {
id: 'branch2',
name: 'Branch 2',
} as BranchDTO;
const orders: DisplayOrderDTO[] = [
{
id: '1',
items: [
{
id: 'item1',
features: { orderType: OrderTypeFeature.Pickup },
} as DisplayOrderItemDTO,
],
features: { orderType: OrderTypeFeature.Pickup },
targetBranch: branch1,
} as DisplayOrderDTO,
{
id: '2',
items: [
{
id: 'item2',
features: { orderType: OrderTypeFeature.Pickup },
} as DisplayOrderItemDTO,
],
features: { orderType: OrderTypeFeature.Pickup },
targetBranch: branch2,
} as DisplayOrderDTO,
];
const result = groupItemsByDeliveryDestination(orders);
expect(result).toHaveLength(2);
const group1 = result.find((g) => g.targetBranch?.id === 'branch1');
expect(group1).toBeDefined();
expect(group1!.items).toHaveLength(1);
expect(group1!.orderType).toBe(OrderTypeFeature.Pickup);
const group2 = result.find((g) => g.targetBranch?.id === 'branch2');
expect(group2).toBeDefined();
expect(group2!.items).toHaveLength(1);
expect(group2!.orderType).toBe(OrderTypeFeature.Pickup);
});
it('should group multiple items with same destination together', () => {
const branch1: BranchDTO = {
id: 'branch1',
name: 'Branch 1',
} as BranchDTO;
const orders: DisplayOrderDTO[] = [
{
id: '1',
items: [
{
id: 'item1',
features: { orderType: OrderTypeFeature.Pickup },
} as DisplayOrderItemDTO,
],
features: { orderType: OrderTypeFeature.Pickup },
targetBranch: branch1,
} as DisplayOrderDTO,
{
id: '2',
items: [
{
id: 'item2',
features: { orderType: OrderTypeFeature.Pickup },
} as DisplayOrderItemDTO,
],
features: { orderType: OrderTypeFeature.Pickup },
targetBranch: branch1,
} as DisplayOrderDTO,
];
const result = groupItemsByDeliveryDestination(orders);
expect(result).toHaveLength(1);
expect(result[0].items).toHaveLength(2);
expect(result[0].targetBranch?.id).toBe('branch1');
expect(result[0].orderType).toBe(OrderTypeFeature.Pickup);
});
it('should handle mixed order types in different orders', () => {
const branch1: BranchDTO = {
id: 'branch1',
name: 'Branch 1',
} as BranchDTO;
const address1: DisplayAddresseeDTO = {
firstName: 'Alice',
lastName: 'Johnson',
gender: 0,
address: {
street: 'Oak Street',
streetNumber: '789',
city: 'Hamburg',
zipCode: '20095',
},
} as DisplayAddresseeDTO;
const orders: DisplayOrderDTO[] = [
{
id: '1',
items: [
{
id: 'item1',
features: { orderType: OrderTypeFeature.Pickup },
} as DisplayOrderItemDTO,
],
features: { orderType: OrderTypeFeature.Pickup },
targetBranch: branch1,
} as DisplayOrderDTO,
{
id: '2',
items: [
{
id: 'item2',
features: { orderType: OrderTypeFeature.Delivery },
} as DisplayOrderItemDTO,
],
features: { orderType: OrderTypeFeature.Delivery },
shippingAddress: address1,
} as DisplayOrderDTO,
];
const result = groupItemsByDeliveryDestination(orders);
expect(result).toHaveLength(2);
const pickupGroup = result.find(
(g) => g.orderType === OrderTypeFeature.Pickup,
);
expect(pickupGroup).toBeDefined();
expect(pickupGroup!.targetBranch?.id).toBe('branch1');
const deliveryGroup = result.find(
(g) => g.orderType === OrderTypeFeature.Delivery,
);
expect(deliveryGroup).toBeDefined();
expect(deliveryGroup!.shippingAddress).toEqual(address1);
});
it('should include order features in the group', () => {
const orders: DisplayOrderDTO[] = [
{
id: '1',
items: [
{
id: 'item1',
features: { orderType: OrderTypeFeature.Delivery },
} as DisplayOrderItemDTO,
],
features: { orderType: OrderTypeFeature.Delivery, customFeature: 'value' },
} as DisplayOrderDTO,
];
const result = groupItemsByDeliveryDestination(orders);
expect(result).toHaveLength(1);
expect(result[0].features).toEqual({
orderType: OrderTypeFeature.Delivery,
customFeature: 'value',
});
});
it('should handle B2BShipping as shipping order type', () => {
const address1: DisplayAddresseeDTO = {
firstName: 'Bob',
lastName: 'Williams',
gender: 0,
address: {
street: 'Business Ave',
streetNumber: '100',
city: 'Frankfurt',
zipCode: '60311',
},
} as DisplayAddresseeDTO;
const orders: DisplayOrderDTO[] = [
{
id: '1',
items: [
{
id: 'item1',
features: { orderType: OrderTypeFeature.B2BShipping },
} as DisplayOrderItemDTO,
],
features: { orderType: OrderTypeFeature.B2BShipping },
shippingAddress: address1,
} as DisplayOrderDTO,
];
const result = groupItemsByDeliveryDestination(orders);
expect(result).toHaveLength(1);
expect(result[0].orderType).toBe(OrderTypeFeature.B2BShipping);
expect(result[0].orderTypeIcon).toBe('isaDeliveryB2BVersand1');
expect(result[0].shippingAddress).toEqual(address1);
});
it('should handle InStore as branch order type', () => {
const branch1: BranchDTO = {
id: 'branch1',
name: 'Branch 1',
} as BranchDTO;
const orders: DisplayOrderDTO[] = [
{
id: '1',
items: [
{
id: 'item1',
features: { orderType: OrderTypeFeature.InStore },
} as DisplayOrderItemDTO,
],
features: { orderType: OrderTypeFeature.InStore },
targetBranch: branch1,
} as DisplayOrderDTO,
];
const result = groupItemsByDeliveryDestination(orders);
expect(result).toHaveLength(1);
expect(result[0].orderType).toBe(OrderTypeFeature.InStore);
expect(result[0].orderTypeIcon).toBe('isaDeliveryRuecklage1');
expect(result[0].targetBranch?.id).toBe('branch1');
});
});
describe('SHIPPING_ORDER_TYPE_FEATURES', () => {
it('should contain expected shipping order types', () => {
expect(SHIPPING_ORDER_TYPE_FEATURES).toContain(OrderTypeFeature.Delivery);
expect(SHIPPING_ORDER_TYPE_FEATURES).toContain(OrderTypeFeature.B2BShipping);
expect(SHIPPING_ORDER_TYPE_FEATURES).toContain(OrderTypeFeature.DigitalShipping);
expect(SHIPPING_ORDER_TYPE_FEATURES).toHaveLength(3);
});
});
describe('BRANCH_ORDER_TYPE_FEATURES', () => {
it('should contain expected branch order types', () => {
expect(BRANCH_ORDER_TYPE_FEATURES).toContain(OrderTypeFeature.Pickup);
expect(BRANCH_ORDER_TYPE_FEATURES).toContain(OrderTypeFeature.InStore);
expect(BRANCH_ORDER_TYPE_FEATURES).toHaveLength(2);
});
});

View File

@@ -0,0 +1,132 @@
import {
BranchDTO,
DisplayAddresseeDTO,
DisplayOrderDTO,
DisplayOrderItemDTO,
} from '@generated/swagger/oms-api';
import { getOrderTypeFeature } from './get-order-type-feature.helper';
import { getOrderTypeIcon } from './get-order-type-icon.helper';
import { OrderTypeFeature } from '@isa/common/data-access';
/**
* Order types that are associated with shipping addresses
*/
export const SHIPPING_ORDER_TYPE_FEATURES: readonly OrderTypeFeature[] = [
OrderTypeFeature.Delivery,
OrderTypeFeature.B2BShipping,
OrderTypeFeature.DigitalShipping,
];
/**
* Order types that are associated with physical branch locations
*/
export const BRANCH_ORDER_TYPE_FEATURES: readonly OrderTypeFeature[] = [
OrderTypeFeature.Pickup,
OrderTypeFeature.InStore,
];
/**
* Represents a group of order items with the same delivery type and destination
*/
export type OrderItemGroup = {
/** The order type feature (e.g., 'Versand', 'Abholung') */
orderType: OrderTypeFeature;
/** The icon name for this order type */
orderTypeIcon: string;
/** The target branch for pickup/in-store orders */
targetBranch?: BranchDTO;
/** The shipping address for delivery orders */
shippingAddress?: DisplayAddresseeDTO;
/** Order features from the DisplayOrderDTO */
features: Record<string, string>;
/** Array of items in this group */
items: DisplayOrderItemDTO[];
};
/**
* Groups display order items by their delivery type and destination.
*
* Items are grouped by:
* - Order type (Versand, Abholung, Rücklage, etc.)
* - Destination (shipping address for delivery orders, branch for pickup/in-store orders)
*
* Uses Map-based lookups for O(1) performance instead of array.find().
*
* @param orders - Array of DisplayOrderDTO objects to process
* @returns Array of OrderItemGroup objects, each containing items with the same delivery type and destination
*
* @example
* ```typescript
* const orders = [
* { id: '1', items: [item1, item2], features: { orderType: 'Versand' }, shippingAddress: addr1 },
* { id: '2', items: [item3], features: { orderType: 'Abholung' }, targetBranch: branch1 }
* ];
*
* const grouped = groupItemsByDeliveryDestination(orders);
* // Returns: [
* // { orderType: 'Versand', items: [item1, item2], shippingAddress: addr1, ... },
* // { orderType: 'Abholung', items: [item3], targetBranch: branch1, ... }
* // ]
* ```
*/
export function groupItemsByDeliveryDestination(
orders: readonly DisplayOrderDTO[] | undefined | null,
): OrderItemGroup[] {
const groupMap = new Map<string, OrderItemGroup>();
if (!orders?.length) {
return [];
}
for (const order of orders) {
const targetBranch = order.targetBranch;
const shippingAddress = order.shippingAddress;
const features = order.features ?? {};
if (!order.items?.length) {
continue;
}
for (const item of order.items) {
const orderType = getOrderTypeFeature(item.features);
if (!orderType) {
continue;
}
// Generate unique key for this group
let groupKey = orderType;
if (
SHIPPING_ORDER_TYPE_FEATURES.includes(orderType) &&
shippingAddress
) {
groupKey += `-shippingAddress-${JSON.stringify(shippingAddress)}`;
}
if (BRANCH_ORDER_TYPE_FEATURES.includes(orderType) && targetBranch) {
groupKey += `-targetBranch-${targetBranch.id}`;
}
// Get or create group
let group = groupMap.get(groupKey);
if (!group) {
group = {
orderType: orderType,
orderTypeIcon: getOrderTypeIcon(orderType),
targetBranch: targetBranch,
shippingAddress: shippingAddress,
features: features,
items: [],
};
groupMap.set(groupKey, group);
}
group.items.push(item);
}
}
return Array.from(groupMap.values());
}

View File

@@ -1,38 +1,38 @@
import { OrderType } from '../models';
import { getOrderTypeFeature } from './get-order-type-feature.helper';
/**
* Checks if the order type feature in the provided features record matches any of the specified order types.
*
* @param features - Record containing feature flags with an 'orderType' key
* @param orderTypes - Array of order types to check against
* @returns true if the feature's order type is one of the provided types, false otherwise
*
* @example
* ```typescript
* // Check if order type is a delivery type
* const isDelivery = hasOrderTypeFeature(features, [
* OrderType.Delivery,
* OrderType.DigitalShipping,
* OrderType.B2BShipping
* ]);
*
* // Check if order type requires a target branch
* const hasTargetBranch = hasOrderTypeFeature(features, [
* OrderType.InStore,
* OrderType.Pickup
* ]);
* ```
*/
export function hasOrderTypeFeature(
features: Record<string, string> | undefined,
orderTypes: readonly OrderType[],
): boolean {
const orderType = getOrderTypeFeature(features);
if (!orderType) {
return false;
}
return orderTypes.includes(orderType);
}
import { OrderTypeFeature } from '../models';
import { getOrderTypeFeature } from './get-order-type-feature.helper';
/**
* Checks if the order type feature in the provided features record matches any of the specified order types.
*
* @param features - Record containing feature flags with an 'orderType' key
* @param orderTypes - Array of order types to check against
* @returns true if the feature's order type is one of the provided types, false otherwise
*
* @example
* ```typescript
* // Check if order type is a delivery type
* const isDelivery = hasOrderTypeFeature(features, [
* OrderType.Delivery,
* OrderType.DigitalShipping,
* OrderType.B2BShipping
* ]);
*
* // Check if order type requires a target branch
* const hasTargetBranch = hasOrderTypeFeature(features, [
* OrderType.InStore,
* OrderType.Pickup
* ]);
* ```
*/
export function hasOrderTypeFeature(
features: Record<string, string> | undefined,
orderTypes: readonly OrderTypeFeature[],
): boolean {
const orderType = getOrderTypeFeature(features);
if (!orderType) {
return false;
}
return orderTypes.includes(orderType);
}

View File

@@ -12,8 +12,7 @@ export * from './group-by-branch.helper';
export * from './group-by-order-type.helper';
export * from './group-display-orders-by-branch.helper';
export * from './group-display-orders-by-delivery-type.helper';
export * from './group-display-order-items-by-branch.helper';
export * from './group-display-order-items-by-delivery-type.helper';
export * from './group-items-by-delivery-destination.helper';
export * from './item-selection-changed.helper';
export * from './merge-reward-selection-items.helper';
export * from './should-show-grouping.helper';

View File

@@ -3,10 +3,12 @@ export * from './campaign';
export * from './checkout-item';
export * from './checkout';
export * from './customer-type-analysis';
export * from './destination';
export * from './gender';
export * from './loyalty';
export * from './ola-availability';
export * from './order-options';
export * from './order-type-feature';
export * from './order-type';
export * from './order';
export * from './price';

View File

@@ -0,0 +1 @@
export { OrderTypeFeature } from '@isa/common/data-access';

View File

@@ -7,27 +7,24 @@ import { ShoppingCartService } from '../services';
export class ShoppingCartResource {
#shoppingCartService = inject(ShoppingCartService);
#params = signal<{ shoppingCartId: number | undefined }>({
shoppingCartId: undefined,
});
#params = signal<number | undefined>(undefined);
params(params: { shoppingCartId: number | undefined }) {
this.#params.set(params);
setShoppingCartId(shoppingCartId: number | undefined) {
this.#params.set(shoppingCartId);
if (shoppingCartId === undefined) {
this.resource.set(undefined);
}
}
readonly resource = resource({
params: () => this.#params(),
loader: ({ params, abortSignal }) =>
params?.shoppingCartId
? this.#shoppingCartService.getShoppingCart(
params.shoppingCartId!,
abortSignal,
)
: Promise.resolve(null),
this.#shoppingCartService.getShoppingCart(params, abortSignal),
});
}
@Injectable()
@Injectable({ providedIn: 'root' })
export class SelectedShoppingCartResource extends ShoppingCartResource {
#tabId = inject(TabService).activatedTabId;
#checkoutMetadata = inject(CheckoutMetadataService);
@@ -41,12 +38,12 @@ export class SelectedShoppingCartResource extends ShoppingCartResource {
? this.#checkoutMetadata.getShoppingCartId(tabId)
: undefined;
this.params({ shoppingCartId });
this.setShoppingCartId(shoppingCartId);
});
}
}
@Injectable()
@Injectable({ providedIn: 'root' })
export class SelectedRewardShoppingCartResource extends ShoppingCartResource {
#tabId = inject(TabService).activatedTabId;
#checkoutMetadata = inject(CheckoutMetadataService);
@@ -60,7 +57,7 @@ export class SelectedRewardShoppingCartResource extends ShoppingCartResource {
? this.#checkoutMetadata.getRewardShoppingCartId(tabId)
: undefined;
this.params({ shoppingCartId });
this.setShoppingCartId(shoppingCartId);
});
}
}

View File

@@ -30,5 +30,3 @@ export const DestinationSchema = z.object({
.describe('Target branch')
.optional(),
});
export type Destination = z.infer<typeof DestinationSchema>;

View File

@@ -76,20 +76,4 @@ export class CheckoutMetadataService {
this.#tabService.entityMap(),
);
}
getCompletedShoppingCarts(tabId: number): ShoppingCart[] | undefined {
return getMetadataHelper(
tabId,
COMPLETED_SHOPPING_CARTS_METADATA_KEY,
z.array(z.any()).optional(),
this.#tabService.entityMap(),
);
}
addCompletedShoppingCart(tabId: number, shoppingCart: ShoppingCart) {
const existingCarts = this.getCompletedShoppingCarts(tabId) || [];
this.#tabService.patchTabMetadata(tabId, {
[COMPLETED_SHOPPING_CARTS_METADATA_KEY]: [...existingCarts, shoppingCart],
});
}
}

View File

@@ -39,7 +39,7 @@ import {
import { CheckoutCompletionError } from '../errors';
import {
AvailabilityService,
OrderType,
OrderTypeFeature,
Availability as AvailabilityModel,
} from '@isa/availability/data-access';
import { AvailabilityAdapter } from '../adapters';
@@ -451,7 +451,7 @@ export class CheckoutService {
const availabilitiesDict =
await this.#availabilityService.getAvailabilities(
{
orderType: OrderType.Download,
orderType: OrderTypeFeature.Download,
items: availabilityItems,
},
abortSignal,
@@ -546,7 +546,7 @@ export class CheckoutService {
if (orderType === 'DIG-Versand') {
availability = await this.#availabilityService.getAvailability(
{
orderType: OrderType.DigitalShipping,
orderType: OrderTypeFeature.DigitalShipping,
item: availabilityItem,
},
abortSignal,
@@ -554,7 +554,7 @@ export class CheckoutService {
} else if (orderType === 'B2B-Versand') {
availability = await this.#availabilityService.getAvailability(
{
orderType: OrderType.B2BShipping,
orderType: OrderTypeFeature.B2BShipping,
item: availabilityItem,
},
abortSignal,
@@ -576,7 +576,8 @@ export class CheckoutService {
availabilityDTO.price.value = {
value: originalPrice,
currency: availabilityDTO.price.value?.currency ?? 'EUR',
currencySymbol: availabilityDTO.price.value?.currencySymbol ?? '€',
currencySymbol:
availabilityDTO.price.value?.currencySymbol ?? '€',
};
}

View File

@@ -0,0 +1,79 @@
import { ChangeDetectionStrategy, Component, computed, inject, input } from '@angular/core';
import { RouterLink } from '@angular/router';
import { DBHOrderItemListItemDTO } from '@generated/swagger/oms-api';
import { TabService } from '@isa/core/tabs';
import { IconButtonComponent } from '@isa/ui/buttons';
import { provideIcons } from '@ng-icons/core';
import { isaActionChevronRight } from '@isa/icons';
/**
* Card component displaying a single open reward task (Prämienausgabe).
*
* Shows customer name and a chevron button for navigation to the order completion page.
*/
@Component({
selector: 'reward-catalog-open-task-card',
standalone: true,
imports: [IconButtonComponent, RouterLink],
providers: [provideIcons({ isaActionChevronRight })],
template: `
<a
class="bg-isa-white flex items-center justify-between px-[22px] py-[20px] rounded-2xl w-[334px] cursor-pointer no-underline"
data-what="open-task-card"
[attr.data-which]="task().orderItemId"
[routerLink]="routePath()"
>
<div class="flex flex-col gap-1">
<p class="isa-text-body-1-regular text-isa-neutral-900">
Offene Prämienausgabe
</p>
<p class="isa-text-body-1-bold text-isa-neutral-900">
{{ customerName() }}
</p>
</div>
<ui-icon-button
name="isaActionChevronRight"
[color]="'secondary'"
size="medium"
data-what="open-task-card-button"
/>
</a>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class OpenTaskCardComponent {
readonly #tabService = inject(TabService);
/**
* The open task data to display
*/
readonly task = input.required<DBHOrderItemListItemDTO>();
/**
* Computed customer name from first and last name
*/
readonly customerName = (): string => {
const t = this.task();
const firstName = t.firstName || '';
const lastName = t.lastName || '';
return `${firstName} ${lastName}`.trim() || 'Unbekannt';
};
/**
* Current tab ID for navigation
*/
readonly #tabId = computed(() => this.#tabService.activatedTab()?.id ?? Date.now());
/**
* Route path to the reward order confirmation page.
* Route: /:tabId/reward/order-confirmation/:orderId
*/
readonly routePath = computed(() => {
const orderId = this.task().orderId;
if (!orderId) {
console.warn('Missing orderId in task', this.task());
return [];
}
return ['/', this.#tabId(), 'reward', 'order-confirmation', orderId.toString()];
});
}

View File

@@ -0,0 +1,39 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { OpenRewardTasksResource } from '@isa/oms/data-access';
import { CarouselComponent } from '@isa/ui/carousel';
import { OpenTaskCardComponent } from './open-task-card.component';
/**
* Carousel component displaying open reward distribution tasks (Prämienausgabe).
*
* Shows a horizontal scrollable list of unfinished reward orders at the top of the
* reward catalog. Hidden when no open tasks exist.
*
* Features:
* - Keyboard navigation (Arrow Left/Right)
* - Automatic visibility based on task availability
* - Shared global resource for consistent data across app
*/
@Component({
selector: 'reward-catalog-open-tasks-carousel',
standalone: true,
imports: [CarouselComponent, OpenTaskCardComponent],
template: `
@if (openTasksResource.hasOpenTasks()) {
<div class="mb-4" data-what="open-tasks-carousel">
<ui-carousel [gap]="'1rem'" [arrowAutoHide]="true">
@for (task of openTasksResource.tasks(); track task.orderItemId) {
<reward-catalog-open-task-card [task]="task" />
}
</ui-carousel>
</div>
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class OpenTasksCarouselComponent {
/**
* Global resource managing open reward tasks data
*/
readonly openTasksResource = inject(OpenRewardTasksResource);
}

View File

@@ -9,6 +9,7 @@ import {
RewardCatalogStore,
CheckoutMetadataService,
ShoppingCartFacade,
SelectedRewardShoppingCartResource,
} from '@isa/checkout/data-access';
import { injectTabId } from '@isa/core/tabs';
import { StatefulButtonComponent, StatefulButtonState } from '@isa/ui/buttons';
@@ -36,6 +37,9 @@ export class RewardActionComponent {
#shoppingCartFacade = inject(ShoppingCartFacade);
#checkoutMetadataService = inject(CheckoutMetadataService);
#primaryBonudCardResource = inject(PrimaryCustomerCardResource);
#selectedRewardShoppingCartResource = inject(
SelectedRewardShoppingCartResource,
);
readonly primaryCustomerCardValue =
this.#primaryBonudCardResource.primaryCustomerCard;
@@ -89,12 +93,13 @@ export class RewardActionComponent {
});
const result = await firstValueFrom(modalRef.afterClosed$);
this.addRewardButtonState.set('success');
if (!result?.data) {
return;
}
this.addRewardButtonState.set('success');
this.#selectedRewardShoppingCartResource.resource.reload();
if (result.data !== 'continue-shopping') {
await this.#navigation(tabId);
}

View File

@@ -1,3 +1,4 @@
<reward-catalog-open-tasks-carousel></reward-catalog-open-tasks-carousel>
<reward-header></reward-header>
<filter-controls-panel
[switchFilters]="displayStockFilterSwitch()"

View File

@@ -1,83 +1,85 @@
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
signal,
} from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import {
provideFilter,
withQuerySettingsFactory,
withQueryParamsSync,
FilterControlsPanelComponent,
SearchTrigger,
FilterService,
FilterInput,
} from '@isa/shared/filter';
import { RewardHeaderComponent } from './reward-header/reward-header.component';
import { RewardListComponent } from './reward-list/reward-list.component';
import { injectRestoreScrollPosition } from '@isa/utils/scroll-position';
import { RewardActionComponent } from './reward-action/reward-action.component';
import { SelectedRewardShoppingCartResource } from '@isa/checkout/data-access';
import { SelectedCustomerResource } from '@isa/crm/data-access';
/**
* Factory function to retrieve query settings from the activated route data.
* @returns The query settings from the activated route data.
*/
function querySettingsFactory() {
return inject(ActivatedRoute).snapshot.data['querySettings'];
}
@Component({
selector: 'reward-catalog',
templateUrl: './reward-catalog.component.html',
styleUrl: './reward-catalog.component.css',
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [
SelectedRewardShoppingCartResource,
SelectedCustomerResource,
provideFilter(
withQuerySettingsFactory(querySettingsFactory),
withQueryParamsSync(),
),
],
imports: [
FilterControlsPanelComponent,
RewardHeaderComponent,
RewardListComponent,
RewardActionComponent,
],
host: {
'[class]':
'"w-full flex flex-col gap-4 mt-5 isa-desktop:mt-6 overflow-x-hidden"',
},
})
export class RewardCatalogComponent {
restoreScrollPosition = injectRestoreScrollPosition();
searchTrigger = signal<SearchTrigger | 'reload' | 'initial'>('initial');
#filterService = inject(FilterService);
displayStockFilterSwitch = computed(() => {
const stockInput = this.#filterService
.inputs()
?.filter((input) => input.target === 'filter')
?.find((input) => input.key === 'stock') as FilterInput | undefined;
return stockInput
? [
{
filter: stockInput,
icon: 'isaFiliale',
},
]
: [];
});
search(trigger: SearchTrigger): void {
this.searchTrigger.set(trigger); // Ist entweder 'scan', 'input', 'filter' oder 'orderBy'
this.#filterService.commit();
}
}
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
signal,
} from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import {
provideFilter,
withQuerySettingsFactory,
withQueryParamsSync,
FilterControlsPanelComponent,
SearchTrigger,
FilterService,
FilterInput,
} from '@isa/shared/filter';
import { RewardHeaderComponent } from './reward-header/reward-header.component';
import { RewardListComponent } from './reward-list/reward-list.component';
import { injectRestoreScrollPosition } from '@isa/utils/scroll-position';
import { RewardActionComponent } from './reward-action/reward-action.component';
import { SelectedRewardShoppingCartResource } from '@isa/checkout/data-access';
import { SelectedCustomerResource } from '@isa/crm/data-access';
import { OpenTasksCarouselComponent } from './open-tasks-carousel/open-tasks-carousel.component';
/**
* Factory function to retrieve query settings from the activated route data.
* @returns The query settings from the activated route data.
*/
function querySettingsFactory() {
return inject(ActivatedRoute).snapshot.data['querySettings'];
}
@Component({
selector: 'reward-catalog',
templateUrl: './reward-catalog.component.html',
styleUrl: './reward-catalog.component.css',
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [
SelectedRewardShoppingCartResource,
SelectedCustomerResource,
provideFilter(
withQuerySettingsFactory(querySettingsFactory),
withQueryParamsSync(),
),
],
imports: [
FilterControlsPanelComponent,
OpenTasksCarouselComponent,
RewardHeaderComponent,
RewardListComponent,
RewardActionComponent,
],
host: {
'[class]':
'"w-full flex flex-col gap-4 mt-5 isa-desktop:mt-6 overflow-x-hidden"',
},
})
export class RewardCatalogComponent {
restoreScrollPosition = injectRestoreScrollPosition();
searchTrigger = signal<SearchTrigger | 'reload' | 'initial'>('initial');
#filterService = inject(FilterService);
displayStockFilterSwitch = computed(() => {
const stockInput = this.#filterService
.inputs()
?.filter((input) => input.target === 'filter')
?.find((input) => input.key === 'stock') as FilterInput | undefined;
return stockInput
? [
{
filter: stockInput,
icon: 'isaFiliale',
},
]
: [];
});
search(trigger: SearchTrigger): void {
this.searchTrigger.set(trigger); // Ist entweder 'scan', 'input', 'filter' oder 'orderBy'
this.#filterService.commit();
}
}

View File

@@ -29,4 +29,20 @@
}
}
}
@if (hasTargetBranchFeature()) {
@for (targetBranch of targetBranches(); track targetBranch) {
@if (targetBranch.address) {
<div>
<h3 class="isa-text-body-1-regular">Abholfiliale</h3>
<div class="isa-text-body-1-bold mt-1">
{{ targetBranch.name }}
</div>
<shared-address
class="isa-text-body-1-bold"
[address]="targetBranch.address"
></shared-address>
</div>
}
}
}
</div>

View File

@@ -1,272 +1,272 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { describe, it, expect, beforeEach } from 'vitest';
import { OrderConfirmationAddressesComponent } from './order-confirmation-addresses.component';
import { OrderConfiramtionStore } from '../reward-order-confirmation.store';
import { signal } from '@angular/core';
import { DebugElement } from '@angular/core';
import { By } from '@angular/platform-browser';
import { provideHttpClient } from '@angular/common/http';
describe('OrderConfirmationAddressesComponent', () => {
let component: OrderConfirmationAddressesComponent;
let fixture: ComponentFixture<OrderConfirmationAddressesComponent>;
let mockStore: {
payers: ReturnType<typeof signal>;
shippingAddresses: ReturnType<typeof signal>;
hasDeliveryOrderTypeFeature: ReturnType<typeof signal>;
targetBranches: ReturnType<typeof signal>;
hasTargetBranchFeature: ReturnType<typeof signal>;
};
beforeEach(() => {
// Create mock store with signals
mockStore = {
payers: signal([]),
shippingAddresses: signal([]),
hasDeliveryOrderTypeFeature: signal(false),
targetBranches: signal([]),
hasTargetBranchFeature: signal(false),
};
TestBed.configureTestingModule({
imports: [OrderConfirmationAddressesComponent],
providers: [
{ provide: OrderConfiramtionStore, useValue: mockStore },
provideHttpClient(),
],
});
fixture = TestBed.createComponent(OrderConfirmationAddressesComponent);
component = fixture.componentInstance;
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should render payer address when available', () => {
// Arrange
mockStore.payers.set([
{
firstName: 'John',
lastName: 'Doe',
address: {
street: 'Main St',
streetNumber: '123',
zipCode: '12345',
city: 'Berlin',
country: 'DE',
},
} as any,
]);
// Act
fixture.detectChanges();
// Assert
const heading = fixture.debugElement.query(By.css('h3'));
expect(heading).toBeTruthy();
expect(heading.nativeElement.textContent.trim()).toBe('Rechnungsadresse');
const customerName = fixture.debugElement.query(
By.css('.isa-text-body-1-bold.mt-1'),
);
expect(customerName).toBeTruthy();
expect(customerName.nativeElement.textContent.trim()).toContain('John Doe');
});
it('should not render payer address when address is missing', () => {
// Arrange
mockStore.payers.set([
{
firstName: 'John',
lastName: 'Doe',
address: undefined,
} as any,
]);
// Act
fixture.detectChanges();
// Assert
const heading = fixture.debugElement.query(By.css('h3'));
expect(heading).toBeFalsy();
});
it('should render shipping address when hasDeliveryOrderTypeFeature is true', () => {
// Arrange
mockStore.hasDeliveryOrderTypeFeature.set(true);
mockStore.shippingAddresses.set([
{
firstName: 'Jane',
lastName: 'Smith',
address: {
street: 'Delivery St',
streetNumber: '456',
zipCode: '54321',
city: 'Hamburg',
country: 'DE',
},
} as any,
]);
// Act
fixture.detectChanges();
// Assert
const headings: DebugElement[] = fixture.debugElement.queryAll(
By.css('h3'),
);
const deliveryHeading = headings.find(
(h) => h.nativeElement.textContent.trim() === 'Lieferadresse',
);
expect(deliveryHeading).toBeTruthy();
});
it('should not render shipping address when hasDeliveryOrderTypeFeature is false', () => {
// Arrange
mockStore.hasDeliveryOrderTypeFeature.set(false);
mockStore.shippingAddresses.set([
{
firstName: 'Jane',
lastName: 'Smith',
address: {
street: 'Delivery St',
streetNumber: '456',
zipCode: '54321',
city: 'Hamburg',
country: 'DE',
},
} as any,
]);
// Act
fixture.detectChanges();
// Assert
const headings: DebugElement[] = fixture.debugElement.queryAll(
By.css('h3'),
);
const deliveryHeading = headings.find(
(h) => h.nativeElement.textContent.trim() === 'Lieferadresse',
);
expect(deliveryHeading).toBeFalsy();
});
it('should render target branch when hasTargetBranchFeature is true', () => {
// Arrange
mockStore.hasTargetBranchFeature.set(true);
mockStore.targetBranches.set([
{
name: 'Branch Berlin',
address: {
street: 'Branch St',
streetNumber: '789',
zipCode: '10115',
city: 'Berlin',
country: 'DE',
},
} as any,
]);
// Act
fixture.detectChanges();
// Assert - Target branch is not yet implemented in the template
// This test verifies that the component properties are correctly set
expect(component.hasTargetBranchFeature()).toBe(true);
expect(component.targetBranches().length).toBe(1);
expect(component.targetBranches()[0].name).toBe('Branch Berlin');
});
it('should not render target branch when hasTargetBranchFeature is false', () => {
// Arrange
mockStore.hasTargetBranchFeature.set(false);
mockStore.targetBranches.set([
{
name: 'Branch Berlin',
address: {
street: 'Branch St',
streetNumber: '789',
zipCode: '10115',
city: 'Berlin',
country: 'DE',
},
} as any,
]);
// Act
fixture.detectChanges();
// Assert
const headings: DebugElement[] = fixture.debugElement.queryAll(
By.css('h3'),
);
const branchHeading = headings.find(
(h) => h.nativeElement.textContent.trim() === 'Abholfiliale',
);
expect(branchHeading).toBeFalsy();
});
it('should render multiple addresses when all features are enabled', () => {
// Arrange
mockStore.payers.set([
{
firstName: 'John',
lastName: 'Doe',
address: {
street: 'Payer St',
streetNumber: '1',
zipCode: '11111',
city: 'City1',
country: 'DE',
},
} as any,
]);
mockStore.hasDeliveryOrderTypeFeature.set(true);
mockStore.shippingAddresses.set([
{
firstName: 'Jane',
lastName: 'Smith',
address: {
street: 'Delivery St',
streetNumber: '2',
zipCode: '22222',
city: 'City2',
country: 'DE',
},
} as any,
]);
mockStore.hasTargetBranchFeature.set(true);
mockStore.targetBranches.set([
{
name: 'Branch Test',
address: {
street: 'Branch St',
streetNumber: '3',
zipCode: '33333',
city: 'City3',
country: 'DE',
},
} as any,
]);
// Act
fixture.detectChanges();
// Assert - Only Payer and Shipping addresses are rendered (target branch not yet implemented in template)
const headings: DebugElement[] = fixture.debugElement.queryAll(
By.css('h3'),
);
expect(headings.length).toBe(2);
const headingTexts = headings.map((h) =>
h.nativeElement.textContent.trim(),
);
expect(headingTexts).toContain('Rechnungsadresse');
expect(headingTexts).toContain('Lieferadresse');
});
});
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { describe, it, expect, beforeEach } from 'vitest';
import { OrderConfirmationAddressesComponent } from './order-confirmation-addresses.component';
import { OrderConfiramtionStore } from '../reward-order-confirmation.store';
import { signal } from '@angular/core';
import { DebugElement } from '@angular/core';
import { By } from '@angular/platform-browser';
import { provideHttpClient } from '@angular/common/http';
describe('OrderConfirmationAddressesComponent', () => {
let component: OrderConfirmationAddressesComponent;
let fixture: ComponentFixture<OrderConfirmationAddressesComponent>;
let mockStore: {
payers: ReturnType<typeof signal>;
shippingAddresses: ReturnType<typeof signal>;
hasDeliveryOrderTypeFeature: ReturnType<typeof signal>;
targetBranches: ReturnType<typeof signal>;
hasTargetBranchFeature: ReturnType<typeof signal>;
};
beforeEach(() => {
// Create mock store with signals
mockStore = {
payers: signal([]),
shippingAddresses: signal([]),
hasDeliveryOrderTypeFeature: signal(false),
targetBranches: signal([]),
hasTargetBranchFeature: signal(false),
};
TestBed.configureTestingModule({
imports: [OrderConfirmationAddressesComponent],
providers: [
{ provide: OrderConfiramtionStore, useValue: mockStore },
provideHttpClient(),
],
});
fixture = TestBed.createComponent(OrderConfirmationAddressesComponent);
component = fixture.componentInstance;
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should render payer address when available', () => {
// Arrange
mockStore.payers.set([
{
firstName: 'John',
lastName: 'Doe',
address: {
street: 'Main St',
streetNumber: '123',
zipCode: '12345',
city: 'Berlin',
country: 'DE',
},
} as any,
]);
// Act
fixture.detectChanges();
// Assert
const heading = fixture.debugElement.query(By.css('h3'));
expect(heading).toBeTruthy();
expect(heading.nativeElement.textContent.trim()).toBe('Rechnungsadresse');
const customerName = fixture.debugElement.query(
By.css('.isa-text-body-1-bold.mt-1'),
);
expect(customerName).toBeTruthy();
expect(customerName.nativeElement.textContent.trim()).toContain('John Doe');
});
it('should not render payer address when address is missing', () => {
// Arrange
mockStore.payers.set([
{
firstName: 'John',
lastName: 'Doe',
address: undefined,
} as any,
]);
// Act
fixture.detectChanges();
// Assert
const heading = fixture.debugElement.query(By.css('h3'));
expect(heading).toBeFalsy();
});
it('should render shipping address when hasDeliveryOrderTypeFeature is true', () => {
// Arrange
mockStore.hasDeliveryOrderTypeFeature.set(true);
mockStore.shippingAddresses.set([
{
firstName: 'Jane',
lastName: 'Smith',
address: {
street: 'Delivery St',
streetNumber: '456',
zipCode: '54321',
city: 'Hamburg',
country: 'DE',
},
} as any,
]);
// Act
fixture.detectChanges();
// Assert
const headings: DebugElement[] = fixture.debugElement.queryAll(
By.css('h3'),
);
const deliveryHeading = headings.find(
(h) => h.nativeElement.textContent.trim() === 'Lieferadresse',
);
expect(deliveryHeading).toBeTruthy();
});
it('should not render shipping address when hasDeliveryOrderTypeFeature is false', () => {
// Arrange
mockStore.hasDeliveryOrderTypeFeature.set(false);
mockStore.shippingAddresses.set([
{
firstName: 'Jane',
lastName: 'Smith',
address: {
street: 'Delivery St',
streetNumber: '456',
zipCode: '54321',
city: 'Hamburg',
country: 'DE',
},
} as any,
]);
// Act
fixture.detectChanges();
// Assert
const headings: DebugElement[] = fixture.debugElement.queryAll(
By.css('h3'),
);
const deliveryHeading = headings.find(
(h) => h.nativeElement.textContent.trim() === 'Lieferadresse',
);
expect(deliveryHeading).toBeFalsy();
});
it('should render target branch when hasTargetBranchFeature is true', () => {
// Arrange
mockStore.hasTargetBranchFeature.set(true);
mockStore.targetBranches.set([
{
name: 'Branch Berlin',
address: {
street: 'Branch St',
streetNumber: '789',
zipCode: '10115',
city: 'Berlin',
country: 'DE',
},
} as any,
]);
// Act
fixture.detectChanges();
// Assert - Target branch is not yet implemented in the template
// This test verifies that the component properties are correctly set
expect(component.hasTargetBranchFeature()).toBe(true);
expect(component.targetBranches().length).toBe(1);
expect(component.targetBranches()[0].name).toBe('Branch Berlin');
});
it('should not render target branch when hasTargetBranchFeature is false', () => {
// Arrange
mockStore.hasTargetBranchFeature.set(false);
mockStore.targetBranches.set([
{
name: 'Branch Berlin',
address: {
street: 'Branch St',
streetNumber: '789',
zipCode: '10115',
city: 'Berlin',
country: 'DE',
},
} as any,
]);
// Act
fixture.detectChanges();
// Assert
const headings: DebugElement[] = fixture.debugElement.queryAll(
By.css('h3'),
);
const branchHeading = headings.find(
(h) => h.nativeElement.textContent.trim() === 'Abholfiliale',
);
expect(branchHeading).toBeFalsy();
});
it('should render multiple addresses when all features are enabled', () => {
// Arrange
mockStore.payers.set([
{
firstName: 'John',
lastName: 'Doe',
address: {
street: 'Payer St',
streetNumber: '1',
zipCode: '11111',
city: 'City1',
country: 'DE',
},
} as any,
]);
mockStore.hasDeliveryOrderTypeFeature.set(true);
mockStore.shippingAddresses.set([
{
firstName: 'Jane',
lastName: 'Smith',
address: {
street: 'Delivery St',
streetNumber: '2',
zipCode: '22222',
city: 'City2',
country: 'DE',
},
} as any,
]);
mockStore.hasTargetBranchFeature.set(true);
mockStore.targetBranches.set([
{
name: 'Branch Test',
address: {
street: 'Branch St',
streetNumber: '3',
zipCode: '33333',
city: 'City3',
country: 'DE',
},
} as any,
]);
// Act
fixture.detectChanges();
// Assert - Only Payer and Shipping addresses are rendered (target branch not yet implemented in template)
const headings: DebugElement[] = fixture.debugElement.queryAll(
By.css('h3'),
);
expect(headings.length).toBe(2);
const headingTexts = headings.map((h) =>
h.nativeElement.textContent.trim(),
);
expect(headingTexts).toContain('Rechnungsadresse');
expect(headingTexts).toContain('Lieferadresse');
});
});

View File

@@ -1,82 +1,82 @@
@if (displayActionCard()) {
<div
class="w-72 desktop-large:w-[24.5rem] h-full p-4 flex flex-col gap-4 rounded-lg bg-isa-secondary-100"
[class.confirmation-list-item-done]="item().status !== 1"
data-which="action-card"
data-what="action-card"
>
@if (!isComplete()) {
<div
data-what="confirmation-message"
data-which="confirmation-comment"
class="isa-text-body-2-bold"
>
Bitte buchen Sie die Prämie aus dem Abholfach aus oder wählen Sie eine
andere Aktion.
</div>
<div
class="flex flex-col gap-[0.62rem] desktop-large:gap-0 desktop-large:flex-row desktop-large:justify-between desktop-large:items-center"
>
<ui-dropdown
class="h-8 border-none px-0 hover:bg-transparent self-end"
[value]="selectedAction()"
(valueChange)="setDropdownAction($event)"
>
<ui-dropdown-option [value]="LoyaltyCollectType.Collect"
>Prämie ausbuchen</ui-dropdown-option
>
<ui-dropdown-option [value]="LoyaltyCollectType.OutOfStock"
>Nicht gefunden</ui-dropdown-option
>
<ui-dropdown-option [value]="LoyaltyCollectType.Cancel"
>Stornieren</ui-dropdown-option
>
</ui-dropdown>
<button
class="flex items-center gap-2 self-end"
type="button"
uiButton
color="primary"
size="small"
(click)="onCollect()"
[pending]="resourcesLoading()"
[disabled]="resourcesLoading()"
data-what="button"
data-which="complete"
>
<ng-icon name="isaActionCheck" uiButtonIcon></ng-icon>
Abschließen
</button>
</div>
} @else {
<div
data-what="done-message"
data-which="done-comment"
class="isa-text-body-2-bold"
>
@switch (processingStatus()) {
@case (ProcessingStatusState.Cancelled) {
Artikel wurde storniert und die Lesepunkte wieder gutgeschrieben.
}
@case (ProcessingStatusState.NotFound) {
Die Prämienbestellung wurde storniert und die Lesepunkte wieder
gutgeschrieben. Bitte korrigieren Sie bei Bedarf den Filialbestand.
}
@case (ProcessingStatusState.Collected) {
Der Artikel wurde aus dem Bestand ausgebucht und kann dem Kunden
mitgegeben werden.
}
}
</div>
<span
class="flex items-center gap-2 self-end text-isa-accent-green isa-text-body-2-bold"
>
<ng-icon name="isaActionCheck"></ng-icon>
Abgeschlossen
</span>
}
</div>
}
@if (displayActionCard()) {
<div
class="w-72 desktop-large:w-[24.5rem] justify-between h-full p-4 flex flex-col gap-4 rounded-lg bg-isa-secondary-100"
[class.confirmation-list-item-done]="item().status !== 1"
data-which="action-card"
data-what="action-card"
>
@if (!isComplete()) {
<div
data-what="confirmation-message"
data-which="confirmation-comment"
class="isa-text-body-2-bold"
>
Bitte buchen Sie die Prämie aus dem Abholfach aus oder wählen Sie eine
andere Aktion.
</div>
<div
class="flex flex-col gap-[0.62rem] desktop-large:gap-0 desktop-large:flex-row desktop-large:justify-between desktop-large:items-center"
>
<ui-dropdown
class="h-8 border-none px-0 hover:bg-transparent self-end"
[value]="selectedAction()"
(valueChange)="setDropdownAction($event)"
>
<ui-dropdown-option [value]="LoyaltyCollectType.Collect"
>Prämie ausbuchen</ui-dropdown-option
>
<ui-dropdown-option [value]="LoyaltyCollectType.OutOfStock"
>Nicht gefunden</ui-dropdown-option
>
<ui-dropdown-option [value]="LoyaltyCollectType.Cancel"
>Stornieren</ui-dropdown-option
>
</ui-dropdown>
<button
class="flex items-center gap-2 self-end"
type="button"
uiButton
color="primary"
size="small"
(click)="onCollect()"
[pending]="resourcesLoading()"
[disabled]="resourcesLoading()"
data-what="button"
data-which="complete"
>
<ng-icon name="isaActionCheck" uiButtonIcon></ng-icon>
Abschließen
</button>
</div>
} @else {
<div
data-what="done-message"
data-which="done-comment"
class="isa-text-body-2-bold"
>
@switch (processingStatus()) {
@case (ProcessingStatusState.Cancelled) {
Artikel wurde storniert und die Lesepunkte wieder gutgeschrieben.
}
@case (ProcessingStatusState.NotFound) {
Die Prämienbestellung wurde storniert und die Lesepunkte wieder
gutgeschrieben. Bitte korrigieren Sie bei Bedarf den Filialbestand.
}
@case (ProcessingStatusState.Collected) {
Der Artikel wurde aus dem Bestand ausgebucht und kann dem Kunden
mitgegeben werden.
}
}
</div>
<span
class="flex items-center gap-2 self-end text-isa-accent-green isa-text-body-2-bold"
>
<ng-icon name="isaActionCheck"></ng-icon>
Abgeschlossen
</span>
}
</div>
}

View File

@@ -1,30 +1,46 @@
:host {
@apply grid grid-cols-[1fr,1fr] desktop-large:grid-cols-[1fr,1fr,1fr] gap-6 pt-4;
grid-template-areas:
'A B'
'C B';
}
:host:not(:has([data-which='action-card'][data-what='action-card'])) {
@apply flex flex-row justify-between;
/* grid-template-areas: 'A C'; */
}
@screen desktop-large {
:host,
:host:not(:has([data-which='action-card'][data-what='action-card'])) {
grid-template-areas: 'A B C';
}
}
.area-a {
grid-area: A;
}
.area-b {
grid-area: B;
}
.area-c {
grid-area: C;
}
:host {
@apply grid grid-cols-[1fr,auto] gap-6 pt-4;
grid-template-areas:
'product-info action-card'
'destination-info action-card';
}
checkout-display-order-destination-info {
@apply ml-20 desktop-large:ml-0 desktop-large:justify-self-end;
}
:host:not(:has([data-which='action-card'][data-what='action-card'])) {
@apply flex justify-between;
checkout-confirmation-list-item-action-card {
display: none;
}
checkout-display-order-destination-info {
@apply ml-0;
}
}
@screen desktop-large {
:host,
:host:not(:has([data-which='action-card'][data-what='action-card'])) {
@apply grid-cols-3;
grid-template-areas: 'product-info action-card destination-info';
}
}
checkout-product-info {
grid-area: product-info;
@apply grow;
}
checkout-confirmation-list-item-action-card {
grid-area: action-card;
@apply w-[18rem] desktop-large:w-[24.5rem];
}
checkout-display-order-destination-info {
grid-area: destination-info;
@apply block w-[18rem] desktop-large:w-[24.5rem] grow-0;
}

View File

@@ -1,19 +1,19 @@
<checkout-product-info
class="area-a"
[item]="productItem()"
[nameSize]="'small'"
>
<div class="isa-text-body-2-regular" data-what="product-points">
<span class="isa-text-body-2-bold">{{ points() }}</span>
Lesepunkte
</div>
<div class="isa-text-body-2-bold">{{ item()?.quantity }} x</div>
</checkout-product-info>
@let product = productItem();
@if (product) {
<checkout-product-info [item]="productItem()" [nameSize]="'small'">
<div class="isa-text-body-2-regular" data-what="product-points">
<span class="isa-text-body-2-bold">{{ points() }}</span>
Lesepunkte
</div>
<div class="isa-text-body-2-bold">{{ item()?.quantity }} x</div>
</checkout-product-info>
}
<checkout-confirmation-list-item-action-card
class="area-b justify-self-end"
class="justify-self-end"
[item]="item()"
></checkout-confirmation-list-item-action-card>
<checkout-destination-info
class="max-w-[22.9rem] area-c ml-20 desktop-large:justify-self-end desktop-large:ml-0"
[shoppingCartItem]="shoppingCartItem()"
></checkout-destination-info>
<checkout-display-order-destination-info
[order]="order()"
[item]="item()"
></checkout-display-order-destination-info>

View File

@@ -1,27 +1,33 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { describe, it, expect, beforeEach } from 'vitest';
import { OrderConfirmationItemListItemComponent } from './order-confirmation-item-list-item.component';
import { OrderConfiramtionStore } from '../../reward-order-confirmation.store';
import { DisplayOrderItem } from '@isa/oms/data-access';
import { signal } from '@angular/core';
import { DebugElement } from '@angular/core';
import { DisplayOrderItem, DisplayOrder } from '@isa/oms/data-access';
import { DebugElement, signal } from '@angular/core';
import { By } from '@angular/platform-browser';
import { provideRouter } from '@angular/router';
import { provideProductImageUrl } from '@isa/shared/product-image';
import { provideProductRouterLinkBuilder } from '@isa/shared/product-router-link';
import { provideHttpClient } from '@angular/common/http';
import { OrderConfiramtionStore } from '../../reward-order-confirmation.store';
describe('OrderConfirmationItemListItemComponent', () => {
let component: OrderConfirmationItemListItemComponent;
let fixture: ComponentFixture<OrderConfirmationItemListItemComponent>;
let mockStore: {
shoppingCart: ReturnType<typeof signal>;
orders: ReturnType<typeof signal>;
};
const mockOrder: DisplayOrder = {
id: 1,
orderNumber: 'ORD-123',
orderType: 'Reward',
status: 'Confirmed',
} as DisplayOrder;
beforeEach(() => {
// Create mock store with signal
// Create mock store
mockStore = {
shoppingCart: signal(null),
orders: signal([mockOrder]),
};
TestBed.configureTestingModule({
@@ -44,7 +50,7 @@ describe('OrderConfirmationItemListItemComponent', () => {
});
describe('productItem computed signal', () => {
it('should map DisplayOrderItem product to ProductInfoItem', () => {
it('should return product from item', () => {
// Arrange
const item: DisplayOrderItem = {
id: 1,
@@ -59,39 +65,35 @@ describe('OrderConfirmationItemListItemComponent', () => {
// Act
fixture.componentRef.setInput('item', item);
fixture.componentRef.setInput('order', mockOrder);
// Assert
expect(component.productItem()).toEqual({
ean: '1234567890123',
name: 'Test Product',
contributors: 'Test Author',
catalogProductNumber: 'CAT-123',
});
});
it('should handle missing product fields with empty strings', () => {
it('should return undefined when item has no product', () => {
// Arrange
const item: DisplayOrderItem = {
id: 1,
quantity: 1,
product: {
catalogProductNumber: 'CAT-123',
},
} as DisplayOrderItem;
// Act
fixture.componentRef.setInput('item', item);
fixture.componentRef.setInput('order', mockOrder);
// Assert
expect(component.productItem()).toEqual({
ean: '',
name: '',
contributors: '',
});
expect(component.productItem()).toBeUndefined();
});
});
describe('points computed signal', () => {
it('should return loyalty points from shopping cart item', () => {
it('should return loyalty points from item', () => {
// Arrange
const item: DisplayOrderItem = {
id: 1,
@@ -100,28 +102,18 @@ describe('OrderConfirmationItemListItemComponent', () => {
catalogProductNumber: 'CAT-123',
ean: '1234567890123',
},
loyalty: { value: 150 },
} as DisplayOrderItem;
mockStore.shoppingCart.set({
id: 1,
items: [
{
data: {
product: { catalogProductNumber: 'CAT-123' },
loyalty: { value: 150 },
},
},
],
} as any);
// Act
fixture.componentRef.setInput('item', item);
fixture.componentRef.setInput('order', mockOrder);
// Assert
expect(component.points()).toBe(150);
});
it('should return 0 when shopping cart is null', () => {
it('should return 0 when loyalty is missing', () => {
// Arrange
const item: DisplayOrderItem = {
id: 1,
@@ -131,39 +123,9 @@ describe('OrderConfirmationItemListItemComponent', () => {
},
} as DisplayOrderItem;
mockStore.shoppingCart.set(null);
// Act
fixture.componentRef.setInput('item', item);
// Assert
expect(component.points()).toBe(0);
});
it('should return 0 when shopping cart item is not found', () => {
// Arrange
const item: DisplayOrderItem = {
id: 1,
quantity: 1,
product: {
catalogProductNumber: 'CAT-123',
},
} as DisplayOrderItem;
mockStore.shoppingCart.set({
id: 1,
items: [
{
data: {
product: { catalogProductNumber: 'CAT-999' },
loyalty: { value: 100 },
},
},
],
} as any);
// Act
fixture.componentRef.setInput('item', item);
fixture.componentRef.setInput('order', mockOrder);
// Assert
expect(component.points()).toBe(0);
@@ -177,104 +139,18 @@ describe('OrderConfirmationItemListItemComponent', () => {
product: {
catalogProductNumber: 'CAT-123',
},
loyalty: {},
} as DisplayOrderItem;
mockStore.shoppingCart.set({
id: 1,
items: [
{
data: {
product: { catalogProductNumber: 'CAT-123' },
loyalty: {},
},
},
],
} as any);
// Act
fixture.componentRef.setInput('item', item);
fixture.componentRef.setInput('order', mockOrder);
// Assert
expect(component.points()).toBe(0);
});
});
describe('shoppingCartItem computed signal', () => {
it('should return shopping cart item data when found', () => {
// Arrange
const item: DisplayOrderItem = {
id: 1,
quantity: 1,
product: {
catalogProductNumber: 'CAT-123',
},
} as DisplayOrderItem;
const shoppingCartItemData = {
product: { catalogProductNumber: 'CAT-123' },
loyalty: { value: 150 },
};
mockStore.shoppingCart.set({
id: 1,
items: [{ data: shoppingCartItemData }],
} as any);
// Act
fixture.componentRef.setInput('item', item);
// Assert
expect(component.shoppingCartItem()).toBe(shoppingCartItemData);
});
it('should return undefined when shopping cart is null', () => {
// Arrange
const item: DisplayOrderItem = {
id: 1,
quantity: 1,
product: {
catalogProductNumber: 'CAT-123',
},
} as DisplayOrderItem;
mockStore.shoppingCart.set(null);
// Act
fixture.componentRef.setInput('item', item);
// Assert
expect(component.shoppingCartItem()).toBeUndefined();
});
it('should return undefined when item is not found in shopping cart', () => {
// Arrange
const item: DisplayOrderItem = {
id: 1,
quantity: 1,
product: {
catalogProductNumber: 'CAT-123',
},
} as DisplayOrderItem;
mockStore.shoppingCart.set({
id: 1,
items: [
{
data: {
product: { catalogProductNumber: 'CAT-999' },
},
},
],
} as any);
// Act
fixture.componentRef.setInput('item', item);
// Assert
expect(component.shoppingCartItem()).toBeUndefined();
});
});
describe('template rendering', () => {
it('should render product points with E2E attribute', () => {
// Arrange
@@ -287,22 +163,12 @@ describe('OrderConfirmationItemListItemComponent', () => {
contributors: 'Test Author',
catalogProductNumber: 'CAT-123',
},
loyalty: { value: 200 },
} as DisplayOrderItem;
mockStore.shoppingCart.set({
id: 1,
items: [
{
data: {
product: { catalogProductNumber: 'CAT-123' },
loyalty: { value: 200 },
},
},
],
} as any);
// Act
fixture.componentRef.setInput('item', item);
fixture.componentRef.setInput('order', mockOrder);
fixture.detectChanges();
// Assert
@@ -326,21 +192,9 @@ describe('OrderConfirmationItemListItemComponent', () => {
},
} as DisplayOrderItem;
// Provide shopping cart data to avoid destination errors
mockStore.shoppingCart.set({
id: 1,
items: [
{
data: {
product: { catalogProductNumber: 'CAT-123' },
destination: { type: 'InStore' },
},
},
],
} as any);
// Act
fixture.componentRef.setInput('item', item);
fixture.componentRef.setInput('order', mockOrder);
fixture.detectChanges();
// Assert
@@ -367,21 +221,9 @@ describe('OrderConfirmationItemListItemComponent', () => {
},
} as DisplayOrderItem;
// Provide shopping cart data to avoid destination errors
mockStore.shoppingCart.set({
id: 1,
items: [
{
data: {
product: { catalogProductNumber: 'CAT-123' },
destination: { type: 'InStore' },
},
},
],
} as any);
// Act
fixture.componentRef.setInput('item', item);
fixture.componentRef.setInput('order', mockOrder);
fixture.detectChanges();
// Assert
@@ -390,7 +232,7 @@ describe('OrderConfirmationItemListItemComponent', () => {
By.css('checkout-confirmation-list-item-action-card')
);
const destinationInfo = fixture.debugElement.query(
By.css('checkout-destination-info')
By.css('checkout-display-order-destination-info')
);
expect(productInfo).toBeTruthy();

View File

@@ -2,17 +2,18 @@ import {
ChangeDetectionStrategy,
Component,
computed,
inject,
input,
} from '@angular/core';
import { DisplayOrderItem } from '@isa/oms/data-access';
import { ConfirmationListItemActionCardComponent } from './confirmation-list-item-action-card/confirmation-list-item-action-card.component';
import {
ProductInfoComponent,
ProductInfoItem,
DestinationInfoComponent,
DisplayOrderDestinationInfoComponent,
} from '@isa/checkout/shared/product-info';
import { OrderConfiramtionStore } from '../../reward-order-confirmation.store';
import {
DisplayOrderItemDTO,
} from '@generated/swagger/oms-api';
import { Product } from '@isa/common/data-access';
import { type OrderItemGroup } from '@isa/checkout/data-access';
@Component({
selector: 'checkout-order-confirmation-item-list-item',
@@ -22,67 +23,25 @@ import { OrderConfiramtionStore } from '../../reward-order-confirmation.store';
imports: [
ConfirmationListItemActionCardComponent,
ProductInfoComponent,
DestinationInfoComponent,
DisplayOrderDestinationInfoComponent,
],
})
export class OrderConfirmationItemListItemComponent {
#orderConfiramtionStore = inject(OrderConfiramtionStore);
item = input.required<DisplayOrderItemDTO>();
item = input.required<DisplayOrderItem>();
/**
* The order item group containing the delivery type, destination, and other group metadata.
* This now receives an OrderItemGroup which contains the necessary order information
* (features, targetBranch, shippingAddress) required by the DisplayOrderDestinationInfoComponent.
*/
order = input.required<Pick<OrderItemGroup, 'features' | 'targetBranch' | 'shippingAddress'>>();
productItem = computed<ProductInfoItem>(() => {
const product = this.item().product;
return {
contributors: product?.contributors ?? '',
ean: product?.ean ?? '',
name: product?.name ?? '',
};
productItem = computed<Product | undefined>(() => {
return this.item()?.product;
});
points = computed(() => {
const shoppingCart = this.#orderConfiramtionStore.shoppingCart();
if (!shoppingCart) {
return 0;
}
const item = this.item();
const shoppingCartItem = shoppingCart.items.find(
(scItem) =>
scItem?.data?.product?.catalogProductNumber ===
item.product?.catalogProductNumber,
)?.data;
return shoppingCartItem?.loyalty?.value ?? 0;
});
shoppingCartItem = computed(() => {
const shoppingCart = this.#orderConfiramtionStore.shoppingCart();
const item = this.item();
if (!shoppingCart) {
// Fallback: use DisplayOrderItem features directly
return {
features: item.features,
availability: undefined,
destination: undefined,
};
}
const foundItem = shoppingCart.items.find(
(scItem) =>
scItem?.data?.product?.catalogProductNumber ===
item.product?.catalogProductNumber,
)?.data;
// Fallback: use DisplayOrderItem features if not found in cart
return (
foundItem ?? {
features: item.features,
availability: undefined,
destination: undefined,
}
);
return item.loyalty?.value ?? 0;
});
}

View File

@@ -1,42 +1,38 @@
@for (group of groupedOrders(); track group.orderType) {
@for (group of groupedItems(); track trackByGroupedItems(group)) {
<!-- Delivery type section -->
<div
<section
class="flex flex-col gap-2 self-stretch"
data-what="delivery-type-group"
[attr.data-which]="group.orderType"
[attr.aria-label]="group.orderType + ' items'"
>
@for (branchGroup of group.branchGroups; track branchGroup.branchId ?? 0) {
<!-- Branch header (only shown when multiple branches exist within same delivery type) -->
@if (branchGroup.branchName && group.branchGroups.length > 1) {
<div
class="flex p-2 items-center gap-[0.625rem] self-stretch rounded-2xl bg-isa-neutral-200 w-full isa-text-body-2-bold"
data-what="branch-header"
[attr.data-which]="branchGroup.branchName"
>
<ng-icon [name]="group.icon" size="1.5rem" />
<div>{{ group.orderType }} - {{ branchGroup.branchName }}</div>
</div>
<h3
class="flex p-2 items-center gap-[0.625rem] self-stretch rounded-2xl bg-isa-neutral-200 w-full isa-text-body-2-bold"
role="heading"
aria-level="3"
>
<ng-icon
[name]="group.orderTypeIcon"
size="1.5rem"
aria-hidden="true"
/>
@if (group.orderType === 'Abholung' || group.orderType === 'Rücklage') {
@let branchName = group?.targetBranch?.name;
<span>{{ group.orderType }} - {{ branchName }}</span>
} @else {
<!-- Order type header (for single branch or Versand types) -->
<div
class="flex p-2 items-center gap-[0.625rem] self-stretch rounded-2xl bg-isa-neutral-200 w-full isa-text-body-2-bold"
data-what="order-type-header"
[attr.data-which]="group.orderType"
>
<ng-icon [name]="group.icon" size="1.5rem" />
<div>
{{ group.orderType }}
</div>
</div>
<span>{{ group.orderType }}</span>
}
</h3>
<!-- Items from all orders in this (orderType + branch) group -->
@for (item of branchGroup.items; track item.id; let isLast = $last) {
<checkout-order-confirmation-item-list-item [item]="item" />
@if (!isLast) {
<hr class="mt-3" />
}
<!-- Items from all orders in this (orderType + branch) group -->
@for (item of group.items; track item.id; let isLast = $last) {
<checkout-order-confirmation-item-list-item
[item]="item"
[order]="group"
/>
@if (!isLast) {
<hr class="mt-3" />
}
}
</div>
</section>
}

View File

@@ -15,13 +15,13 @@ describe('OrderConfirmationItemListComponent', () => {
let component: OrderConfirmationItemListComponent;
let fixture: ComponentFixture<OrderConfirmationItemListComponent>;
let mockStore: {
shoppingCart: ReturnType<typeof signal>;
orders: ReturnType<typeof signal>;
};
beforeEach(() => {
// Create mock store with signal
// Create mock store with orders signal
mockStore = {
shoppingCart: signal(null),
orders: signal([]),
};
TestBed.configureTestingModule({
@@ -43,243 +43,136 @@ describe('OrderConfirmationItemListComponent', () => {
expect(component).toBeTruthy();
});
describe('orderType computed signal', () => {
it('should return Delivery for delivery order type', () => {
// Arrange
const order: DisplayOrder = {
it('should expose orders signal from store', () => {
const testOrders: DisplayOrder[] = [
{
id: 1,
features: { orderType: OrderType.Delivery },
items: [],
} as DisplayOrder;
} as DisplayOrder,
];
// Act
fixture.componentRef.setInput('order', order);
fixture.detectChanges();
mockStore.orders.set(testOrders);
// Assert
expect(component.orderType()).toBe(OrderType.Delivery);
});
it('should return Pickup for pickup order type', () => {
// Arrange
const order: DisplayOrder = {
id: 1,
features: { orderType: OrderType.Pickup },
items: [],
} as DisplayOrder;
// Act
fixture.componentRef.setInput('order', order);
fixture.detectChanges();
// Assert
expect(component.orderType()).toBe(OrderType.Pickup);
});
it('should return InStore for in-store order type', () => {
// Arrange
const order: DisplayOrder = {
id: 1,
features: { orderType: OrderType.InStore },
items: [],
} as DisplayOrder;
// Act
fixture.componentRef.setInput('order', order);
fixture.detectChanges();
// Assert
expect(component.orderType()).toBe(OrderType.InStore);
});
expect(component.orders()).toEqual(testOrders);
});
describe('orderTypeIcon computed signal', () => {
it('should return isaDeliveryVersand icon for Delivery', () => {
// Arrange
const order: DisplayOrder = {
id: 1,
features: { orderType: OrderType.Delivery },
items: [],
} as DisplayOrder;
// Act
fixture.componentRef.setInput('order', order);
// Assert
expect(component.orderTypeIcon()).toBe('isaDeliveryVersand');
});
it('should return isaDeliveryRuecklage2 icon for Pickup', () => {
// Arrange
const order: DisplayOrder = {
id: 1,
features: { orderType: OrderType.Pickup },
items: [],
} as DisplayOrder;
// Act
fixture.componentRef.setInput('order', order);
// Assert
expect(component.orderTypeIcon()).toBe('isaDeliveryRuecklage2');
});
it('should return isaDeliveryRuecklage1 icon for InStore', () => {
// Arrange
const order: DisplayOrder = {
id: 1,
features: { orderType: OrderType.InStore },
items: [],
} as DisplayOrder;
// Act
fixture.componentRef.setInput('order', order);
// Assert
expect(component.orderTypeIcon()).toBe('isaDeliveryRuecklage1');
});
it('should default to isaDeliveryVersand for unknown order type', () => {
// Arrange
const order: DisplayOrder = {
id: 1,
features: { orderType: 'Unknown' as any },
items: [],
} as DisplayOrder;
// Act
fixture.componentRef.setInput('order', order);
// Assert
expect(component.orderTypeIcon()).toBe('isaDeliveryVersand');
});
it('should have getOrderTypeIcon function', () => {
expect(component.getOrderTypeIcon).toBeDefined();
expect(typeof component.getOrderTypeIcon).toBe('function');
});
describe('items computed signal', () => {
it('should return items from order', () => {
// Arrange
const items = [
{ id: 1, ean: '1234567890123' },
{ id: 2, ean: '9876543210987' },
] as any[];
const order: DisplayOrder = {
id: 1,
features: { orderType: OrderType.InStore },
items,
} as DisplayOrder;
// Act
fixture.componentRef.setInput('order', order);
// Assert
expect(component.items()).toEqual(items);
});
it('should return empty array when items is undefined', () => {
// Arrange
const order: DisplayOrder = {
id: 1,
features: { orderType: OrderType.InStore },
items: undefined,
} as DisplayOrder;
// Act
fixture.componentRef.setInput('order', order);
// Assert
expect(component.items()).toEqual([]);
});
it('should have getOrderTypeFeature function', () => {
expect(component.getOrderTypeFeature).toBeDefined();
expect(typeof component.getOrderTypeFeature).toBe('function');
});
describe('template rendering', () => {
it('should render order type header with icon and text', () => {
it('should render order groups for each order', () => {
// Arrange
const order: DisplayOrder = {
id: 1,
features: { orderType: OrderType.Delivery },
items: [],
} as DisplayOrder;
const orders: DisplayOrder[] = [
{
id: 1,
features: { orderType: OrderType.Delivery },
items: [
{
id: 1,
product: { ean: '123', name: 'Product 1', catalogProductNumber: 'CAT-1' },
quantity: 1,
} as any,
],
} as DisplayOrder,
{
id: 2,
features: { orderType: OrderType.Pickup },
targetBranch: { name: 'Test Branch' },
items: [
{
id: 2,
product: { ean: '456', name: 'Product 2', catalogProductNumber: 'CAT-2' },
quantity: 2,
} as any,
],
} as DisplayOrder,
];
mockStore.orders.set(orders);
// Act
fixture.componentRef.setInput('order', order);
fixture.detectChanges();
// Assert
const header: DebugElement = fixture.debugElement.query(
By.css('.bg-isa-neutral-200')
const deliveryGroups = fixture.debugElement.queryAll(
By.css('[data-what="delivery-type-group"]')
);
expect(header).toBeTruthy();
expect(header.nativeElement.textContent).toContain(OrderType.Delivery);
expect(deliveryGroups.length).toBe(2);
});
it('should render item list components for each item', () => {
it('should render order type header with icon', () => {
// Arrange
const items = [
{ id: 1, product: { ean: '1234567890123', catalogProductNumber: 'CAT-123' } },
{ id: 2, product: { ean: '9876543210987', catalogProductNumber: 'CAT-456' } },
{ id: 3, product: { ean: '1111111111111', catalogProductNumber: 'CAT-789' } },
] as any[];
const orders: DisplayOrder[] = [
{
id: 1,
features: { orderType: OrderType.Delivery },
items: [],
} as DisplayOrder,
];
const order: DisplayOrder = {
id: 1,
features: { orderType: OrderType.InStore },
items,
} as DisplayOrder;
// Provide shopping cart data to avoid destination errors
mockStore.shoppingCart.set({
id: 1,
items: [
{
data: {
product: { catalogProductNumber: 'CAT-123' },
destination: { type: 'InStore' },
},
},
{
data: {
product: { catalogProductNumber: 'CAT-456' },
destination: { type: 'InStore' },
},
},
{
data: {
product: { catalogProductNumber: 'CAT-789' },
destination: { type: 'InStore' },
},
},
],
} as any);
mockStore.orders.set(orders);
// Act
fixture.componentRef.setInput('order', order);
fixture.detectChanges();
// Assert
const itemComponents = fixture.debugElement.queryAll(
By.css('checkout-order-confirmation-item-list-item')
);
expect(itemComponents.length).toBe(3);
const icon = fixture.debugElement.query(By.css('ng-icon'));
expect(icon).toBeTruthy();
});
it('should not render any items when items array is empty', () => {
it('should render items for each order', () => {
// Arrange
const order: DisplayOrder = {
id: 1,
features: { orderType: OrderType.InStore },
items: [],
} as DisplayOrder;
const orders: DisplayOrder[] = [
{
id: 1,
features: { orderType: OrderType.Delivery },
items: [
{
id: 1,
product: { ean: '123', name: 'Product 1', catalogProductNumber: 'CAT-1' },
quantity: 1,
} as any,
{
id: 2,
product: { ean: '456', name: 'Product 2', catalogProductNumber: 'CAT-2' },
quantity: 2,
} as any,
],
} as DisplayOrder,
];
mockStore.orders.set(orders);
// Act
fixture.componentRef.setInput('order', order);
fixture.detectChanges();
// Assert
const itemComponents = fixture.debugElement.queryAll(
const items = fixture.debugElement.queryAll(
By.css('checkout-order-confirmation-item-list-item')
);
expect(itemComponents.length).toBe(0);
expect(items.length).toBe(2);
});
it('should render nothing when orders array is empty', () => {
// Arrange
mockStore.orders.set([]);
// Act
fixture.detectChanges();
// Assert
const deliveryGroups = fixture.debugElement.queryAll(
By.css('[data-what="delivery-type-group"]')
);
expect(deliveryGroups.length).toBe(0);
});
});
});

View File

@@ -8,9 +8,8 @@ import {
import { OrderConfirmationItemListItemComponent } from './order-confirmation-item-list-item/order-confirmation-item-list-item.component';
import {
groupDisplayOrderItemsByDeliveryType,
groupDisplayOrderItemsByBranch,
getOrderTypeIcon,
groupItemsByDeliveryDestination,
type OrderItemGroup,
} from '@isa/checkout/data-access';
import { NgIcon, provideIcons } from '@ng-icons/core';
import {
@@ -39,33 +38,30 @@ import { OrderConfiramtionStore } from '../reward-order-confirmation.store';
export class OrderConfirmationItemListComponent {
#store = inject(OrderConfiramtionStore);
orders = this.#store.orders;
/**
* Groups order items hierarchically by delivery type and branch.
* - Primary grouping: By delivery type from item.features.orderType (Abholung, Rücklage, Versand, etc.)
* - Secondary grouping: By branch (only for Abholung and Rücklage)
*
* Note: Groups by item-level orderType, not order-level, since items within
* a single order can have different delivery types.
* Track function for @for to optimize rendering.
* Generates a unique key for each group based on order type and destination.
*/
groupedOrders = computed(() => {
const orders = this.orders();
if (!orders || orders.length === 0) {
return [];
trackByGroupedItems = (group: OrderItemGroup): string => {
let key = group.orderType;
if (group.shippingAddress) {
key += `-shippingAddress-${JSON.stringify(group.shippingAddress)}`;
}
const byDeliveryType = groupDisplayOrderItemsByDeliveryType(orders);
if (group.targetBranch) {
key += `-targetBranch-${group.targetBranch.id}`;
}
const result = Array.from(byDeliveryType.entries()).map(
([orderType, items]) => ({
orderType,
icon: getOrderTypeIcon(orderType),
branchGroups: groupDisplayOrderItemsByBranch(orderType, items),
}),
);
return key;
};
console.log('Grouped orders:', result);
return result;
/**
* Groups order items by delivery type and destination.
* Uses the helper function for efficient Map-based grouping.
*/
groupedItems = computed(() => {
const orders = this.#store.orders();
return groupItemsByDeliveryDestination(orders ?? []);
});
}

View File

@@ -11,6 +11,7 @@ import { By } from '@angular/platform-browser';
import { provideHttpClient } from '@angular/common/http';
import { provideProductImageUrl } from '@isa/shared/product-image';
import { provideProductRouterLinkBuilder } from '@isa/shared/product-router-link';
import { DisplayOrdersResource } from '@isa/oms/data-access';
describe('RewardOrderConfirmationComponent', () => {
let component: RewardOrderConfirmationComponent;
@@ -29,6 +30,14 @@ describe('RewardOrderConfirmationComponent', () => {
let mockTabService: {
activatedTabId: ReturnType<typeof signal>;
};
let mockDisplayOrdersResource: {
orders: ReturnType<typeof signal>;
loading: ReturnType<typeof signal>;
error: ReturnType<typeof signal>;
loadOrder: ReturnType<typeof vi.fn>;
loadOrders: ReturnType<typeof vi.fn>;
refresh: ReturnType<typeof vi.fn>;
};
beforeEach(() => {
// Create mock paramMap subject
@@ -46,6 +55,16 @@ describe('RewardOrderConfirmationComponent', () => {
patch: vi.fn(),
};
// Create mock DisplayOrdersResource
mockDisplayOrdersResource = {
orders: signal([]),
loading: signal(false),
error: signal(null),
loadOrder: vi.fn(),
loadOrders: vi.fn(),
refresh: vi.fn(),
};
// Create mock TabService with writable signal
mockTabService = {
activatedTabId: signal(null),
@@ -68,10 +87,13 @@ describe('RewardOrderConfirmationComponent', () => {
],
});
// Override component's providers to use our mock store
// Override component's providers to use our mock store and resource
TestBed.overrideComponent(RewardOrderConfirmationComponent, {
set: {
providers: [{ provide: OrderConfiramtionStore, useValue: mockStore }],
providers: [
{ provide: OrderConfiramtionStore, useValue: mockStore },
{ provide: DisplayOrdersResource, useValue: mockDisplayOrdersResource },
],
},
});
@@ -109,7 +131,7 @@ describe('RewardOrderConfirmationComponent', () => {
it('should parse single order ID from route params', () => {
// Arrange - recreate subject with correct initial value
paramMapSubject = new BehaviorSubject<ParamMap>(convertToParamMap({ orderIds: '123' }));
paramMapSubject = new BehaviorSubject<ParamMap>(convertToParamMap({ displayOrderIds: '123' }));
TestBed.overrideProvider(ActivatedRoute, {
useValue: { paramMap: paramMapSubject.asObservable() },
});
@@ -125,7 +147,7 @@ describe('RewardOrderConfirmationComponent', () => {
it('should parse multiple order IDs from route params', () => {
// Arrange - recreate subject with correct initial value
paramMapSubject = new BehaviorSubject<ParamMap>(convertToParamMap({ orderIds: '123+456+789' }));
paramMapSubject = new BehaviorSubject<ParamMap>(convertToParamMap({ displayOrderIds: '123,456,789' }));
TestBed.overrideProvider(ActivatedRoute, {
useValue: { paramMap: paramMapSubject.asObservable() },
});
@@ -141,7 +163,7 @@ describe('RewardOrderConfirmationComponent', () => {
it('should handle single digit order IDs', () => {
// Arrange - recreate subject with correct initial value
paramMapSubject = new BehaviorSubject<ParamMap>(convertToParamMap({ orderIds: '1+2+3' }));
paramMapSubject = new BehaviorSubject<ParamMap>(convertToParamMap({ displayOrderIds: '1,2,3' }));
TestBed.overrideProvider(ActivatedRoute, {
useValue: { paramMap: paramMapSubject.asObservable() },
});
@@ -157,7 +179,7 @@ describe('RewardOrderConfirmationComponent', () => {
it('should return empty array for empty string param', () => {
// Arrange - recreate subject with correct initial value
paramMapSubject = new BehaviorSubject<ParamMap>(convertToParamMap({ orderIds: '' }));
paramMapSubject = new BehaviorSubject<ParamMap>(convertToParamMap({ displayOrderIds: '' }));
TestBed.overrideProvider(ActivatedRoute, {
useValue: { paramMap: paramMapSubject.asObservable() },
});
@@ -177,7 +199,7 @@ describe('RewardOrderConfirmationComponent', () => {
it('should call store.patch with tabId and orderIds', () => {
// Arrange - set up state before creating component
mockTabService.activatedTabId.set('test-tab-123');
paramMapSubject = new BehaviorSubject<ParamMap>(convertToParamMap({ orderIds: '456' }));
paramMapSubject = new BehaviorSubject<ParamMap>(convertToParamMap({ displayOrderIds: '456' }));
TestBed.overrideProvider(ActivatedRoute, {
useValue: { paramMap: paramMapSubject.asObservable() },
});
@@ -198,7 +220,7 @@ describe('RewardOrderConfirmationComponent', () => {
it('should call store.patch with undefined tabId when no tab is active', () => {
// Arrange - set up state before creating component
mockTabService.activatedTabId.set(null);
paramMapSubject = new BehaviorSubject<ParamMap>(convertToParamMap({ orderIds: '789' }));
paramMapSubject = new BehaviorSubject<ParamMap>(convertToParamMap({ displayOrderIds: '789' }));
TestBed.overrideProvider(ActivatedRoute, {
useValue: { paramMap: paramMapSubject.asObservable() },
});
@@ -218,7 +240,7 @@ describe('RewardOrderConfirmationComponent', () => {
it('should update store when route params change', () => {
// Arrange - create component with initial params
paramMapSubject = new BehaviorSubject<ParamMap>(convertToParamMap({ orderIds: '111' }));
paramMapSubject = new BehaviorSubject<ParamMap>(convertToParamMap({ displayOrderIds: '111' }));
TestBed.overrideProvider(ActivatedRoute, {
useValue: { paramMap: paramMapSubject.asObservable() },
});
@@ -231,7 +253,7 @@ describe('RewardOrderConfirmationComponent', () => {
mockStore.patch.mockClear();
// Act - change route params
paramMapSubject.next(convertToParamMap({ orderIds: '222+333' }));
paramMapSubject.next(convertToParamMap({ displayOrderIds: '222,333' }));
fixture.detectChanges();
TestBed.flushEffects();
@@ -282,10 +304,6 @@ describe('RewardOrderConfirmationComponent', () => {
{ id: 3, items: [], features: { orderType: 'Versand' } },
] as any);
// Need to add shopping cart to avoid child component errors
const mockStoreWithCart = mockStore as any;
mockStoreWithCart.shoppingCart = signal({ id: 1, items: [] });
fixture = TestBed.createComponent(RewardOrderConfirmationComponent);
component = fixture.componentInstance;
@@ -293,10 +311,11 @@ describe('RewardOrderConfirmationComponent', () => {
fixture.detectChanges();
// Assert
// There's always exactly one item list component that renders all orders internally
const itemLists = fixture.debugElement.queryAll(
By.css('checkout-order-confirmation-item-list')
);
expect(itemLists.length).toBe(3);
expect(itemLists.length).toBe(1);
});
it('should not render item lists when orders array is empty', () => {
@@ -309,10 +328,11 @@ describe('RewardOrderConfirmationComponent', () => {
fixture.detectChanges();
// Assert
// The item list component always exists, but it won't render any order groups
const itemLists = fixture.debugElement.queryAll(
By.css('checkout-order-confirmation-item-list')
);
expect(itemLists.length).toBe(0);
expect(itemLists.length).toBe(1);
});
it('should pass order to item list component', () => {
@@ -325,20 +345,6 @@ describe('RewardOrderConfirmationComponent', () => {
mockStore.orders.set([testOrder]);
// Need to add shopping cart to avoid child component errors
const mockStoreWithCart = mockStore as any;
mockStoreWithCart.shoppingCart = signal({
id: 1,
items: [
{
data: {
product: { catalogProductNumber: 'CAT-123' },
destination: { type: 'InStore' },
},
},
],
});
fixture = TestBed.createComponent(RewardOrderConfirmationComponent);
component = fixture.componentInstance;
@@ -350,7 +356,8 @@ describe('RewardOrderConfirmationComponent', () => {
By.css('checkout-order-confirmation-item-list')
);
expect(itemList).toBeTruthy();
expect(itemList.componentInstance.order()).toEqual(testOrder);
// The item list component gets all orders from the store, not individual orders
expect(itemList.componentInstance.orders()).toEqual([testOrder]);
});
});

View File

@@ -14,6 +14,7 @@ import { OrderConfirmationItemListComponent } from './order-confirmation-item-li
import { ActivatedRoute } from '@angular/router';
import { TabService } from '@isa/core/tabs';
import { OrderConfiramtionStore } from './reward-order-confirmation.store';
import { DisplayOrdersResource } from '@isa/oms/data-access';
@Component({
selector: 'checkout-reward-order-confirmation',
@@ -25,32 +26,47 @@ import { OrderConfiramtionStore } from './reward-order-confirmation.store';
OrderConfirmationAddressesComponent,
OrderConfirmationItemListComponent,
],
providers: [OrderConfiramtionStore],
providers: [OrderConfiramtionStore, DisplayOrdersResource],
})
export class RewardOrderConfirmationComponent {
#store = inject(OrderConfiramtionStore);
#displayOrdersResource = inject(DisplayOrdersResource);
#tabId = inject(TabService).activatedTabId;
#activatedRoute = inject(ActivatedRoute);
params = toSignal(this.#activatedRoute.paramMap);
orderIds = computed(() => {
displayOrderIds = computed(() => {
const params = this.params();
if (!params) {
return [];
}
const param = params.get('orderIds');
return param ? param.split('+').map((strId) => parseInt(strId, 10)) : [];
const param = params.get('displayOrderIds');
return param ? param.split(',').map((strId) => parseInt(strId, 10)) : [];
});
// Expose for testing
orderIds = this.displayOrderIds;
orders = this.#store.orders;
constructor() {
// Update store state
effect(() => {
const tabId = this.#tabId() || undefined;
const orderIds = this.orderIds();
const orderIds = this.displayOrderIds();
untracked(() => {
this.#store.patch({ tabId, orderIds });
});
});
// Load display orders when orderIds change
effect(() => {
const orderIds = this.displayOrderIds();
untracked(() => {
this.#displayOrdersResource.loadOrders(orderIds);
});
});
}
}

View File

@@ -3,11 +3,10 @@ import {
deduplicateAddressees,
deduplicateBranches,
} from '@isa/crm/data-access';
import { OmsMetadataService } from '@isa/oms/data-access';
import { DisplayOrdersResource } from '@isa/oms/data-access';
import {
CheckoutMetadataService,
hasOrderTypeFeature,
OrderType,
OrderTypeFeature,
} from '@isa/checkout/data-access';
import {
patchState,
@@ -31,46 +30,22 @@ const initialState: OrderConfiramtionState = {
export const OrderConfiramtionStore = signalStore(
withState(initialState),
withProps(() => ({
_omsMetadataService: inject(OmsMetadataService),
_checkoutMetadataService: inject(CheckoutMetadataService),
_displayOrdersResource: inject(DisplayOrdersResource),
})),
withComputed((state) => ({
orders: computed(() => {
const tabId = state.tabId();
const orderIds = state.orderIds();
const orders = state._displayOrdersResource.orders();
if (!tabId) {
if (!orders || orders.length === 0) {
return undefined;
}
if (!orderIds || !orderIds.length) {
return undefined;
}
const orders = state._omsMetadataService.getDisplayOrders(tabId);
return orders?.filter(
(order) => order.id !== undefined && orderIds.includes(order.id),
);
return orders;
}),
loading: computed(() => state._displayOrdersResource.loading()),
error: computed(() => state._displayOrdersResource.error()),
})),
withComputed((state) => ({
shoppingCart: computed(() => {
const tabId = state.tabId();
const orders = state.orders();
if (!tabId) {
return undefined;
}
if (!orders || !orders.length) {
return undefined;
}
const completedCarts =
state._checkoutMetadataService.getCompletedShoppingCarts(tabId);
return completedCarts?.find(
(cart) => cart.id === orders[0].shoppingCartId,
);
}),
payers: computed(() => {
const orders = state.orders();
if (!orders) {
@@ -102,9 +77,9 @@ export const OrderConfiramtionStore = signalStore(
}
return orders.some((order) => {
return hasOrderTypeFeature(order.features, [
OrderType.Delivery,
OrderType.DigitalShipping,
OrderType.B2BShipping,
OrderTypeFeature.Delivery,
OrderTypeFeature.DigitalShipping,
OrderTypeFeature.B2BShipping,
]);
});
}),
@@ -115,8 +90,8 @@ export const OrderConfiramtionStore = signalStore(
}
return orders.some((order) => {
return hasOrderTypeFeature(order.features, [
OrderType.InStore,
OrderType.Pickup,
OrderTypeFeature.InStore,
OrderTypeFeature.Pickup,
]);
});
}),

View File

@@ -4,7 +4,7 @@ import { OMS_ACTION_HANDLERS } from '@isa/oms/data-access';
export const routes: Routes = [
{
path: ':orderIds',
path: ':displayOrderIds',
providers: [
CoreCommandModule.forChild(OMS_ACTION_HANDLERS).providers ?? [],
],

View File

@@ -147,7 +147,7 @@ export class CompleteOrderButtonComponent {
`/${this.#tabId()}`,
'reward',
'order-confirmation',
orders.map((o) => o.id).join('+'),
orders.map((o) => o.id).join(','),
]);
this.isCompleted.set(true);

View File

@@ -25,7 +25,7 @@ import {
AvailabilityFacade,
GetSingleItemAvailabilityInputParams,
GetAvailabilityParamsAdapter,
OrderType,
OrderTypeFeature,
} from '@isa/availability/data-access';
// TODO: [Next Sprint - High Priority] Create comprehensive test file
@@ -64,9 +64,9 @@ export class RewardShoppingCartItemQuantityControlComponent {
maxQuantity = computed(() => {
const orderType = this.orderType();
if (
orderType === OrderType.Delivery ||
orderType === OrderType.DigitalShipping ||
orderType === OrderType.B2BShipping
orderType === OrderTypeFeature.Delivery ||
orderType === OrderTypeFeature.DigitalShipping ||
orderType === OrderTypeFeature.B2BShipping
) {
return 999;
}

View File

@@ -1,9 +1,17 @@
import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core';
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
} from '@angular/core';
import { NavigateBackButtonComponent } from '@isa/core/tabs';
import { CheckoutCustomerRewardCardComponent } from './customer-reward-card/customer-reward-card.component';
import { BillingAndShippingAddressCardComponent } from './billing-and-shipping-address-card/billing-and-shipping-address-card.component';
import { RewardShoppingCartItemsComponent } from './reward-shopping-cart-items/reward-shopping-cart-items.component';
import { SelectedRewardShoppingCartResource, calculateTotalLoyaltyPoints } from '@isa/checkout/data-access';
import {
SelectedRewardShoppingCartResource,
calculateTotalLoyaltyPoints,
} from '@isa/checkout/data-access';
import {
SelectedCustomerResource,
PrimaryCustomerCardResource,
@@ -27,18 +35,15 @@ import { isaOtherInfo } from '@isa/icons';
RewardSelectionTriggerComponent,
NgIconComponent,
],
providers: [
SelectedRewardShoppingCartResource,
SelectedCustomerResource,
provideIcons({ isaOtherInfo }),
],
providers: [provideIcons({ isaOtherInfo })],
})
export class RewardShoppingCartComponent {
#shoppingCartResource = inject(SelectedRewardShoppingCartResource).resource;
#primaryCustomerCardResource = inject(PrimaryCustomerCardResource);
primaryBonusCardPoints = computed(
() => this.#primaryCustomerCardResource.primaryCustomerCard()?.totalPoints ?? 0,
() =>
this.#primaryCustomerCardResource.primaryCustomerCard()?.totalPoints ?? 0,
);
totalPointsRequired = computed(() => {
@@ -49,4 +54,8 @@ export class RewardShoppingCartComponent {
insufficientPoints = computed(() => {
return this.primaryBonusCardPoints() < this.totalPointsRequired();
});
constructor() {
this.#shoppingCartResource.reload();
}
}

View File

@@ -4,14 +4,11 @@ import {
CompleteCrmOrderParams,
CheckoutMetadataService,
} from '@isa/checkout/data-access';
import {
OrderCreationFacade,
OmsMetadataService,
DisplayOrder,
} from '@isa/oms/data-access';
import { OrderCreationFacade, DisplayOrder } from '@isa/oms/data-access';
import { logger } from '@isa/core/logging';
import { HttpErrorResponse } from '@angular/common/http';
import { isResponseArgs } from '@isa/common/data-access';
import { DisplayOrderDTO } from '@generated/swagger/oms-api';
/**
* Orchestrates checkout completion and order creation.
@@ -39,7 +36,6 @@ export class CheckoutCompletionOrchestratorService {
#shoppingCartFacade = inject(ShoppingCartFacade);
#orderCreationFacade = inject(OrderCreationFacade);
#omsMetadataService = inject(OmsMetadataService);
#checkoutMetadataService = inject(CheckoutMetadataService);
/**
@@ -65,7 +61,7 @@ export class CheckoutCompletionOrchestratorService {
params: CompleteCrmOrderParams,
tabId?: number,
abortSignal?: AbortSignal,
): Promise<DisplayOrder[]> {
): Promise<DisplayOrderDTO[]> {
this.#logger.info('Starting checkout completion and order creation');
try {
@@ -76,12 +72,12 @@ export class CheckoutCompletionOrchestratorService {
abortSignal,
);
const shoppingCart = await this.#shoppingCartFacade.getShoppingCart(
await this.#shoppingCartFacade.getShoppingCart(
params.shoppingCartId,
abortSignal,
);
let orders: DisplayOrder[] = [];
let orders: DisplayOrderDTO[] = [];
try {
orders =
@@ -114,7 +110,7 @@ export class CheckoutCompletionOrchestratorService {
*/
if (
error instanceof HttpErrorResponse &&
isResponseArgs<DisplayOrder[]>(error.error)
isResponseArgs<DisplayOrderDTO[]>(error.error)
) {
const responseArgs = error.error;
orders = responseArgs.result;
@@ -142,22 +138,7 @@ export class CheckoutCompletionOrchestratorService {
}
if (tabId) {
// Step 2: Update OMS metadata with created orders
if (shoppingCart) {
this.#checkoutMetadataService.addCompletedShoppingCart(
tabId,
shoppingCart,
);
}
if (orders.length > 0 && shoppingCart?.id) {
this.#omsMetadataService.addDisplayOrders(
tabId,
orders,
shoppingCart.id,
);
}
// Step 3: Cleanup the reward shopping cart
// Step 2: Cleanup the reward shopping cart
this.#checkoutMetadataService.setRewardShoppingCartId(tabId, undefined);
}

View File

@@ -1,4 +1,5 @@
export * from './lib/destination-info/destination-info.component';
export * from './lib/display-order-destination-info/display-order-destination-info.component';
export * from './lib/product-info/product-info.component';
export * from './lib/product-info/product-info-redemption.component';
export * from './lib/stock-info/stock-info.component';

View File

@@ -1,7 +1,3 @@
:host {
@apply flex flex-col items-start gap-2 flex-grow;
}
.address-container {
@apply line-clamp-2 break-words text-ellipsis;
@apply contents;
}

View File

@@ -1,28 +1,7 @@
<div
class="flex items-center gap-2 self-stretch"
[class.underline]="underline()"
>
<ng-icon
[name]="destinationIcon()"
size="1.5rem"
class="text-neutral-900"
></ng-icon>
<span class="isa-text-body-2-bold text-isa-secondary-900">{{
orderType()
}}</span>
</div>
<div class="text-isa-neutral-600 isa-text-body-2-regular address-container">
@if (displayAddress()) {
{{ name() }} |
<shared-inline-address [address]="address()"></shared-inline-address>
} @else {
@if (estimatedDelivery(); as delivery) {
@if (delivery.stop) {
Zustellung zwischen {{ delivery.start | date: 'E, dd.MM.' }} und
{{ delivery.stop | date: 'E, dd.MM.' }}
} @else {
Zustellung voraussichtlich am {{ delivery.start | date: 'E, dd.MM.' }}
}
}
}
</div>
<shared-order-destination
[underline]="underline()"
[branch]="mappedBranch()"
[shippingAddress]="mappedShippingAddress()"
[orderType]="orderType()"
[estimatedDelivery]="estimatedDelivery()">
</shared-order-destination>

View File

@@ -9,36 +9,28 @@ import {
import {
BranchResource,
getOrderTypeFeature,
OrderType,
ShoppingCartItem,
} from '@isa/checkout/data-access';
import { SelectedCustomerShippingAddressResource } from '@isa/crm/data-access';
import {
isaDeliveryVersand,
isaDeliveryRuecklage2,
isaDeliveryRuecklage1,
} from '@isa/icons';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { InlineAddressComponent } from '@isa/shared/address';
import { DatePipe } from '@angular/common';
import { coerceBooleanProperty } from '@angular/cdk/coercion';
import { OrderDestinationComponent } from '@isa/shared/delivery';
import { logger } from '@isa/core/logging';
export type DestinationInfo = {
features: ShoppingCartItem['features'];
availability: ShoppingCartItem['availability'];
destination: ShoppingCartItem['destination'];
};
@Component({
selector: 'checkout-destination-info',
templateUrl: './destination-info.component.html',
styleUrls: ['./destination-info.component.css'],
imports: [NgIcon, InlineAddressComponent, DatePipe],
providers: [
provideIcons({
isaDeliveryVersand,
isaDeliveryRuecklage2,
isaDeliveryRuecklage1,
}),
BranchResource,
SelectedCustomerShippingAddressResource,
],
imports: [OrderDestinationComponent],
providers: [BranchResource],
})
export class DestinationInfoComponent {
#logger = logger({ component: 'DestinationInfoComponent' });
#branchResource = inject(BranchResource);
#shippingAddressResource = inject(SelectedCustomerShippingAddressResource);
@@ -46,40 +38,13 @@ export class DestinationInfoComponent {
transform: coerceBooleanProperty,
});
shoppingCartItem =
input.required<
Pick<ShoppingCartItem, 'availability' | 'destination' | 'features'>
>();
shoppingCartItem = input.required<DestinationInfo>();
orderType = computed(() => {
return getOrderTypeFeature(this.shoppingCartItem().features);
});
destinationIcon = computed(() => {
const orderType = this.orderType();
if (OrderType.Delivery === orderType) {
return 'isaDeliveryVersand';
}
if (OrderType.Pickup === orderType) {
return 'isaDeliveryRuecklage2';
}
if (OrderType.InStore === orderType) {
return 'isaDeliveryRuecklage1';
}
return 'isaDeliveryVersand';
});
displayAddress = computed(() => {
const orderType = this.orderType();
return (
OrderType.InStore === orderType ||
OrderType.Pickup === orderType ||
OrderType.B2BShipping === orderType
);
const features = this.shoppingCartItem().features;
const orderType = getOrderTypeFeature(features);
this.#logger.debug('Computing order type', () => ({ orderType, features }));
return orderType;
});
branchContainer = computed(
@@ -99,34 +64,25 @@ export class DestinationInfoComponent {
}
});
name = computed(() => {
const orderType = this.orderType();
if (
OrderType.Delivery === orderType ||
OrderType.B2BShipping === orderType ||
OrderType.DigitalShipping === orderType
) {
const shippingAddress = this.#shippingAddressResource.resource.value();
return `${shippingAddress?.firstName || ''} ${shippingAddress?.lastName || ''}`.trim();
}
return this.branch()?.name || 'Filiale nicht gefunden';
mappedBranch = computed(() => {
const branch = this.branch();
return branch
? {
name: branch.name,
address: branch.address,
}
: undefined;
});
address = computed(() => {
const orderType = this.orderType();
if (
OrderType.Delivery === orderType ||
OrderType.B2BShipping === orderType ||
OrderType.DigitalShipping === orderType
) {
const shippingAddress = this.#shippingAddressResource.resource.value();
return shippingAddress?.address || undefined;
}
const destination = this.shoppingCartItem().destination;
return destination?.data?.targetBranch?.data?.address;
mappedShippingAddress = computed(() => {
const address = this.#shippingAddressResource.resource.value();
return address
? {
firstName: address.firstName,
lastName: address.lastName,
address: address.address,
}
: undefined;
});
estimatedDelivery = computed(() => {

View File

@@ -0,0 +1,3 @@
:host {
@apply contents;
}

View File

@@ -0,0 +1,7 @@
<shared-order-destination
[underline]="underline()"
[branch]="mappedBranch()"
[shippingAddress]="mappedShippingAddress()"
[orderType]="orderType()"
[estimatedDelivery]="estimatedDelivery()">
</shared-order-destination>

View File

@@ -0,0 +1,74 @@
import { Component, computed, inject, input } from '@angular/core';
import { coerceBooleanProperty } from '@angular/cdk/coercion';
import { getOrderTypeFeature } from '@isa/checkout/data-access';
import { DisplayOrder, DisplayOrderItem } from '@isa/oms/data-access';
import {
OrderDestinationComponent,
ShippingAddress,
} from '@isa/shared/delivery';
import { logger } from '@isa/core/logging';
@Component({
selector: 'checkout-display-order-destination-info',
templateUrl: './display-order-destination-info.component.html',
styleUrls: ['./display-order-destination-info.component.css'],
imports: [OrderDestinationComponent],
})
export class DisplayOrderDestinationInfoComponent {
#logger = logger({ component: 'DisplayOrderDestinationInfoComponent' });
underline = input<boolean, unknown>(false, {
transform: coerceBooleanProperty,
});
// Accept the parent DisplayOrder (required for branch info)
order = input.required<DisplayOrder>();
// Optionally accept DisplayOrderItem (for potential future item-specific logic)
item = input<DisplayOrderItem>();
orderType = computed(() => {
const order = this.order();
const features = order.features;
return getOrderTypeFeature(features);
});
mappedBranch = computed(() => {
const order = this.order();
const branch = order.targetBranch;
this.#logger.debug('Mapping branch from DisplayOrder', () => ({
branchName: branch?.name,
branchAddress: branch?.address,
}));
return branch
? {
name: branch.name,
address: branch.address,
}
: undefined;
});
mappedShippingAddress = computed<ShippingAddress | undefined>(() => {
const order = this.order();
const shippingAddress = order.shippingAddress;
this.#logger.debug('Mapping shipping address from DisplayOrder', () => ({
firstName: shippingAddress?.firstName,
lastName: shippingAddress?.lastName,
hasAddress: !!shippingAddress?.address,
}));
if (!shippingAddress) {
return undefined;
}
return {
firstName: shippingAddress.firstName,
lastName: shippingAddress.lastName,
address: shippingAddress.address,
};
});
// DisplayOrder doesn't have estimatedDelivery
estimatedDelivery = computed(() => null);
}

View File

@@ -4,14 +4,11 @@ import {
computed,
input,
} from '@angular/core';
import { Product } from '@isa/common/data-access';
import { ProductImageDirective } from '@isa/shared/product-image';
import { ProductRouterLinkDirective } from '@isa/shared/product-router-link';
export type ProductInfoItem = {
ean: string;
name: string;
contributors: string;
};
export type ProductInfoItem = Pick<Product, 'ean' | 'name' | 'contributors'>;
export type ProductNameSize = 'small' | 'medium' | 'large';

View File

@@ -1,209 +1,205 @@
# Reward Selection Dialog
Angular library for managing reward selection in shopping cart context. Allows users to toggle between regular purchase and reward redemption using bonus points.
## Features
- 🎯 Pre-built trigger component or direct service integration
- 🔄 Automatic resource management (carts, bonus cards)
- 📊 Smart grouping by order type and branch
- 💾 NgRx Signals state management
- ✅ Full TypeScript support
## Installation
```typescript
import {
RewardSelectionService,
RewardSelectionPopUpService,
RewardSelectionTriggerComponent,
} from '@isa/checkout/shared/reward-selection-dialog';
```
## Quick Start
### Using the Trigger Component (Recommended)
Simplest integration - includes all providers automatically:
```typescript
import { Component } from '@angular/core';
import { RewardSelectionTriggerComponent } from '@isa/checkout/shared/reward-selection-dialog';
@Component({
selector: 'app-checkout',
template: `<lib-reward-selection-trigger />`,
imports: [RewardSelectionTriggerComponent],
})
export class CheckoutComponent {}
```
### Using the Pop-Up Service
More control over navigation flow:
```typescript
import { Component, inject } from '@angular/core';
import {
RewardSelectionPopUpService,
NavigateAfterRewardSelection,
RewardSelectionService,
} from '@isa/checkout/shared/reward-selection-dialog';
import {
SelectedShoppingCartResource,
SelectedRewardShoppingCartResource,
} from '@isa/checkout/data-access';
@Component({
selector: 'app-custom-checkout',
template: `<button (click)="openRewardSelection()">Select Rewards</button>`,
providers: [
// Required providers
SelectedShoppingCartResource,
SelectedRewardShoppingCartResource,
RewardSelectionService,
RewardSelectionPopUpService,
],
})
export class CustomCheckoutComponent {
#popUpService = inject(RewardSelectionPopUpService);
async openRewardSelection() {
const result = await this.#popUpService.popUp();
// Handle navigation: 'cart' | 'reward' | 'catalog' | undefined
if (result === NavigateAfterRewardSelection.CART) {
// Navigate to cart
}
}
}
```
### Using the Service Directly
For custom UI or advanced use cases:
```typescript
import { Component, inject } from '@angular/core';
import { RewardSelectionService } from '@isa/checkout/shared/reward-selection-dialog';
import {
SelectedShoppingCartResource,
SelectedRewardShoppingCartResource,
} from '@isa/checkout/data-access';
@Component({
selector: 'app-advanced',
template: `
@if (canOpen()) {
<button (click)="openDialog()" [disabled]="isLoading()">
{{ eligibleItemsCount() }} items as rewards ({{ availablePoints() }} points)
</button>
}
`,
providers: [
SelectedShoppingCartResource,
SelectedRewardShoppingCartResource,
RewardSelectionService,
],
})
export class AdvancedComponent {
#service = inject(RewardSelectionService);
canOpen = this.#service.canOpen;
isLoading = this.#service.isLoading;
eligibleItemsCount = computed(() => this.#service.eligibleItems().length);
availablePoints = this.#service.primaryBonusCardPoints;
async openDialog() {
const result = await this.#service.open({ closeText: 'Cancel' });
if (result) {
// Handle result.rewardSelectionItems
await this.#service.reloadResources();
}
}
}
```
## API Reference
### RewardSelectionService
**Key Signals:**
- `canOpen()`: `boolean` - Can dialog be opened
- `isLoading()`: `boolean` - Loading state
- `eligibleItems()`: `RewardSelectionItem[]` - Items available as rewards
- `primaryBonusCardPoints()`: `number` - Available points
**Methods:**
- `open({ closeText }): Promise<RewardSelectionDialogResult>` - Opens dialog
- `reloadResources(): Promise<void>` - Reloads all data
### RewardSelectionPopUpService
**Methods:**
- `popUp(): Promise<NavigateAfterRewardSelection | undefined>` - Opens dialog with navigation flow
**Return values:**
- `'cart'` - Navigate to shopping cart
- `'reward'` - Navigate to reward checkout
- `'catalog'` - Navigate to catalog
- `undefined` - No navigation needed
### Types
```typescript
interface RewardSelectionItem {
item: ShoppingCartItem;
catalogPrice: Price | undefined;
availabilityPrice: Price | undefined;
catalogRewardPoints: number | undefined;
cartQuantity: number;
rewardCartQuantity: number;
}
type RewardSelectionDialogResult = {
rewardSelectionItems: RewardSelectionItem[];
} | undefined;
type NavigateAfterRewardSelection = 'cart' | 'reward' | 'catalog';
```
## Required Providers
When using `RewardSelectionService` or `RewardSelectionPopUpService` directly, provide:
```typescript
providers: [
SelectedShoppingCartResource, // Regular cart data
SelectedRewardShoppingCartResource, // Reward cart data
RewardSelectionService, // Core service
RewardSelectionPopUpService, // Optional: only if using pop-up
]
```
**Note:** `RewardSelectionTriggerComponent` includes all required providers automatically.
## Testing
```bash
nx test reward-selection-dialog
```
## Architecture
```
reward-selection-dialog/
── helper/ # Pure utility functions
├── resource/ # Data resources
├── service/ # Business logic
├── store/ # NgRx Signals state
└── trigger/ # Trigger component
```
## Dependencies
- `@isa/checkout/data-access` - Cart resources
- `@isa/crm/data-access` - Customer data
- `@isa/catalogue/data-access` - Product catalog
- `@isa/ui/dialog` - Dialog infrastructure
- `@ngrx/signals` - State management
# Reward Selection Dialog
Angular library for managing reward selection in shopping cart context. Allows users to toggle between regular purchase and reward redemption using bonus points.
## Features
- 🎯 Pre-built trigger component or direct service integration
- 🔄 Automatic resource management (carts, bonus cards)
- 📊 Smart grouping by order type and branch
- 💾 NgRx Signals state management
- ✅ Full TypeScript support
## Installation
```typescript
import {
RewardSelectionService,
RewardSelectionPopUpService,
RewardSelectionTriggerComponent,
} from '@isa/checkout/shared/reward-selection-dialog';
```
## Quick Start
### Using the Trigger Component (Recommended)
Simplest integration - includes all providers automatically:
```typescript
import { Component } from '@angular/core';
import { RewardSelectionTriggerComponent } from '@isa/checkout/shared/reward-selection-dialog';
@Component({
selector: 'app-checkout',
template: `<lib-reward-selection-trigger />`,
imports: [RewardSelectionTriggerComponent],
})
export class CheckoutComponent {}
```
### Using the Pop-Up Service
More control over navigation flow:
```typescript
import { Component, inject } from '@angular/core';
import {
RewardSelectionPopUpService,
NavigateAfterRewardSelection,
RewardSelectionService,
} from '@isa/checkout/shared/reward-selection-dialog';
import {
SelectedShoppingCartResource,
SelectedRewardShoppingCartResource,
} from '@isa/checkout/data-access';
@Component({
selector: 'app-custom-checkout',
template: `<button (click)="openRewardSelection()">Select Rewards</button>`,
providers: [
// Required providers
SelectedShoppingCartResource,
RewardSelectionService,
RewardSelectionPopUpService,
],
})
export class CustomCheckoutComponent {
#popUpService = inject(RewardSelectionPopUpService);
async openRewardSelection() {
const result = await this.#popUpService.popUp();
// Handle navigation: 'cart' | 'reward' | 'catalog' | undefined
if (result === NavigateAfterRewardSelection.CART) {
// Navigate to cart
}
}
}
```
### Using the Service Directly
For custom UI or advanced use cases:
```typescript
import { Component, inject } from '@angular/core';
import { RewardSelectionService } from '@isa/checkout/shared/reward-selection-dialog';
import {
SelectedShoppingCartResource,
} from '@isa/checkout/data-access';
@Component({
selector: 'app-advanced',
template: `
@if (canOpen()) {
<button (click)="openDialog()" [disabled]="isLoading()">
{{ eligibleItemsCount() }} items as rewards ({{ availablePoints() }} points)
</button>
}
`,
providers: [
SelectedShoppingCartResource,
RewardSelectionService,
],
})
export class AdvancedComponent {
#service = inject(RewardSelectionService);
canOpen = this.#service.canOpen;
isLoading = this.#service.isLoading;
eligibleItemsCount = computed(() => this.#service.eligibleItems().length);
availablePoints = this.#service.primaryBonusCardPoints;
async openDialog() {
const result = await this.#service.open({ closeText: 'Cancel' });
if (result) {
// Handle result.rewardSelectionItems
await this.#service.reloadResources();
}
}
}
```
## API Reference
### RewardSelectionService
**Key Signals:**
- `canOpen()`: `boolean` - Can dialog be opened
- `isLoading()`: `boolean` - Loading state
- `eligibleItems()`: `RewardSelectionItem[]` - Items available as rewards
- `primaryBonusCardPoints()`: `number` - Available points
**Methods:**
- `open({ closeText }): Promise<RewardSelectionDialogResult>` - Opens dialog
- `reloadResources(): Promise<void>` - Reloads all data
### RewardSelectionPopUpService
**Methods:**
- `popUp(): Promise<NavigateAfterRewardSelection | undefined>` - Opens dialog with navigation flow
**Return values:**
- `'cart'` - Navigate to shopping cart
- `'reward'` - Navigate to reward checkout
- `'catalog'` - Navigate to catalog
- `undefined` - No navigation needed
### Types
```typescript
interface RewardSelectionItem {
item: ShoppingCartItem;
catalogPrice: Price | undefined;
availabilityPrice: Price | undefined;
catalogRewardPoints: number | undefined;
cartQuantity: number;
rewardCartQuantity: number;
}
type RewardSelectionDialogResult = {
rewardSelectionItems: RewardSelectionItem[];
} | undefined;
type NavigateAfterRewardSelection = 'cart' | 'reward' | 'catalog';
```
## Required Providers
When using `RewardSelectionService` or `RewardSelectionPopUpService` directly, provide:
```typescript
providers: [
SelectedShoppingCartResource, // Regular cart data
RewardSelectionService, // Core service
RewardSelectionPopUpService, // Optional: only if using pop-up
]
```
**Note:** `RewardSelectionTriggerComponent` includes all required providers automatically.
## Testing
```bash
nx test reward-selection-dialog
```
## Architecture
```
reward-selection-dialog/
├── helper/ # Pure utility functions
├── resource/ # Data resources
├── service/ # Business logic
├── store/ # NgRx Signals state
── trigger/ # Trigger component
```
## Dependencies
- `@isa/checkout/data-access` - Cart resources
- `@isa/crm/data-access` - Customer data
- `@isa/catalogue/data-access` - Product catalog
- `@isa/ui/dialog` - Dialog infrastructure
- `@ngrx/signals` - State management

View File

@@ -1,175 +1,175 @@
import { computed, inject, Injectable, resource, signal } from '@angular/core';
import { AvailabilityService } from '@isa/availability/data-access';
import {
CatalougeSearchService,
Price as CatalogPrice,
} from '@isa/catalogue/data-access';
import {
OrderType,
Price as AvailabilityPrice,
} from '@isa/checkout/data-access';
import { logger } from '@isa/core/logging';
/**
* Input item for availability check - contains EAN, orderType and optional branchId
*/
export interface ItemWithOrderType {
ean: string;
orderType: OrderType;
branchId?: number;
}
/**
* Result containing price from availability and redemption points from catalog
*/
export interface PriceAndRedemptionPointsResult {
ean: string;
availabilityPrice?: AvailabilityPrice;
catalogPrice?: CatalogPrice;
redemptionPoints?: number;
}
/**
* Resource for fetching combined price and redemption points data.
*
* This resource:
* 1. Fetches catalog items by EAN to get redemption points
* 2. Groups items by order type
* 3. Fetches availability data for each order type group
* 4. Combines catalog redemption points with availability prices
*
* @example
* ```typescript
* const resource = inject(PriceAndRedemptionPointsResource);
*
* // Load data for items
* resource.loadPriceAndRedemptionPoints([
* { ean: '1234567890', orderType: OrderType.Delivery },
* { ean: '0987654321', orderType: OrderType.Pickup }
* ]);
*
* // Access results
* const results = resource.priceAndRedemptionPoints();
* ```
*/
@Injectable({ providedIn: 'root' })
export class PriceAndRedemptionPointsResource {
#catalogueSearchService = inject(CatalougeSearchService);
#availabilityService = inject(AvailabilityService);
#logger = logger(() => ({ resource: 'PriceAndRedemptionPoints' }));
#items = signal<ItemWithOrderType[] | undefined>(undefined);
#priceAndRedemptionPointsResource = resource({
params: computed(() => ({ items: this.#items() })),
loader: async ({
params,
abortSignal,
}): Promise<PriceAndRedemptionPointsResult[]> => {
if (!params?.items || params.items.length === 0) {
return [];
}
// Extract unique EANs for catalog lookup
const eans = [...new Set(params.items.map((item) => item.ean))];
// Fetch catalog items to get redemption points
const catalogItems = await this.#catalogueSearchService.searchByEans(
eans,
abortSignal,
);
// Create a map for quick catalog lookup by EAN
const catalogByEan = new Map(
catalogItems.map((item) => [item.product.ean, item]),
);
// Fetch availability for each item individually (in parallel)
const availabilityPromises = params.items.map(async (checkItem) => {
const catalogItem = catalogByEan.get(checkItem.ean);
// Skip items without catalog entry
if (!catalogItem?.id) {
return { ean: checkItem.ean, price: undefined };
}
try {
// Call getAvailability for single item
// InStore (Rücklage) has different schema: uses itemId instead of item object
const params =
checkItem.orderType === OrderType.InStore
? {
orderType: checkItem.orderType,
branchId: checkItem.branchId,
itemId: catalogItem.id,
}
: {
orderType: checkItem.orderType,
branchId: checkItem.branchId,
item: {
itemId: catalogItem.id,
ean: checkItem.ean,
quantity: 1,
price: catalogItem.catalogAvailability?.price,
},
};
const availability = await this.#availabilityService.getAvailability(
params as any,
abortSignal,
);
return {
ean: checkItem.ean,
price: availability?.price as AvailabilityPrice | undefined,
};
} catch (error) {
this.#logger.error(
'Failed to fetch availability for item',
error as Error,
() => ({
ean: checkItem.ean,
orderType: checkItem.orderType,
branchId: checkItem.branchId,
}),
);
return { ean: checkItem.ean, price: undefined };
}
});
// Wait for all availability requests to complete
const availabilityResults = await Promise.all(availabilityPromises);
// Build price map from results
const pricesByEan = new Map(
availabilityResults.map((result) => [result.ean, result.price]),
);
// Build final result: combine catalog prices, availability prices and redemption points
const results: PriceAndRedemptionPointsResult[] = eans.map((ean) => ({
ean,
availabilityPrice: pricesByEan.get(ean),
catalogPrice: catalogByEan.get(ean)?.catalogAvailability?.price,
redemptionPoints: catalogByEan.get(ean)?.redemptionPoints,
}));
return results;
},
defaultValue: [],
});
readonly priceAndRedemptionPoints =
this.#priceAndRedemptionPointsResource.value.asReadonly();
readonly loading = this.#priceAndRedemptionPointsResource.isLoading;
readonly error = computed(
() => this.#priceAndRedemptionPointsResource.error()?.message ?? null,
);
loadPriceAndRedemptionPoints(items: ItemWithOrderType[] | undefined) {
this.#items.set(items);
}
refresh() {
this.#priceAndRedemptionPointsResource.reload();
}
}
import { computed, inject, Injectable, resource, signal } from '@angular/core';
import { AvailabilityService } from '@isa/availability/data-access';
import {
CatalougeSearchService,
Price as CatalogPrice,
} from '@isa/catalogue/data-access';
import {
OrderTypeFeature,
Price as AvailabilityPrice,
} from '@isa/checkout/data-access';
import { logger } from '@isa/core/logging';
/**
* Input item for availability check - contains EAN, orderType and optional branchId
*/
export interface ItemWithOrderType {
ean: string;
orderType: OrderTypeFeature;
branchId?: number;
}
/**
* Result containing price from availability and redemption points from catalog
*/
export interface PriceAndRedemptionPointsResult {
ean: string;
availabilityPrice?: AvailabilityPrice;
catalogPrice?: CatalogPrice;
redemptionPoints?: number;
}
/**
* Resource for fetching combined price and redemption points data.
*
* This resource:
* 1. Fetches catalog items by EAN to get redemption points
* 2. Groups items by order type
* 3. Fetches availability data for each order type group
* 4. Combines catalog redemption points with availability prices
*
* @example
* ```typescript
* const resource = inject(PriceAndRedemptionPointsResource);
*
* // Load data for items
* resource.loadPriceAndRedemptionPoints([
* { ean: '1234567890', orderType: OrderType.Delivery },
* { ean: '0987654321', orderType: OrderType.Pickup }
* ]);
*
* // Access results
* const results = resource.priceAndRedemptionPoints();
* ```
*/
@Injectable({ providedIn: 'root' })
export class PriceAndRedemptionPointsResource {
#catalogueSearchService = inject(CatalougeSearchService);
#availabilityService = inject(AvailabilityService);
#logger = logger(() => ({ resource: 'PriceAndRedemptionPoints' }));
#items = signal<ItemWithOrderType[] | undefined>(undefined);
#priceAndRedemptionPointsResource = resource({
params: computed(() => ({ items: this.#items() })),
loader: async ({
params,
abortSignal,
}): Promise<PriceAndRedemptionPointsResult[]> => {
if (!params?.items || params.items.length === 0) {
return [];
}
// Extract unique EANs for catalog lookup
const eans = [...new Set(params.items.map((item) => item.ean))];
// Fetch catalog items to get redemption points
const catalogItems = await this.#catalogueSearchService.searchByEans(
eans,
abortSignal,
);
// Create a map for quick catalog lookup by EAN
const catalogByEan = new Map(
catalogItems.map((item) => [item.product.ean, item]),
);
// Fetch availability for each item individually (in parallel)
const availabilityPromises = params.items.map(async (checkItem) => {
const catalogItem = catalogByEan.get(checkItem.ean);
// Skip items without catalog entry
if (!catalogItem?.id) {
return { ean: checkItem.ean, price: undefined };
}
try {
// Call getAvailability for single item
// InStore (Rücklage) has different schema: uses itemId instead of item object
const params =
checkItem.orderType === OrderTypeFeature.InStore
? {
orderType: checkItem.orderType,
branchId: checkItem.branchId,
itemId: catalogItem.id,
}
: {
orderType: checkItem.orderType,
branchId: checkItem.branchId,
item: {
itemId: catalogItem.id,
ean: checkItem.ean,
quantity: 1,
price: catalogItem.catalogAvailability?.price,
},
};
const availability = await this.#availabilityService.getAvailability(
params as any,
abortSignal,
);
return {
ean: checkItem.ean,
price: availability?.price as AvailabilityPrice | undefined,
};
} catch (error) {
this.#logger.error(
'Failed to fetch availability for item',
error as Error,
() => ({
ean: checkItem.ean,
orderType: checkItem.orderType,
branchId: checkItem.branchId,
}),
);
return { ean: checkItem.ean, price: undefined };
}
});
// Wait for all availability requests to complete
const availabilityResults = await Promise.all(availabilityPromises);
// Build price map from results
const pricesByEan = new Map(
availabilityResults.map((result) => [result.ean, result.price]),
);
// Build final result: combine catalog prices, availability prices and redemption points
const results: PriceAndRedemptionPointsResult[] = eans.map((ean) => ({
ean,
availabilityPrice: pricesByEan.get(ean),
catalogPrice: catalogByEan.get(ean)?.catalogAvailability?.price,
redemptionPoints: catalogByEan.get(ean)?.redemptionPoints,
}));
return results;
},
defaultValue: [],
});
readonly priceAndRedemptionPoints =
this.#priceAndRedemptionPointsResource.value.asReadonly();
readonly loading = this.#priceAndRedemptionPointsResource.isLoading;
readonly error = computed(
() => this.#priceAndRedemptionPointsResource.error()?.message ?? null,
);
loadPriceAndRedemptionPoints(items: ItemWithOrderType[] | undefined) {
this.#items.set(items);
}
refresh() {
this.#priceAndRedemptionPointsResource.reload();
}
}

View File

@@ -0,0 +1,80 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { RewardSelectionDialogComponent } from './reward-selection-dialog.component';
import { RewardSelectionStore } from './store/reward-selection-dialog.store';
import { RewardSelectionFacade } from '@isa/checkout/data-access';
import { DialogComponent } from '@isa/ui/dialog';
import { DialogRef, DIALOG_DATA } from '@angular/cdk/dialog';
import { provideHttpClient } from '@angular/common/http';
import { signal } from '@angular/core';
describe('RewardSelectionDialogComponent', () => {
let component: RewardSelectionDialogComponent;
let fixture: ComponentFixture<RewardSelectionDialogComponent>;
const mockDialogData = {
rewardSelectionItems: [
{
item: {
id: 1,
data: {
product: { catalogProductNumber: 'CAT-123', name: 'Test Product' },
quantity: 1,
loyalty: { value: 100 },
},
},
catalogPrice: undefined,
availabilityPrice: undefined,
catalogRewardPoints: 100,
cartQuantity: 1,
rewardCartQuantity: 0,
} as any,
],
customerRewardPoints: 500,
closeText: 'Close',
};
const dialogComponentMock = {
close: vi.fn(),
data: mockDialogData,
};
const dialogRefMock = {
close: vi.fn(),
};
beforeEach(() => {
TestBed.configureTestingModule({
imports: [RewardSelectionDialogComponent],
providers: [
{ provide: DialogComponent, useValue: dialogComponentMock },
{ provide: DialogRef, useValue: dialogRefMock },
{ provide: DIALOG_DATA, useValue: mockDialogData },
provideHttpClient(),
],
});
fixture = TestBed.createComponent(RewardSelectionDialogComponent);
component = fixture.componentInstance;
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should initialize store with dialog data on construction', () => {
// The component provides its own store, so we verify it exists and has the init method
expect(component.store).toBeDefined();
expect(component.store.initState).toBeDefined();
});
it('should inject store', () => {
// The component provides its own store instance
expect(component.store).toBeDefined();
expect(typeof component.store.initState).toBe('function');
});
it('should extend DialogContentDirective', () => {
expect(component.data).toBe(mockDialogData);
});
});

View File

@@ -7,7 +7,7 @@ import {
import { RewardSelectionItemComponent } from '../reward-selection-item.component';
import { RewardSelectionStore } from '../../../store/reward-selection-dialog.store';
import {
OrderType,
OrderTypeFeature,
calculatePriceValue,
calculateLoyaltyPointsValue,
hasOrderTypeFeature,
@@ -42,8 +42,8 @@ export class RewardSelectionInputsComponent {
hasCorrectOrderType = computed(() => {
const item = this.rewardSelectionItem().item;
return hasOrderTypeFeature(item.features, [
OrderType.InStore,
OrderType.Pickup,
OrderTypeFeature.InStore,
OrderTypeFeature.Pickup,
]);
});

View File

@@ -1,163 +1,163 @@
import { inject, Injectable } from '@angular/core';
import { injectConfirmationDialog, injectFeedbackDialog } from '@isa/ui/dialog';
import { firstValueFrom } from 'rxjs';
import { RewardSelectionService } from './reward-selection.service';
import { CheckoutMetadataService } from '@isa/checkout/data-access';
import { injectTabId } from '@isa/core/tabs';
export const NavigateAfterRewardSelection = {
CART: 'cart',
REWARD: 'reward',
CATALOG: 'catalog',
} as const;
export type NavigateAfterRewardSelection =
(typeof NavigateAfterRewardSelection)[keyof typeof NavigateAfterRewardSelection];
@Injectable()
export class RewardSelectionPopUpService {
#tabId = injectTabId();
#feedbackDialog = injectFeedbackDialog();
#confirmationDialog = injectConfirmationDialog();
#rewardSelectionService = inject(RewardSelectionService);
#checkoutMetadataService = inject(CheckoutMetadataService);
/**
* Displays the reward selection popup dialog if conditions are met.
*
* This method manages the complete flow of the reward selection popup:
* 1. Checks if the popup has already been shown in the current tab (prevents duplicate displays)
* 2. Reloads necessary resources for the reward selection dialog
* 3. Opens the reward selection dialog if conditions allow
* 4. Marks the popup as opened for the current tab using {@link #setPopUpOpenedState}
* 5. Processes user selections and determines navigation flow
*
* @returns A promise that resolves to:
* - `NavigateAfterRewardSelection.CART` - Navigate to the shopping cart
* - `NavigateAfterRewardSelection.REWARD` - Navigate to the reward cart
* - `NavigateAfterRewardSelection.CATALOG` - Navigate back to the catalog
* - `undefined` - Stay on the current page (e.g., when all quantities are set to 0)
*
* @example
* ```typescript
* const result = await rewardSelectionPopUpService.popUp();
* if (result === NavigateAfterRewardSelection.CART) {
* this.router.navigate(['/cart']);
* }
* ```
*/
async popUp(): Promise<NavigateAfterRewardSelection | undefined> {
if (this.#popUpAlreadyOpened(this.#tabId())) {
return NavigateAfterRewardSelection.CART;
}
await this.#rewardSelectionService.reloadResources();
if (this.#rewardSelectionService.canOpen()) {
const dialogResult = await this.#rewardSelectionService.open({
closeText: 'Weiter einkaufen',
});
this.#setPopUpOpenedState(this.#tabId(), true);
if (dialogResult) {
// Wenn der User alles 0 setzt, bleibt man einfach auf der aktuellen Seite
if (dialogResult?.rewardSelectionItems?.length === 0) {
await this.#feedback();
return undefined;
}
if (dialogResult.rewardSelectionItems?.length > 0) {
const hasRegularCartItems = dialogResult.rewardSelectionItems?.some(
(item) => item?.cartQuantity > 0,
);
const hasRewardCartItems = dialogResult.rewardSelectionItems?.some(
(item) => item?.rewardCartQuantity > 0,
);
return await this.#confirmDialog(
hasRegularCartItems,
hasRewardCartItems,
);
}
}
}
return NavigateAfterRewardSelection.CART;
}
async #feedback() {
this.#feedbackDialog({
data: { message: 'Auswahl gespeichert' },
});
}
async #confirmDialog(
hasRegularCartItems: boolean,
hasRewardCartItems: boolean,
): Promise<NavigateAfterRewardSelection | undefined> {
const title = hasRewardCartItems
? 'Artikel wurde der Prämienausgabe hinzugefügt'
: 'Artikel wurde zum Warenkorb hinzugefügt';
const message = hasRegularCartItems
? 'Bitte schließen sie erst den Warenkorb ab und dann die Prämienausgabe'
: hasRegularCartItems && !hasRewardCartItems
? 'Bitte schließen sie den Warenkorb ab'
: '';
const dialogRef = this.#confirmationDialog({
title,
data: {
message,
closeText: 'Weiter einkaufen',
confirmText: hasRegularCartItems
? 'Zum Warenkorb'
: 'Zur Prämienausgabe',
},
width: '30rem',
});
const dialogResult = await firstValueFrom(dialogRef.closed);
if (dialogResult) {
if (dialogResult.confirmed && hasRegularCartItems) {
return NavigateAfterRewardSelection.CART;
} else if (dialogResult.confirmed) {
return NavigateAfterRewardSelection.REWARD;
} else {
return NavigateAfterRewardSelection.CATALOG;
}
}
return undefined;
}
#popUpAlreadyOpened(tabId: number | null): boolean | undefined {
if (tabId == null) return;
return this.#checkoutMetadataService.getRewardSelectionPopupOpenedState(
tabId,
);
}
/**
* Sets the opened state of the reward selection popup for a specific tab.
*
* This method persists the popup state to prevent the popup from being displayed
* multiple times within the same tab session. It's called after successfully
* opening the reward selection dialog.
*
* @param tabId - The unique identifier of the tab. If null, the method returns early without setting state.
* @param opened - The opened state to set. `true` indicates the popup has been shown, `false` or `undefined` resets the state.
*
* @remarks
* This state is typically set to `true` after the user has seen the popup to ensure
* it doesn't appear again during the same browsing session in that tab.
*/
#setPopUpOpenedState(tabId: number | null, opened: boolean | undefined) {
if (tabId == null) return;
this.#checkoutMetadataService.setRewardSelectionPopupOpenedState(
tabId,
opened,
);
}
}
import { inject, Injectable } from '@angular/core';
import { injectConfirmationDialog, injectFeedbackDialog } from '@isa/ui/dialog';
import { firstValueFrom } from 'rxjs';
import { RewardSelectionService } from './reward-selection.service';
import { CheckoutMetadataService } from '@isa/checkout/data-access';
import { injectTabId } from '@isa/core/tabs';
export const NavigateAfterRewardSelection = {
CART: 'cart',
REWARD: 'reward',
CATALOG: 'catalog',
} as const;
export type NavigateAfterRewardSelection =
(typeof NavigateAfterRewardSelection)[keyof typeof NavigateAfterRewardSelection];
@Injectable()
export class RewardSelectionPopUpService {
#tabId = injectTabId();
#feedbackDialog = injectFeedbackDialog();
#confirmationDialog = injectConfirmationDialog();
#rewardSelectionService = inject(RewardSelectionService);
#checkoutMetadataService = inject(CheckoutMetadataService);
/**
* Displays the reward selection popup dialog if conditions are met.
*
* This method manages the complete flow of the reward selection popup:
* 1. Checks if the popup has already been shown in the current tab (prevents duplicate displays)
* 2. Reloads necessary resources for the reward selection dialog
* 3. Opens the reward selection dialog if conditions allow
* 4. Marks the popup as opened for the current tab using {@link #setPopUpOpenedState}
* 5. Processes user selections and determines navigation flow
*
* @returns A promise that resolves to:
* - `NavigateAfterRewardSelection.CART` - Navigate to the shopping cart
* - `NavigateAfterRewardSelection.REWARD` - Navigate to the reward cart
* - `NavigateAfterRewardSelection.CATALOG` - Navigate back to the catalog
* - `undefined` - Stay on the current page (e.g., when all quantities are set to 0)
*
* @example
* ```typescript
* const result = await rewardSelectionPopUpService.popUp();
* if (result === NavigateAfterRewardSelection.CART) {
* this.router.navigate(['/cart']);
* }
* ```
*/
async popUp(): Promise<NavigateAfterRewardSelection | undefined> {
if (this.#popUpAlreadyOpened(this.#tabId())) {
return NavigateAfterRewardSelection.CART;
}
await this.#rewardSelectionService.reloadResources();
if (this.#rewardSelectionService.canOpen()) {
const dialogResult = await this.#rewardSelectionService.open({
closeText: 'Weiter einkaufen',
});
this.#setPopUpOpenedState(this.#tabId(), true);
if (dialogResult) {
// Wenn der User alles 0 setzt, bleibt man einfach auf der aktuellen Seite
if (dialogResult?.rewardSelectionItems?.length === 0) {
await this.#feedback();
return undefined;
}
if (dialogResult.rewardSelectionItems?.length > 0) {
const hasRegularCartItems = dialogResult.rewardSelectionItems?.some(
(item) => item?.cartQuantity > 0,
);
const hasRewardCartItems = dialogResult.rewardSelectionItems?.some(
(item) => item?.rewardCartQuantity > 0,
);
return await this.#confirmDialog(
hasRegularCartItems,
hasRewardCartItems,
);
}
}
}
return NavigateAfterRewardSelection.CART;
}
async #feedback() {
this.#feedbackDialog({
data: { message: 'Auswahl gespeichert' },
});
}
async #confirmDialog(
hasRegularCartItems: boolean,
hasRewardCartItems: boolean,
): Promise<NavigateAfterRewardSelection | undefined> {
const title = hasRewardCartItems
? 'Artikel wurde der Prämienausgabe hinzugefügt'
: 'Artikel wurde zum Warenkorb hinzugefügt';
const message = hasRegularCartItems
? 'Bitte schließen sie erst den Warenkorb ab und dann die Prämienausgabe'
: hasRegularCartItems && !hasRewardCartItems
? 'Bitte schließen sie den Warenkorb ab'
: '';
const dialogRef = this.#confirmationDialog({
title,
data: {
message,
closeText: 'Weiter einkaufen',
confirmText: hasRegularCartItems
? 'Zum Warenkorb'
: 'Zur Prämienausgabe',
},
width: '30rem',
});
const dialogResult = await firstValueFrom(dialogRef.closed);
if (dialogResult) {
if (dialogResult.confirmed && hasRegularCartItems) {
return NavigateAfterRewardSelection.CART;
} else if (dialogResult.confirmed) {
return NavigateAfterRewardSelection.REWARD;
} else {
return NavigateAfterRewardSelection.CATALOG;
}
}
return undefined;
}
#popUpAlreadyOpened(tabId: number | null): boolean | undefined {
if (tabId == null) return;
return this.#checkoutMetadataService.getRewardSelectionPopupOpenedState(
tabId,
);
}
/**
* Sets the opened state of the reward selection popup for a specific tab.
*
* This method persists the popup state to prevent the popup from being displayed
* multiple times within the same tab session. It's called after successfully
* opening the reward selection dialog.
*
* @param tabId - The unique identifier of the tab. If null, the method returns early without setting state.
* @param opened - The opened state to set. `true` indicates the popup has been shown, `false` or `undefined` resets the state.
*
* @remarks
* This state is typically set to `true` after the user has seen the popup to ensure
* it doesn't appear again during the same browsing session in that tab.
*/
#setPopUpOpenedState(tabId: number | null, opened: boolean | undefined) {
if (tabId == null) return;
this.#checkoutMetadataService.setRewardSelectionPopupOpenedState(
tabId,
opened,
);
}
}

View File

@@ -1,195 +1,191 @@
import { computed, effect, inject, Injectable, untracked } from '@angular/core';
import {
SelectedRewardShoppingCartResource,
SelectedShoppingCartResource,
ShoppingCartItem,
getOrderTypeFeature,
RewardSelectionItem,
itemSelectionChanged,
mergeRewardSelectionItems,
} from '@isa/checkout/data-access';
import { injectDialog } from '@isa/ui/dialog';
import {
RewardSelectionDialogComponent,
RewardSelectionDialogResult,
} from '../reward-selection-dialog.component';
import { PrimaryCustomerCardResource } from '@isa/crm/data-access';
import { firstValueFrom } from 'rxjs';
import { toObservable } from '@angular/core/rxjs-interop';
import { filter, first } from 'rxjs/operators';
import {
PriceAndRedemptionPointsResource,
ItemWithOrderType,
} from '../resource/price-and-redemption-points.resource';
@Injectable()
export class RewardSelectionService {
rewardSelectionDialog = injectDialog(RewardSelectionDialogComponent);
#primaryCustomerCardResource = inject(PrimaryCustomerCardResource);
#priceAndRedemptionPointsResource = inject(PriceAndRedemptionPointsResource);
#shoppingCartResource = inject(SelectedShoppingCartResource).resource;
#rewardShoppingCartResource = inject(SelectedRewardShoppingCartResource)
.resource;
readonly shoppingCartResponseValue =
this.#shoppingCartResource.value.asReadonly();
readonly rewardShoppingCartResponseValue =
this.#rewardShoppingCartResource.value.asReadonly();
readonly primaryCustomerCardValue =
this.#primaryCustomerCardResource.primaryCustomerCard;
readonly priceAndRedemptionPoints =
this.#priceAndRedemptionPointsResource.priceAndRedemptionPoints;
shoppingCartItems = computed(() => {
return (
this.shoppingCartResponseValue()
?.items?.map((item) => item?.data as ShoppingCartItem)
.filter((item): item is ShoppingCartItem => item != null) ?? []
);
});
rewardShoppingCartItems = computed(() => {
return (
this.rewardShoppingCartResponseValue()
?.items?.map((item) => item?.data as ShoppingCartItem)
.filter((item): item is ShoppingCartItem => item != null) ?? []
);
});
mergedRewardSelectionItems = computed<RewardSelectionItem[]>(() => {
return mergeRewardSelectionItems(
this.shoppingCartItems(),
this.rewardShoppingCartItems(),
);
});
selectionItemsWithOrderType = computed<ItemWithOrderType[]>(() => {
return this.mergedRewardSelectionItems()
.map((item): ItemWithOrderType | null => {
const ean = item.item.product.ean;
const orderType = getOrderTypeFeature(item.item.features);
const branchId = item.item.destination?.data?.targetBranch?.data?.id;
if (!ean || !orderType) {
return null;
}
return { ean, orderType, branchId };
})
.filter((item): item is ItemWithOrderType => item !== null);
});
updatedRewardSelectionItems = computed<RewardSelectionItem[]>(() => {
const rewardSelectionItems = this.mergedRewardSelectionItems();
const priceAndRedemptionResults = this.priceAndRedemptionPoints();
return rewardSelectionItems.map((selectionItem) => {
const ean = selectionItem.item.product.ean;
const result = priceAndRedemptionResults?.find((r) => r.ean === ean);
return {
...selectionItem,
catalogPrice: result?.catalogPrice,
catalogRewardPoints: result?.redemptionPoints,
availabilityPrice: result?.availabilityPrice,
};
});
});
primaryBonusCardPoints = computed(
() => this.primaryCustomerCardValue()?.totalPoints ?? 0,
);
isLoading = computed(
() =>
this.#shoppingCartResource.isLoading() ||
this.#rewardShoppingCartResource.isLoading() ||
this.#primaryCustomerCardResource.loading() ||
this.#priceAndRedemptionPointsResource.loading(),
);
#isLoading$ = toObservable(this.isLoading);
eligibleItems = computed(() =>
this.updatedRewardSelectionItems().filter(
(selectionItem) =>
(selectionItem.item.loyalty?.value != null &&
selectionItem.item.loyalty.value !== 0) ||
(selectionItem?.catalogRewardPoints != null &&
selectionItem.catalogRewardPoints !== 0),
),
);
canOpen = computed(
() => this.eligibleItems().length > 0 && !!this.primaryBonusCardPoints(),
);
constructor() {
effect(() => {
const items = this.selectionItemsWithOrderType();
untracked(() => {
const resourceLoading =
this.#priceAndRedemptionPointsResource.loading();
if (!resourceLoading && items.length > 0) {
this.#priceAndRedemptionPointsResource.loadPriceAndRedemptionPoints(
items,
);
}
});
});
}
async open({
closeText,
}: {
closeText: string;
}): Promise<RewardSelectionDialogResult> {
const rewardSelectionItems = this.eligibleItems();
const dialogRef = this.rewardSelectionDialog({
title: 'Ein oder mehrere Artikel sind als Prämie verfügbar',
data: {
rewardSelectionItems,
customerRewardPoints: this.primaryBonusCardPoints(),
closeText,
},
displayClose: true,
disableClose: false,
width: '44.5rem',
});
const dialogResult = await firstValueFrom(dialogRef.closed);
if (
itemSelectionChanged(
rewardSelectionItems,
dialogResult?.rewardSelectionItems,
)
) {
return dialogResult;
}
return undefined;
}
async reloadResources(): Promise<void> {
// Start reloading all resources
// Note: PrimaryCustomerCard, Price and redemption points will be loaded automatically by the effect
// when selectionItemsWithOrderType changes after cart resources are reloaded
await Promise.all([
this.#shoppingCartResource.reload(),
this.#rewardShoppingCartResource.reload(),
]);
// Wait until all resources are fully loaded (isLoading becomes false)
await firstValueFrom(
this.#isLoading$.pipe(
filter((isLoading) => !isLoading),
first(),
),
);
}
}
import { computed, effect, inject, Injectable, untracked } from '@angular/core';
import {
SelectedRewardShoppingCartResource,
SelectedShoppingCartResource,
ShoppingCartItem,
getOrderTypeFeature,
RewardSelectionItem,
itemSelectionChanged,
mergeRewardSelectionItems,
} from '@isa/checkout/data-access';
import { injectDialog } from '@isa/ui/dialog';
import {
RewardSelectionDialogComponent,
RewardSelectionDialogResult,
} from '../reward-selection-dialog.component';
import { PrimaryCustomerCardResource } from '@isa/crm/data-access';
import { firstValueFrom } from 'rxjs';
import { toObservable } from '@angular/core/rxjs-interop';
import { filter, first } from 'rxjs/operators';
import {
PriceAndRedemptionPointsResource,
ItemWithOrderType,
} from '../resource/price-and-redemption-points.resource';
@Injectable()
export class RewardSelectionService {
rewardSelectionDialog = injectDialog(RewardSelectionDialogComponent);
#primaryCustomerCardResource = inject(PrimaryCustomerCardResource);
#priceAndRedemptionPointsResource = inject(PriceAndRedemptionPointsResource);
#shoppingCartResource = inject(SelectedShoppingCartResource).resource;
#rewardShoppingCartResource = inject(SelectedRewardShoppingCartResource)
.resource;
readonly shoppingCartResponseValue =
this.#shoppingCartResource.value.asReadonly();
readonly rewardShoppingCartResponseValue =
this.#rewardShoppingCartResource.value.asReadonly();
readonly primaryCustomerCardValue =
this.#primaryCustomerCardResource.primaryCustomerCard;
readonly priceAndRedemptionPoints =
this.#priceAndRedemptionPointsResource.priceAndRedemptionPoints;
shoppingCartItems = computed(() => {
return (
this.shoppingCartResponseValue()
?.items?.map((item) => item?.data as ShoppingCartItem)
.filter((item): item is ShoppingCartItem => item != null) ?? []
);
});
rewardShoppingCartItems = computed(() => {
return (
this.rewardShoppingCartResponseValue()
?.items?.map((item) => item?.data as ShoppingCartItem)
.filter((item): item is ShoppingCartItem => item != null) ?? []
);
});
mergedRewardSelectionItems = computed<RewardSelectionItem[]>(() => {
return mergeRewardSelectionItems(
this.shoppingCartItems(),
this.rewardShoppingCartItems(),
);
});
selectionItemsWithOrderType = computed<ItemWithOrderType[]>(() => {
return this.mergedRewardSelectionItems()
.map((item): ItemWithOrderType | null => {
const ean = item.item.product.ean;
const orderType = getOrderTypeFeature(item.item.features);
const branchId = item.item.destination?.data?.targetBranch?.data?.id;
if (!ean || !orderType) {
return null;
}
return { ean, orderType, branchId };
})
.filter((item): item is ItemWithOrderType => item !== null);
});
updatedRewardSelectionItems = computed<RewardSelectionItem[]>(() => {
const rewardSelectionItems = this.mergedRewardSelectionItems();
const priceAndRedemptionResults = this.priceAndRedemptionPoints();
return rewardSelectionItems.map((selectionItem) => {
const ean = selectionItem.item.product.ean;
const result = priceAndRedemptionResults?.find((r) => r.ean === ean);
return {
...selectionItem,
catalogPrice: result?.catalogPrice,
catalogRewardPoints: result?.redemptionPoints,
availabilityPrice: result?.availabilityPrice,
};
});
});
primaryBonusCardPoints = computed(
() => this.primaryCustomerCardValue()?.totalPoints ?? 0,
);
isLoading = computed(
() =>
this.#shoppingCartResource.isLoading() ||
this.#rewardShoppingCartResource.isLoading() ||
this.#primaryCustomerCardResource.loading() ||
this.#priceAndRedemptionPointsResource.loading(),
);
#isLoading$ = toObservable(this.isLoading);
eligibleItems = computed(() =>
this.updatedRewardSelectionItems().filter(
(selectionItem) =>
(selectionItem.item.loyalty?.value != null &&
selectionItem.item.loyalty.value !== 0) ||
(selectionItem?.catalogRewardPoints != null &&
selectionItem.catalogRewardPoints !== 0),
),
);
canOpen = computed(
() => this.eligibleItems().length > 0 && !!this.primaryBonusCardPoints(),
);
constructor() {
effect(() => {
const items = this.selectionItemsWithOrderType();
untracked(() => {
const resourceLoading =
this.#priceAndRedemptionPointsResource.loading();
if (!resourceLoading && items.length > 0) {
this.#priceAndRedemptionPointsResource.loadPriceAndRedemptionPoints(
items,
);
}
});
});
}
async open({
closeText,
}: {
closeText: string;
}): Promise<RewardSelectionDialogResult> {
const rewardSelectionItems = this.eligibleItems();
const dialogRef = this.rewardSelectionDialog({
title: 'Ein oder mehrere Artikel sind als Prämie verfügbar',
data: {
rewardSelectionItems,
customerRewardPoints: this.primaryBonusCardPoints(),
closeText,
},
displayClose: true,
disableClose: false,
width: '44.5rem',
});
const dialogResult = await firstValueFrom(dialogRef.closed);
if (
itemSelectionChanged(
rewardSelectionItems,
dialogResult?.rewardSelectionItems,
)
) {
await this.reloadResources();
return dialogResult;
}
return undefined;
}
async reloadResources(): Promise<void> {
// Start reloading all resources
// Note: PrimaryCustomerCard, Price and redemption points will be loaded automatically by the effect
// when selectionItemsWithOrderType changes after cart resources are reloaded
this.#shoppingCartResource.reload();
this.#rewardShoppingCartResource.reload();
// Wait until all resources are fully loaded (isLoading becomes false)
await firstValueFrom(
this.#isLoading$.pipe(filter((isLoading) => !isLoading)),
);
}
}

View File

@@ -1,95 +1,87 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { TextButtonComponent } from '@isa/ui/buttons';
import { SkeletonLoaderDirective } from '@isa/ui/skeleton-loader';
import { injectTabId } from '@isa/core/tabs';
import { DomainCheckoutService } from '@domain/checkout';
import { injectFeedbackDialog } from '@isa/ui/dialog';
import { RewardSelectionService } from '../service/reward-selection.service';
import { Router } from '@angular/router';
import {
SelectedRewardShoppingCartResource,
SelectedShoppingCartResource,
} from '@isa/checkout/data-access';
import { CheckoutNavigationService } from '@shared/services/navigation';
@Component({
selector: 'lib-reward-selection-trigger',
templateUrl: './reward-selection-trigger.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [TextButtonComponent, SkeletonLoaderDirective],
providers: [
SelectedShoppingCartResource,
SelectedRewardShoppingCartResource,
RewardSelectionService,
],
})
export class RewardSelectionTriggerComponent {
#router = inject(Router);
#tabId = injectTabId();
#feedbackDialog = injectFeedbackDialog();
#rewardSelectionService = inject(RewardSelectionService);
#domainCheckoutService = inject(DomainCheckoutService);
#checkoutNavigationService = inject(CheckoutNavigationService);
canOpen = this.#rewardSelectionService.canOpen;
isLoading = this.#rewardSelectionService.isLoading;
async openRewardSelectionDialog() {
const tabId = this.#tabId();
const dialogResult = await this.#rewardSelectionService.open({
closeText: 'Abbrechen',
});
if (dialogResult && tabId) {
await this.#reloadShoppingCart(tabId);
// Wenn der User alles 0 setzt, bleibt man einfach auf der aktuellen Seite
if (dialogResult.rewardSelectionItems?.length === 0) {
await this.#feedback();
}
if (dialogResult.rewardSelectionItems?.length > 0) {
const hasRegularCartItems = dialogResult.rewardSelectionItems?.some(
(item) => item?.cartQuantity > 0,
);
const hasRewardCartItems = dialogResult.rewardSelectionItems?.some(
(item) => item?.rewardCartQuantity > 0,
);
await this.#feedback();
// Wenn Nutzer im Warenkorb ist und alle Items als Prämie setzt -> Navigation zum Prämien Checkout
if (!hasRegularCartItems && hasRewardCartItems) {
await this.#navigateToRewardCheckout(tabId);
}
// Wenn Nutzer im Prämien Checkout ist und alle Items in den Warenkorb setzt -> Navigation zu Warenkorb
if (hasRegularCartItems && !hasRewardCartItems) {
await this.#navigateToCheckout(tabId);
}
}
}
}
async #reloadShoppingCart(tabId: number) {
await this.#domainCheckoutService.reloadShoppingCart({
processId: tabId,
});
}
async #feedback() {
this.#feedbackDialog({
data: { message: 'Auswahl gespeichert' },
});
}
async #navigateToRewardCheckout(tabId: number) {
await this.#router.navigate([`/${tabId}`, 'reward', 'cart']);
}
async #navigateToCheckout(tabId: number) {
await this.#checkoutNavigationService
.getCheckoutReviewPath(tabId)
.navigate();
}
}
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { TextButtonComponent } from '@isa/ui/buttons';
import { SkeletonLoaderDirective } from '@isa/ui/skeleton-loader';
import { injectTabId } from '@isa/core/tabs';
import { DomainCheckoutService } from '@domain/checkout';
import { injectFeedbackDialog } from '@isa/ui/dialog';
import { RewardSelectionService } from '../service/reward-selection.service';
import { Router } from '@angular/router';
import { CheckoutNavigationService } from '@shared/services/navigation';
@Component({
selector: 'lib-reward-selection-trigger',
templateUrl: './reward-selection-trigger.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [TextButtonComponent, SkeletonLoaderDirective],
providers: [RewardSelectionService],
})
export class RewardSelectionTriggerComponent {
#router = inject(Router);
#tabId = injectTabId();
#feedbackDialog = injectFeedbackDialog();
#rewardSelectionService = inject(RewardSelectionService);
#domainCheckoutService = inject(DomainCheckoutService);
#checkoutNavigationService = inject(CheckoutNavigationService);
canOpen = this.#rewardSelectionService.canOpen;
isLoading = this.#rewardSelectionService.isLoading;
async openRewardSelectionDialog() {
const tabId = this.#tabId();
const dialogResult = await this.#rewardSelectionService.open({
closeText: 'Abbrechen',
});
if (dialogResult && tabId) {
await this.#reloadShoppingCart(tabId);
// Wenn der User alles 0 setzt, bleibt man einfach auf der aktuellen Seite
if (dialogResult.rewardSelectionItems?.length === 0) {
await this.#feedback();
}
if (dialogResult.rewardSelectionItems?.length > 0) {
const hasRegularCartItems = dialogResult.rewardSelectionItems?.some(
(item) => item?.cartQuantity > 0,
);
const hasRewardCartItems = dialogResult.rewardSelectionItems?.some(
(item) => item?.rewardCartQuantity > 0,
);
await this.#feedback();
// Wenn Nutzer im Warenkorb ist und alle Items als Prämie setzt -> Navigation zum Prämien Checkout
if (!hasRegularCartItems && hasRewardCartItems) {
await this.#navigateToRewardCheckout(tabId);
}
// Wenn Nutzer im Prämien Checkout ist und alle Items in den Warenkorb setzt -> Navigation zu Warenkorb
if (hasRegularCartItems && !hasRewardCartItems) {
await this.#navigateToCheckout(tabId);
}
}
}
}
async #reloadShoppingCart(tabId: number) {
await this.#domainCheckoutService.reloadShoppingCart({
processId: tabId,
});
}
async #feedback() {
this.#feedbackDialog({
data: { message: 'Auswahl gespeichert' },
});
}
async #navigateToRewardCheckout(tabId: number) {
await this.#router.navigate([`/${tabId}`, 'reward', 'cart']);
}
async #navigateToCheckout(tabId: number) {
await this.#checkoutNavigationService
.getCheckoutReviewPath(tabId)
.navigate();
}
}

View File

@@ -5,10 +5,11 @@ export * from './entity-cotnainer';
export * from './entity-status';
export * from './gender';
export * from './list-response-args';
export * from './order-type';
export * from './order-type-feature';
export * from './payer-type';
export * from './price-value';
export * from './price';
export * from './product';
export * from './response-args';
export * from './return-value';
export * from './vat-type';

View File

@@ -1,10 +1,11 @@
export const OrderType = {
InStore: 'Rücklage',
Pickup: 'Abholung',
Delivery: 'Versand',
DigitalShipping: 'DIG-Versand',
B2BShipping: 'B2B-Versand',
Download: 'Download',
} as const;
export type OrderType = (typeof OrderType)[keyof typeof OrderType];
export const OrderTypeFeature = {
InStore: 'Rücklage',
Pickup: 'Abholung',
Delivery: 'Versand',
DigitalShipping: 'DIG-Versand',
B2BShipping: 'B2B-Versand',
Download: 'Download',
} as const;
export type OrderTypeFeature =
(typeof OrderTypeFeature)[keyof typeof OrderTypeFeature];

View File

@@ -0,0 +1,5 @@
import { ProductDTO as CatProductDTO } from '@generated/swagger/cat-search-api';
import { ProductDTO as CheckoutProductDTO } from '@generated/swagger/checkout-api';
import { ProductDTO as OmsProductDTO } from '@generated/swagger/oms-api';
export type Product = CatProductDTO | CheckoutProductDTO | OmsProductDTO;

View File

@@ -1,12 +1,10 @@
import z from 'zod';
export const AddressSchema = z
.object({
street: z.string().describe('Street name').optional(),
streetNumber: z.string().describe('Street number').optional(),
zipCode: z.string().describe('Postal code').optional(),
city: z.string().describe('City name').optional(),
country: z.string().describe('Country').optional(),
additionalInfo: z.string().describe('Additional information').optional(),
})
.optional();
export const AddressSchema = z.object({
street: z.string().describe('Street name').optional(),
streetNumber: z.string().describe('Street number').optional(),
zipCode: z.string().describe('Postal code').optional(),
city: z.string().describe('City name').optional(),
country: z.string().describe('Country').optional(),
additionalInfo: z.string().describe('Additional information').optional(),
});

View File

@@ -7,11 +7,15 @@ import { OrganisationSchema } from './organisation.schema';
export const AddresseeWithReferenceSchema = EntityReferenceSchema.extend({
address: AddressSchema.describe('Address').optional(),
communicationDetails: CommunicationDetailsSchema.describe('Communication details').optional(),
communicationDetails: CommunicationDetailsSchema.describe(
'Communication details',
).optional(),
firstName: z.string().describe('First name').optional(),
lastName: z.string().describe('Last name').optional(),
gender: GenderSchema.describe('Gender').optional(),
locale: z.string().describe('Locale').optional(),
organisation: OrganisationSchema.describe('Organisation information').optional(),
organisation: OrganisationSchema.describe(
'Organisation information',
).optional(),
title: z.string().describe('Title').optional(),
});

View File

@@ -4,4 +4,6 @@ import { EntityStatus } from '../models';
/**
* EntityStatus is a bitwise enum with values: 0 | 1 | 2 | 4 | 8
*/
export const EntityStatusSchema = z.nativeEnum(EntityStatus).describe('Entity status');
export const EntityStatusSchema = z
.nativeEnum(EntityStatus)
.describe('Entity status');

View File

@@ -13,6 +13,7 @@ export * from './gender.schema';
export * from './key-value.schema';
export * from './label.schema';
export * from './notification-channel.schema';
export * from './order-type.schema';
export * from './organisation-names.schema';
export * from './organisation.schema';
export * from './payment-type.schema';

View File

@@ -0,0 +1,14 @@
import { z } from 'zod';
export const OrderType = {
NotSet: 0,
Branch: 1,
Mail: 2,
Download: 4,
BranchAndDownload: 5, // Branch | Download
MailAndDownload: 6, // Mail | Download
} as const;
export const OrderTypeSchema = z.nativeEnum(OrderType).describe('Order type');
export type OrderType = z.infer<typeof OrderTypeSchema>;

View File

@@ -25,6 +25,9 @@ export const tabResolverFn: ResolveFn<Tab> = (route) => {
tab = tabService.addTab({
id: tabId,
name: 'Neuer Vorgang',
metadata: {
process_type: 'cart',
},
});
}

View File

@@ -39,7 +39,7 @@ export class CustomerShippingAddressResource {
});
}
@Injectable()
@Injectable({ providedIn: 'root' })
export class SelectedCustomerShippingAddressResource extends CustomerShippingAddressResource {
#tabId = inject(TabService).activatedTabId;
#customerMetadata = inject(CrmTabMetadataService);

View File

@@ -48,7 +48,7 @@ export class CustomerShippingAddressesResource {
});
}
@Injectable()
@Injectable({ providedIn: 'root' })
export class SelectedCustomerShippingAddressesResource extends CustomerShippingAddressesResource {
#tabId = inject(TabService).activatedTabId;
#customerMetadata = inject(CrmTabMetadataService);

View File

@@ -36,7 +36,7 @@ export class CustomerResource {
});
}
@Injectable()
@Injectable({ providedIn: 'root' })
export class SelectedCustomerResource extends CustomerResource {
#tabId = inject(TabService).activatedTabId;
#customerMetadata = inject(CrmTabMetadataService);

View File

@@ -0,0 +1,95 @@
import {
computed,
inject,
Injectable,
resource,
signal,
Signal,
} from '@angular/core';
import { OrdersService } from '../services/orders.service';
import { DisplayOrderDTO } from '@generated/swagger/oms-api';
/**
* Resource for fetching display orders by their numeric IDs.
*
* Provides reactive access to display order data with loading and error states.
* Supports both single and multiple display order fetching.
*
* @example
* ```typescript
* // Fetch single display order
* readonly displayOrdersResource = inject(DisplayOrdersResource);
* this.displayOrdersResource.loadOrder(123);
*
* // Fetch multiple display orders
* this.displayOrdersResource.loadOrders([123, 456, 789]);
*
* // Access data
* const orders = this.displayOrdersResource.orders();
* const isLoading = this.displayOrdersResource.loading();
* ```
*/
@Injectable()
export class DisplayOrdersResource {
#ordersService = inject(OrdersService);
#orderIds = signal<number[] | undefined>(undefined);
/**
* Internal resource that manages data fetching and caching
*/
#resource = resource({
params: computed(() => ({
orderIds: this.#orderIds(),
})),
loader: async ({ params, abortSignal }): Promise<DisplayOrderDTO[]> => {
if (!params?.orderIds?.length) {
return [];
}
return await this.#ordersService.getDisplayOrders(
params.orderIds,
abortSignal,
);
},
defaultValue: [],
});
/**
* Signal containing the array of fetched display orders.
* Returns empty array when loading or on error.
*/
readonly orders: Signal<readonly DisplayOrderDTO[]> =
this.#resource.value.asReadonly();
/**
* Signal indicating whether data is currently being fetched
*/
readonly loading: Signal<boolean> = this.#resource.isLoading;
/**
* Signal containing error message if fetch failed, otherwise null
*/
readonly error = computed(() => this.#resource.error()?.message ?? null);
/**
* Load a single display order by its numeric ID
*/
loadOrder(orderId: number): void {
this.#orderIds.set([orderId]);
}
/**
* Load multiple display orders by their numeric IDs
*/
loadOrders(orderIds: number[] | undefined): void {
this.#orderIds.set(orderIds);
}
/**
* Manually refresh the current display orders data
*/
refresh(): void {
this.#resource.reload();
}
}

View File

@@ -1 +1,4 @@
export * from './display-orders.resource';
export * from './open-reward-tasks.resource';
export * from './order-item-subset.resource';
export * from './orders.resource';

View File

@@ -0,0 +1,73 @@
import { computed, inject, Injectable, resource, Signal } from '@angular/core';
import { DBHOrderItemListItemDTO } from '@generated/swagger/oms-api';
import { OpenRewardTasksService } from '../services/open-reward-tasks.service';
/**
* Global resource for managing open reward distribution tasks (Prämienausgabe).
*
* Provides reactive access to unfinished reward orders across the application.
* This resource is provided at root level to ensure a single shared instance
* for both the side menu indicator and the reward catalog carousel.
*
* @example
* ```typescript
* // In component
* readonly openTasksResource = inject(OpenRewardTasksResource);
* readonly hasOpenTasks = computed(() =>
* (this.openTasksResource.tasks()?.length ?? 0) > 0
* );
* ```
*/
@Injectable({ providedIn: 'root' })
export class OpenRewardTasksResource {
#openRewardTasksService = inject(OpenRewardTasksService);
/**
* Internal resource that manages data fetching and caching
*/
#resource = resource({
loader: async ({ abortSignal }): Promise<DBHOrderItemListItemDTO[]> => {
return await this.#openRewardTasksService.getOpenRewardTasks(
abortSignal,
);
},
defaultValue: [],
});
/**
* Signal containing the array of open reward tasks.
* Returns empty array when loading or on error.
*/
readonly tasks: Signal<readonly DBHOrderItemListItemDTO[]> =
this.#resource.value.asReadonly();
/**
* Signal indicating whether data is currently being fetched
*/
readonly loading: Signal<boolean> = this.#resource.isLoading;
/**
* Signal containing error message if fetch failed, otherwise null
*/
readonly error = computed(
() => this.#resource.error()?.message ?? null,
);
/**
* Signal indicating whether there are any open tasks
*/
readonly hasOpenTasks = computed(() => (this.tasks()?.length ?? 0) > 0);
/**
* Signal containing the count of open tasks
*/
readonly taskCount = computed(() => this.tasks()?.length ?? 0);
/**
* Manually refresh the open tasks data.
* Useful for updating after a task is completed.
*/
refresh(): void {
this.#resource.reload();
}
}

View File

@@ -0,0 +1,92 @@
import {
computed,
inject,
Injectable,
resource,
signal,
Signal,
} from '@angular/core';
import { OrderDTO } from '@generated/swagger/oms-api';
import { OrdersService } from '../services/orders.service';
/**
* Resource for fetching orders by their numeric IDs.
*
* Provides reactive access to order data with loading and error states.
* Supports both single and multiple order fetching.
*
* @example
* ```typescript
* // Fetch single order
* readonly ordersResource = inject(OrdersResource);
* this.ordersResource.loadOrder(123);
*
* // Fetch multiple orders
* this.ordersResource.loadOrders([123, 456, 789]);
*
* // Access data
* const orders = this.ordersResource.orders();
* const isLoading = this.ordersResource.loading();
* ```
*/
@Injectable()
export class OrdersResource {
#ordersService = inject(OrdersService);
#orderIds = signal<number[] | undefined>(undefined);
/**
* Internal resource that manages data fetching and caching
*/
#resource = resource({
params: computed(() => ({
orderIds: this.#orderIds(),
})),
loader: async ({ params, abortSignal }): Promise<OrderDTO[]> => {
if (!params?.orderIds?.length) {
return [];
}
return await this.#ordersService.getOrders(params.orderIds, abortSignal);
},
defaultValue: [],
});
/**
* Signal containing the array of fetched orders.
* Returns empty array when loading or on error.
*/
readonly orders: Signal<readonly OrderDTO[]> =
this.#resource.value.asReadonly();
/**
* Signal indicating whether data is currently being fetched
*/
readonly loading: Signal<boolean> = this.#resource.isLoading;
/**
* Signal containing error message if fetch failed, otherwise null
*/
readonly error = computed(() => this.#resource.error()?.message ?? null);
/**
* Load a single order by its numeric ID
*/
loadOrder(orderId: number): void {
this.#orderIds.set([orderId]);
}
/**
* Load multiple orders by their numeric IDs
*/
loadOrders(orderIds: number[] | undefined): void {
this.#orderIds.set(orderIds);
}
/**
* Manually refresh the current orders data
*/
refresh(): void {
this.#resource.reload();
}
}

View File

@@ -1,50 +1,73 @@
import {
BuyerTypeSchema,
EntitySchema,
KeyValueOfStringAndStringSchema,
NotificationChannelSchema,
} from '@isa/common/data-access';
import { z } from 'zod';
import { DisplayAddresseeSchema } from './display-addressee.schema';
import { DisplayBranchSchema } from './display-branch.schema';
import { DisplayLogisticianSchema } from './display-logistician.schema';
import { DisplayOrderItemSchema } from './display-order-item.schema';
import { DisplayOrderPaymentSchema } from './display-order-payment.schema';
import { EnvironmentChannelSchema } from './environment-channel.schema';
import { LinkedRecordSchema } from './linked-record.schema';
import { OrderTypeSchema } from './order-type.schema';
import { TermsOfDeliverySchema } from './terms-of-delivery.schema';
export const DisplayOrderSchema = z
.object({
actions: z.array(KeyValueOfStringAndStringSchema).describe('Actions').optional(),
buyer: DisplayAddresseeSchema.describe('Buyer information').optional(),
buyerComment: z.string().describe('Buyer comment').optional(),
buyerIsGuestAccount: z.boolean().describe('Buyer is guest account').optional(),
buyerNumber: z.string().describe('Unique buyer identifier number').optional(),
buyerType: BuyerTypeSchema.describe('Buyer type').optional(),
clientChannel: EnvironmentChannelSchema.describe('Client channel').optional(),
completedDate: z.string().describe('Completed date').optional(),
features: z.record(z.string().describe('Features'), z.string()).optional(),
items: z.array(DisplayOrderItemSchema).describe('List of items').optional(),
itemsCount: z.number().describe('Number of itemss').optional(),
linkedRecords: z.array(LinkedRecordSchema).describe('List of linked records').optional(),
logistician: DisplayLogisticianSchema.describe('Logistician information').optional(),
notificationChannels: NotificationChannelSchema.describe('Notification channels').optional(),
orderBranch: DisplayBranchSchema.describe('Order branch').optional(),
orderDate: z.string().describe('Order date').optional(),
orderNumber: z.string().describe('Order number').optional(),
orderType: OrderTypeSchema.describe('Order type'),
orderValue: z.number().describe('Order value').optional(),
orderValueCurrency: z.string().describe('Order value currency').optional(),
payer: DisplayAddresseeSchema.describe('Payer information').optional(),
payerIsGuestAccount: z.boolean().describe('Payer is guest account').optional(),
payerNumber: z.string().describe('Unique payer account number').optional(),
payment: DisplayOrderPaymentSchema.describe('Payment').optional(),
shippingAddress: DisplayAddresseeSchema.describe('Shipping address information').optional(),
targetBranch: DisplayBranchSchema.describe('Target branch').optional(),
termsOfDelivery: TermsOfDeliverySchema.describe('Terms of delivery').optional(),
})
.extend(EntitySchema.shape);
export type DisplayOrder = z.infer<typeof DisplayOrderSchema>;
import {
BuyerTypeSchema,
EntitySchema,
KeyValueOfStringAndStringSchema,
NotificationChannelSchema,
} from '@isa/common/data-access';
import { z } from 'zod';
import { DisplayAddresseeSchema } from './display-addressee.schema';
import { DisplayBranchSchema } from './display-branch.schema';
import { DisplayLogisticianSchema } from './display-logistician.schema';
import { DisplayOrderItemSchema } from './display-order-item.schema';
import { DisplayOrderPaymentSchema } from './display-order-payment.schema';
import { EnvironmentChannelSchema } from './environment-channel.schema';
import { LinkedRecordSchema } from './linked-record.schema';
import { OrderTypeSchema } from './order-type.schema';
import { TermsOfDeliverySchema } from './terms-of-delivery.schema';
export const DisplayOrderSchema = z
.object({
actions: z
.array(KeyValueOfStringAndStringSchema)
.describe('Actions')
.optional(),
buyer: DisplayAddresseeSchema.describe('Buyer information').optional(),
buyerComment: z.string().describe('Buyer comment').optional(),
buyerIsGuestAccount: z
.boolean()
.describe('Buyer is guest account')
.optional(),
buyerNumber: z
.string()
.describe('Unique buyer identifier number')
.optional(),
buyerType: BuyerTypeSchema.describe('Buyer type').optional(),
clientChannel:
EnvironmentChannelSchema.describe('Client channel').optional(),
completedDate: z.string().describe('Completed date').optional(),
features: z.record(z.string().describe('Features'), z.string()).optional(),
items: z.array(DisplayOrderItemSchema).describe('List of items').optional(),
itemsCount: z.number().describe('Number of itemss').optional(),
linkedRecords: z
.array(LinkedRecordSchema)
.describe('List of linked records')
.optional(),
logistician: DisplayLogisticianSchema.describe(
'Logistician information',
).optional(),
notificationChannels: NotificationChannelSchema.describe(
'Notification channels',
).optional(),
orderBranch: DisplayBranchSchema.describe('Order branch').optional(),
orderDate: z.string().describe('Order date').optional(),
orderNumber: z.string().describe('Order number').optional(),
orderType: OrderTypeSchema.describe('Order type'),
orderValue: z.number().describe('Order value').optional(),
orderValueCurrency: z.string().describe('Order value currency').optional(),
payer: DisplayAddresseeSchema.describe('Payer information').optional(),
payerIsGuestAccount: z
.boolean()
.describe('Payer is guest account')
.optional(),
payerNumber: z.string().describe('Unique payer account number').optional(),
payment: DisplayOrderPaymentSchema.describe('Payment').optional(),
shippingAddress: DisplayAddresseeSchema.describe(
'Shipping address information',
).optional(),
targetBranch: DisplayBranchSchema.describe('Target branch').optional(),
termsOfDelivery:
TermsOfDeliverySchema.describe('Terms of delivery').optional(),
})
.extend(EntitySchema.shape);
export type DisplayOrder = z.infer<typeof DisplayOrderSchema>;

View File

@@ -3,12 +3,13 @@ export * from './dbh-order-item-list-item.schema';
export * from './display-addressee.schema';
export * from './display-branch.schema';
export * from './display-logistician.schema';
export * from './display-order-item.schema';
export * from './display-order-item-subset.schema';
export * from './display-order-item.schema';
export * from './display-order-payment.schema';
export * from './display-order.schema';
export * from './environment-channel.schema';
export * from './fetch-order-item-subset.schema';
export * from './fetch-receipts-by-order-item-subset-ids.schema';
export * from './fetch-return-details.schema';
export * from './gender.schema';
export * from './handle-command.schema';
@@ -32,4 +33,3 @@ export * from './shipping-type.schema';
export * from './terms-of-delivery.schema';
export * from './type-of-delivery.schema';
export * from './vat-type.schema';
export * from './fetch-receipts-by-order-item-subset-ids.schema';

View File

@@ -1,14 +1 @@
import { z } from 'zod';
export const OrderType = {
NotSet: 0,
Branch: 1,
Mail: 2,
Download: 4,
BranchAndDownload: 5, // Branch | Download
MailAndDownload: 6, // Mail | Download
} as const;
export const OrderTypeSchema = z.nativeEnum(OrderType).describe('Order type');
export type OrderType = z.infer<typeof OrderTypeSchema>;
export { OrderTypeSchema } from '@isa/common/data-access';

View File

@@ -1,6 +1,9 @@
export * from './handle-command.service';
export * from './logistician.service';
export * from './oms-metadata.service';
export * from './open-reward-tasks.service';
export * from './order-creation.service';
export * from './order-reward-collect.service';
export * from './orders.service';
export * from './print-receipts.service';
export * from './print-tolino-return-receipt.service';
export * from './return-can-return.service';
@@ -8,5 +11,3 @@ export * from './return-details.service';
export * from './return-process.service';
export * from './return-search.service';
export * from './return-task-list.service';
export * from './order-reward-collect.service';
export * from './handle-command.service';

View File

@@ -1,41 +0,0 @@
import { inject, Injectable } from '@angular/core';
import { TabService, getMetadataHelper } from '@isa/core/tabs';
import { DisplayOrder, DisplayOrderSchema } from '../schemas';
import { OMS_DISPLAY_ORDERS_KEY } from '../constants';
import z from 'zod';
@Injectable({ providedIn: 'root' })
export class OmsMetadataService {
#tabService = inject(TabService);
getDisplayOrders(tabId: number) {
return getMetadataHelper(
tabId,
OMS_DISPLAY_ORDERS_KEY,
z
.array(DisplayOrderSchema.extend({ shoppingCartId: z.number() }))
.optional(),
this.#tabService.entityMap(),
);
}
addDisplayOrders(
tabId: number,
orders: DisplayOrder[],
shoppingCartId: number,
) {
const existingOrders = this.getDisplayOrders(tabId) || [];
this.#tabService.patchTabMetadata(tabId, {
[OMS_DISPLAY_ORDERS_KEY]: [
...existingOrders,
...orders.map((order) => ({ ...order, shoppingCartId })),
],
});
}
clearDisplayOrders(tabId: number) {
this.#tabService.patchTabMetadata(tabId, {
[OMS_DISPLAY_ORDERS_KEY]: undefined,
});
}
}

View File

@@ -0,0 +1,74 @@
import { inject, Injectable } from '@angular/core';
import {
AbholfachService,
DBHOrderItemListItemDTO,
QueryTokenDTO,
} from '@generated/swagger/oms-api';
import { ResponseArgsError, takeUntilAborted } from '@isa/common/data-access';
import { logger } from '@isa/core/logging';
import { firstValueFrom } from 'rxjs';
import { Cache, CacheTimeToLive, InFlight } from '@isa/common/decorators';
/**
* Service for fetching open reward distribution tasks (Prämienausgabe) from the OMS API.
*
* Provides cached access to unfinished reward orders with automatic request deduplication.
* These are reward orders in processing statuses 16 (InPreparation) and 128 (ReadyForPickup)
* that need to be completed.
*/
@Injectable({ providedIn: 'root' })
export class OpenRewardTasksService {
#logger = logger(() => ({ service: 'OpenRewardTasksService' }));
#abholfachService = inject(AbholfachService);
/**
* Fetches open reward distribution tasks (unfinished Prämienausgabe orders).
*
* Returns reward orders that are:
* - In status 16 (InPreparation) or 128 (ReadyForPickup)
* - Flagged as reward items (praemie: "1-")
*
* Results are cached for 1 minute to balance freshness with performance.
* Cache is automatically invalidated on refresh.
*
* @param abortSignal Optional abort signal for request cancellation
* @returns Promise resolving to array of open reward tasks
* @throws ResponseArgsError if the API call fails
*/
async getOpenRewardTasks(
abortSignal?: AbortSignal,
): Promise<DBHOrderItemListItemDTO[]> {
this.#logger.debug('Fetching open reward tasks');
const payload: QueryTokenDTO = {
input: {},
filter: {
orderitemprocessingstatus: '16', // InPreparation(16) and ReadyForPickup(128)
praemie: '1-', // Reward items only
},
orderBy: [],
};
let req$ = this.#abholfachService.AbholfachWarenausgabe(payload);
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 open reward tasks', error);
throw error;
}
const tasks = res.result ?? [];
this.#logger.debug('Open reward tasks fetched', () => ({
taskCount: tasks.length,
}));
return tasks;
}
}

View File

@@ -0,0 +1,107 @@
import { inject, Injectable } from '@angular/core';
import {
OrderService,
OrderDTO,
DisplayOrderDTO,
} from '@generated/swagger/oms-api';
import { ResponseArgsError, takeUntilAborted } from '@isa/common/data-access';
import { logger } from '@isa/core/logging';
import { firstValueFrom } from 'rxjs';
@Injectable({ providedIn: 'root' })
export class OrdersService {
#logger = logger(() => ({ service: 'OrdersService' }));
#orderService = inject(OrderService);
/**
* Fetch a single order by its numeric ID
*/
async getOrder(
orderId: number,
abortSignal?: AbortSignal,
): Promise<OrderDTO | null> {
let req$ = this.#orderService.OrderGetOrder(orderId);
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 });
throw error;
}
return res.result ?? null;
}
/**
* Fetch multiple orders by their numeric IDs
* Uses Promise.all to fetch orders concurrently
*/
async getOrders(
orderIds: number[],
abortSignal?: AbortSignal,
): Promise<OrderDTO[]> {
if (!orderIds.length) {
return [];
}
const promises = orderIds.map((orderId) =>
this.getOrder(orderId, abortSignal),
);
const results = await Promise.all(promises);
// Filter out null results (failed fetches)
return results.filter((order): order is OrderDTO => order !== null);
}
/**
* Fetch a single display order by its numeric ID
*/
async getDisplayOrder(
orderId: number,
abortSignal?: AbortSignal,
): Promise<DisplayOrderDTO | null> {
let req$ = this.#orderService.OrderGetDisplayOrder(orderId);
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 });
throw error;
}
return res.result ?? null;
}
/**
* Fetch multiple display orders by their numeric IDs
* Uses Promise.all to fetch orders concurrently
*/
async getDisplayOrders(
orderIds: number[],
abortSignal?: AbortSignal,
): Promise<DisplayOrderDTO[]> {
if (!orderIds.length) {
return [];
}
const promises = orderIds.map((orderId) =>
this.getDisplayOrder(orderId, abortSignal),
);
const results = await Promise.all(promises);
// Filter out null results (failed fetches)
return results.filter((order): order is DisplayOrderDTO => !!order);
}
}

View File

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

View File

@@ -0,0 +1,34 @@
const nx = require('@nx/eslint-plugin');
const baseConfig = require('../../../eslint.config.js');
module.exports = [
...baseConfig,
...nx.configs['flat/angular'],
...nx.configs['flat/angular-template'],
{
files: ['**/*.ts'],
rules: {
'@angular-eslint/directive-selector': [
'error',
{
type: 'attribute',
prefix: 'lib',
style: 'camelCase',
},
],
'@angular-eslint/component-selector': [
'error',
{
type: 'element',
prefix: 'lib',
style: 'kebab-case',
},
],
},
},
{
files: ['**/*.html'],
// Override or add rules here
rules: {},
},
];

View File

@@ -0,0 +1,20 @@
{
"name": "shared-delivery",
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/shared/delivery/src",
"prefix": "lib",
"projectType": "library",
"tags": [],
"targets": {
"test": {
"executor": "@nx/vite:test",
"outputs": ["{options.reportsDirectory}"],
"options": {
"reportsDirectory": "../../../coverage/libs/shared/delivery"
}
},
"lint": {
"executor": "@nx/eslint:lint"
}
}
}

View File

@@ -0,0 +1 @@
export * from './lib/order-destination/order-destination.component';

View File

@@ -0,0 +1,7 @@
:host {
@apply flex flex-col items-start gap-2 flex-grow;
}
.address-container {
@apply line-clamp-2 break-words text-ellipsis;
}

View File

@@ -0,0 +1,34 @@
<div
class="flex items-center gap-2 self-stretch"
data-what="order-type-indicator"
role="status"
[attr.aria-label]="'Order type: ' + orderType()">
<ng-icon
[name]="destinationIcon()"
size="1.5rem"
class="text-neutral-900"
[attr.aria-hidden]="true"></ng-icon>
<span class="isa-text-body-2-bold text-isa-secondary-900" [class.underline]="underline()">{{
orderType()
}}</span>
</div>
<div
class="text-isa-neutral-600 isa-text-body-2-regular address-container"
data-what="destination-details"
role="region"
aria-label="Destination information">
@if (name() || address()) {
<span data-what="recipient-name">{{ name() }}</span> |
<shared-inline-address
[address]="address()"
data-what="destination-address"></shared-inline-address>
} @else if (estimatedDelivery()) {
<span data-what="estimated-delivery">
@if (estimatedDelivery()!.stop) {
Zustellung zwischen {{ estimatedDelivery()!.start | date: 'EEE, dd.MM.' }} und {{ estimatedDelivery()!.stop | date: 'EEE, dd.MM.' }}
} @else if (estimatedDelivery()!.start) {
Zustellung am {{ estimatedDelivery()!.start | date: 'EEE, dd.MM.' }}
}
</span>
}
</div>

View File

@@ -0,0 +1,97 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting } from '@angular/common/http/testing';
import { OrderDestinationComponent } from './order-destination.component';
import { OrderType } from '@isa/common/data-access';
describe('OrderDestinationComponent', () => {
let component: OrderDestinationComponent;
let fixture: ComponentFixture<OrderDestinationComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [OrderDestinationComponent],
providers: [provideHttpClient(), provideHttpClientTesting()],
}).compileComponents();
fixture = TestBed.createComponent(OrderDestinationComponent);
component = fixture.componentInstance;
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should display delivery icon for delivery order type', () => {
fixture.componentRef.setInput('orderType', OrderType.Delivery);
fixture.detectChanges();
expect(component.destinationIcon()).toBe('isaDeliveryVersand');
});
it('should display pickup icon for pickup order type', () => {
fixture.componentRef.setInput('orderType', OrderType.Pickup);
fixture.detectChanges();
expect(component.destinationIcon()).toBe('isaDeliveryRuecklage2');
});
it('should display in-store icon for in-store order type', () => {
fixture.componentRef.setInput('orderType', OrderType.InStore);
fixture.detectChanges();
expect(component.destinationIcon()).toBe('isaDeliveryRuecklage1');
});
it('should display branch name when branch is provided', () => {
fixture.componentRef.setInput('orderType', OrderType.Pickup);
fixture.componentRef.setInput('branch', { name: 'Test Branch' });
fixture.detectChanges();
expect(component.name()).toBe('Test Branch');
});
it('should display shipping address name for delivery', () => {
fixture.componentRef.setInput('orderType', OrderType.Delivery);
fixture.componentRef.setInput('shippingAddress', {
firstName: 'John',
lastName: 'Doe',
});
fixture.detectChanges();
expect(component.name()).toBe('John Doe');
});
it('should return shipping address for delivery orders', () => {
const testAddress = {
street: 'Test St',
city: 'Vienna',
zipCode: '1010',
};
fixture.componentRef.setInput('orderType', OrderType.Delivery);
fixture.componentRef.setInput('shippingAddress', {
firstName: 'John',
lastName: 'Doe',
address: testAddress,
});
fixture.detectChanges();
expect(component.address()).toBe(testAddress);
});
it('should return branch address for pickup orders', () => {
const testAddress = {
street: 'Branch St',
city: 'Vienna',
zipCode: '1020',
};
fixture.componentRef.setInput('orderType', OrderType.Pickup);
fixture.componentRef.setInput('branch', {
name: 'Test Branch',
address: testAddress,
});
fixture.detectChanges();
expect(component.address()).toBe(testAddress);
});
});

View File

@@ -0,0 +1,119 @@
import { Component, computed, input } from '@angular/core';
import { coerceBooleanProperty } from '@angular/cdk/coercion';
import { DatePipe } from '@angular/common';
import { NgIcon, provideIcons } from '@ng-icons/core';
import {
isaDeliveryVersand,
isaDeliveryRuecklage2,
isaDeliveryRuecklage1,
} from '@isa/icons';
import { InlineAddressComponent } from '@isa/shared/address';
import { logger } from '@isa/core/logging';
import { OrderTypeFeature } from '@isa/common/data-access';
export type Address = {
apartment?: string;
careOf?: string;
city?: string;
country?: string;
district?: string;
info?: string;
po?: string;
region?: string;
state?: string;
street?: string;
streetNumber?: string;
zipCode?: string;
};
export type Branch = {
name?: string;
address?: Address;
};
export type ShippingAddress = {
firstName?: string;
lastName?: string;
address?: Address;
};
export type EstimatedDelivery = {
start: string;
stop: string | null;
};
@Component({
selector: 'shared-order-destination',
templateUrl: './order-destination.component.html',
styleUrls: ['./order-destination.component.css'],
imports: [NgIcon, InlineAddressComponent, DatePipe],
providers: [
provideIcons({
isaDeliveryVersand,
isaDeliveryRuecklage2,
isaDeliveryRuecklage1,
}),
],
})
export class OrderDestinationComponent {
#logger = logger({ component: 'OrderDestinationComponent' });
underline = input<boolean, unknown>(false, {
transform: coerceBooleanProperty,
});
branch = input<Branch>();
shippingAddress = input<ShippingAddress>();
orderType = input.required<OrderTypeFeature>();
estimatedDelivery = input<EstimatedDelivery | null>();
destinationIcon = computed(() => {
const orderType = this.orderType();
this.#logger.debug('Computing destination icon', () => ({ orderType }));
if (OrderTypeFeature.Delivery === orderType) {
return 'isaDeliveryVersand';
}
if (OrderTypeFeature.Pickup === orderType) {
return 'isaDeliveryRuecklage2';
}
if (OrderTypeFeature.InStore === orderType) {
return 'isaDeliveryRuecklage1';
}
return 'isaDeliveryVersand';
});
name = computed(() => {
const orderType = this.orderType();
if (
OrderTypeFeature.Delivery === orderType ||
OrderTypeFeature.B2BShipping === orderType ||
OrderTypeFeature.DigitalShipping === orderType
) {
const address = this.shippingAddress();
return `${address?.firstName || ''} ${address?.lastName || ''}`.trim();
}
const branch = this.branch();
return branch?.name || 'Filiale nicht gefunden';
});
address = computed(() => {
const orderType = this.orderType();
if (
OrderTypeFeature.Delivery === orderType ||
OrderTypeFeature.B2BShipping === orderType ||
OrderTypeFeature.DigitalShipping === orderType
) {
const shipping = this.shippingAddress();
return shipping?.address;
}
const branch = this.branch();
return branch?.address;
});
}

View File

@@ -0,0 +1,13 @@
import '@angular/compiler';
import '@analogjs/vitest-angular/setup-zone';
import {
BrowserTestingModule,
platformBrowserTesting,
} from '@angular/platform-browser/testing';
import { getTestBed } from '@angular/core/testing';
getTestBed().initTestEnvironment(
BrowserTestingModule,
platformBrowserTesting(),
);

View File

@@ -0,0 +1,30 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"importHelpers": true,
"moduleResolution": "bundler",
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"module": "preserve"
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"typeCheckHostBindings": true,
"strictTemplates": true
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}

View File

@@ -0,0 +1,27 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../dist/out-tsc",
"declaration": true,
"declarationMap": true,
"inlineSources": true,
"types": []
},
"exclude": [
"src/**/*.spec.ts",
"src/test-setup.ts",
"jest.config.ts",
"src/**/*.test.ts",
"vite.config.ts",
"vite.config.mts",
"vitest.config.ts",
"vitest.config.mts",
"src/**/*.test.tsx",
"src/**/*.spec.tsx",
"src/**/*.test.js",
"src/**/*.spec.js",
"src/**/*.test.jsx",
"src/**/*.spec.jsx"
],
"include": ["src/**/*.ts"]
}

View File

@@ -0,0 +1,29 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../dist/out-tsc",
"types": [
"vitest/globals",
"vitest/importMeta",
"vite/client",
"node",
"vitest"
]
},
"include": [
"vite.config.ts",
"vite.config.mts",
"vitest.config.ts",
"vitest.config.mts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.test.tsx",
"src/**/*.spec.tsx",
"src/**/*.test.js",
"src/**/*.spec.js",
"src/**/*.test.jsx",
"src/**/*.spec.jsx",
"src/**/*.d.ts"
],
"files": ["src/test-setup.ts"]
}

View File

@@ -0,0 +1,33 @@
/// <reference types='vitest' />
import { defineConfig } from 'vite';
import angular from '@analogjs/vite-plugin-angular';
import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin';
import { nxCopyAssetsPlugin } from '@nx/vite/plugins/nx-copy-assets.plugin';
export default
// @ts-expect-error - Vitest reporter tuple types have complex inference issues
defineConfig(() => ({
root: __dirname,
cacheDir: '../../../node_modules/.vite/libs/shared/delivery',
plugins: [angular(), nxViteTsPaths(), nxCopyAssetsPlugin(['*.md'])],
// Uncomment this if you are using workers.
// worker: {
// plugins: [ nxViteTsPaths() ],
// },
test: {
watch: false,
globals: true,
environment: 'jsdom',
include: ['{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
setupFiles: ['src/test-setup.ts'],
reporters: [
'default',
['junit', { outputFile: '../../../testresults/junit-shared-delivery.xml' }],
],
coverage: {
reportsDirectory: '../../../coverage/libs/shared/delivery',
provider: 'v8' as const,
reporter: ['text', 'cobertura'],
},
},
}));