mirror of
https://dev.azure.com/hugendubel/ISA/_git/ISA-Frontend
synced 2025-12-31 09:37:15 +01:00
committed by
Nino Righi
parent
1d4c900d3a
commit
89b3d9aa60
@@ -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,
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
export * from './availability-type';
|
||||
export * from './availability';
|
||||
export * from './order-type';
|
||||
export * from './order-type-feature';
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { OrderTypeFeature } from '@isa/common/data-access';
|
||||
@@ -1 +0,0 @@
|
||||
export { OrderType } from '@isa/common/data-access';
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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[]>());
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { OrderTypeFeature } from '@isa/common/data-access';
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,5 +30,3 @@ export const DestinationSchema = z.object({
|
||||
.describe('Target branch')
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export type Destination = z.infer<typeof DestinationSchema>;
|
||||
|
||||
@@ -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],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 ?? '€',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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()];
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
<reward-catalog-open-tasks-carousel></reward-catalog-open-tasks-carousel>
|
||||
<reward-header></reward-header>
|
||||
<filter-controls-panel
|
||||
[switchFilters]="displayStockFilterSwitch()"
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 ?? []);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
});
|
||||
}),
|
||||
|
||||
@@ -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 ?? [],
|
||||
],
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
:host {
|
||||
@apply contents;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<shared-order-destination
|
||||
[underline]="underline()"
|
||||
[branch]="mappedBranch()"
|
||||
[shippingAddress]="mappedShippingAddress()"
|
||||
[orderType]="orderType()"
|
||||
[estimatedDelivery]="estimatedDelivery()">
|
||||
</shared-order-destination>
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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];
|
||||
5
libs/common/data-access/src/lib/models/product.ts
Normal file
5
libs/common/data-access/src/lib/models/product.ts
Normal 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;
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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';
|
||||
|
||||
14
libs/common/data-access/src/lib/schemas/order-type.schema.ts
Normal file
14
libs/common/data-access/src/lib/schemas/order-type.schema.ts
Normal 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>;
|
||||
@@ -25,6 +25,9 @@ export const tabResolverFn: ResolveFn<Tab> = (route) => {
|
||||
tab = tabService.addTab({
|
||||
id: tabId,
|
||||
name: 'Neuer Vorgang',
|
||||
metadata: {
|
||||
process_type: 'cart',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ export class CustomerShippingAddressResource {
|
||||
});
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class SelectedCustomerShippingAddressResource extends CustomerShippingAddressResource {
|
||||
#tabId = inject(TabService).activatedTabId;
|
||||
#customerMetadata = inject(CrmTabMetadataService);
|
||||
|
||||
@@ -48,7 +48,7 @@ export class CustomerShippingAddressesResource {
|
||||
});
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class SelectedCustomerShippingAddressesResource extends CustomerShippingAddressesResource {
|
||||
#tabId = inject(TabService).activatedTabId;
|
||||
#customerMetadata = inject(CrmTabMetadataService);
|
||||
|
||||
@@ -36,7 +36,7 @@ export class CustomerResource {
|
||||
});
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class SelectedCustomerResource extends CustomerResource {
|
||||
#tabId = inject(TabService).activatedTabId;
|
||||
#customerMetadata = inject(CrmTabMetadataService);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -1 +1,4 @@
|
||||
export * from './display-orders.resource';
|
||||
export * from './open-reward-tasks.resource';
|
||||
export * from './order-item-subset.resource';
|
||||
export * from './orders.resource';
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
92
libs/oms/data-access/src/lib/resources/orders.resource.ts
Normal file
92
libs/oms/data-access/src/lib/resources/orders.resource.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
107
libs/oms/data-access/src/lib/services/orders.service.ts
Normal file
107
libs/oms/data-access/src/lib/services/orders.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
7
libs/shared/delivery/README.md
Normal file
7
libs/shared/delivery/README.md
Normal 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.
|
||||
34
libs/shared/delivery/eslint.config.cjs
Normal file
34
libs/shared/delivery/eslint.config.cjs
Normal 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: {},
|
||||
},
|
||||
];
|
||||
20
libs/shared/delivery/project.json
Normal file
20
libs/shared/delivery/project.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
1
libs/shared/delivery/src/index.ts
Normal file
1
libs/shared/delivery/src/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './lib/order-destination/order-destination.component';
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
13
libs/shared/delivery/src/test-setup.ts
Normal file
13
libs/shared/delivery/src/test-setup.ts
Normal 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(),
|
||||
);
|
||||
30
libs/shared/delivery/tsconfig.json
Normal file
30
libs/shared/delivery/tsconfig.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
27
libs/shared/delivery/tsconfig.lib.json
Normal file
27
libs/shared/delivery/tsconfig.lib.json
Normal 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"]
|
||||
}
|
||||
29
libs/shared/delivery/tsconfig.spec.json
Normal file
29
libs/shared/delivery/tsconfig.spec.json
Normal 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"]
|
||||
}
|
||||
33
libs/shared/delivery/vite.config.mts
Normal file
33
libs/shared/delivery/vite.config.mts
Normal 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'],
|
||||
},
|
||||
},
|
||||
}));
|
||||
Reference in New Issue
Block a user