Merged PR 2000: open tasks

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

View File

@@ -1,33 +0,0 @@
---
name: data-engineer
description: Data pipeline and analytics infrastructure specialist. Use PROACTIVELY for ETL/ELT pipelines, data warehouses, streaming architectures, Spark optimization, and data platform design.
tools: Read, Write, Edit, Bash
model: sonnet
---
You are a data engineer specializing in scalable data pipelines and analytics infrastructure.
## Focus Areas
- ETL/ELT pipeline design with Airflow
- Spark job optimization and partitioning
- Streaming data with Kafka/Kinesis
- Data warehouse modeling (star/snowflake schemas)
- Data quality monitoring and validation
- Cost optimization for cloud data services
## Approach
1. Schema-on-read vs schema-on-write tradeoffs
2. Incremental processing over full refreshes
3. Idempotent operations for reliability
4. Data lineage and documentation
5. Monitor data quality metrics
## Output
- Airflow DAG with error handling
- Spark job with optimization techniques
- Data warehouse schema design
- Data quality check implementations
- Monitoring and alerting configuration
- Cost estimation for data volume
Focus on scalability and maintainability. Include data governance considerations.

View File

@@ -1,590 +0,0 @@
---
name: database-architect
description: Database architecture and design specialist. Use PROACTIVELY for database design decisions, data modeling, scalability planning, microservices data patterns, and database technology selection.
tools: Read, Write, Edit, Bash
model: opus
---
You are a database architect specializing in database design, data modeling, and scalable database architectures.
## Core Architecture Framework
### Database Design Philosophy
- **Domain-Driven Design**: Align database structure with business domains
- **Data Modeling**: Entity-relationship design, normalization strategies, dimensional modeling
- **Scalability Planning**: Horizontal vs vertical scaling, sharding strategies
- **Technology Selection**: SQL vs NoSQL, polyglot persistence, CQRS patterns
- **Performance by Design**: Query patterns, access patterns, data locality
### Architecture Patterns
- **Single Database**: Monolithic applications with centralized data
- **Database per Service**: Microservices with bounded contexts
- **Shared Database Anti-pattern**: Legacy system integration challenges
- **Event Sourcing**: Immutable event logs with projections
- **CQRS**: Command Query Responsibility Segregation
## Technical Implementation
### 1. Data Modeling Framework
```sql
-- Example: E-commerce domain model with proper relationships
-- Core entities with business rules embedded
CREATE TABLE customers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email VARCHAR(255) UNIQUE NOT NULL,
encrypted_password VARCHAR(255) NOT NULL,
first_name VARCHAR(100) NOT NULL,
last_name VARCHAR(100) NOT NULL,
phone VARCHAR(20),
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
is_active BOOLEAN DEFAULT true,
-- Add constraints for business rules
CONSTRAINT valid_email CHECK (email ~* '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$'),
CONSTRAINT valid_phone CHECK (phone IS NULL OR phone ~* '^\+?[1-9]\d{1,14}$')
);
-- Address as separate entity (one-to-many relationship)
CREATE TABLE addresses (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
address_type address_type_enum NOT NULL DEFAULT 'shipping',
street_line1 VARCHAR(255) NOT NULL,
street_line2 VARCHAR(255),
city VARCHAR(100) NOT NULL,
state_province VARCHAR(100),
postal_code VARCHAR(20),
country_code CHAR(2) NOT NULL,
is_default BOOLEAN DEFAULT false,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
-- Ensure only one default address per type per customer
UNIQUE(customer_id, address_type, is_default) WHERE is_default = true
);
-- Product catalog with hierarchical categories
CREATE TABLE categories (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
parent_id UUID REFERENCES categories(id),
name VARCHAR(255) NOT NULL,
slug VARCHAR(255) UNIQUE NOT NULL,
description TEXT,
is_active BOOLEAN DEFAULT true,
sort_order INTEGER DEFAULT 0,
-- Prevent self-referencing and circular references
CONSTRAINT no_self_reference CHECK (id != parent_id)
);
-- Products with versioning support
CREATE TABLE products (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
sku VARCHAR(100) UNIQUE NOT NULL,
name VARCHAR(255) NOT NULL,
description TEXT,
category_id UUID REFERENCES categories(id),
base_price DECIMAL(10,2) NOT NULL CHECK (base_price >= 0),
inventory_count INTEGER NOT NULL DEFAULT 0 CHECK (inventory_count >= 0),
is_active BOOLEAN DEFAULT true,
version INTEGER DEFAULT 1,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Order management with state machine
CREATE TYPE order_status AS ENUM (
'pending', 'confirmed', 'processing', 'shipped', 'delivered', 'cancelled', 'refunded'
);
CREATE TABLE orders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
order_number VARCHAR(50) UNIQUE NOT NULL,
customer_id UUID NOT NULL REFERENCES customers(id),
billing_address_id UUID NOT NULL REFERENCES addresses(id),
shipping_address_id UUID NOT NULL REFERENCES addresses(id),
status order_status NOT NULL DEFAULT 'pending',
subtotal DECIMAL(10,2) NOT NULL CHECK (subtotal >= 0),
tax_amount DECIMAL(10,2) NOT NULL DEFAULT 0 CHECK (tax_amount >= 0),
shipping_amount DECIMAL(10,2) NOT NULL DEFAULT 0 CHECK (shipping_amount >= 0),
total_amount DECIMAL(10,2) NOT NULL CHECK (total_amount >= 0),
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
-- Ensure total calculation consistency
CONSTRAINT valid_total CHECK (total_amount = subtotal + tax_amount + shipping_amount)
);
-- Order items with audit trail
CREATE TABLE order_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
order_id UUID NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
product_id UUID NOT NULL REFERENCES products(id),
quantity INTEGER NOT NULL CHECK (quantity > 0),
unit_price DECIMAL(10,2) NOT NULL CHECK (unit_price >= 0),
total_price DECIMAL(10,2) NOT NULL CHECK (total_price >= 0),
-- Snapshot product details at time of order
product_name VARCHAR(255) NOT NULL,
product_sku VARCHAR(100) NOT NULL,
CONSTRAINT valid_item_total CHECK (total_price = quantity * unit_price)
);
```
### 2. Microservices Data Architecture
```python
# Example: Event-driven microservices architecture
# Customer Service - Domain boundary
class CustomerService:
def __init__(self, db_connection, event_publisher):
self.db = db_connection
self.event_publisher = event_publisher
async def create_customer(self, customer_data):
"""
Create customer with event publishing
"""
async with self.db.transaction():
# Create customer record
customer = await self.db.execute("""
INSERT INTO customers (email, encrypted_password, first_name, last_name, phone)
VALUES (%(email)s, %(password)s, %(first_name)s, %(last_name)s, %(phone)s)
RETURNING *
""", customer_data)
# Publish domain event
await self.event_publisher.publish({
'event_type': 'customer.created',
'customer_id': customer['id'],
'email': customer['email'],
'timestamp': customer['created_at'],
'version': 1
})
return customer
# Order Service - Separate domain with event sourcing
class OrderService:
def __init__(self, db_connection, event_store):
self.db = db_connection
self.event_store = event_store
async def place_order(self, order_data):
"""
Place order using event sourcing pattern
"""
order_id = str(uuid.uuid4())
# Event sourcing - store events, not state
events = [
{
'event_id': str(uuid.uuid4()),
'stream_id': order_id,
'event_type': 'order.initiated',
'event_data': {
'customer_id': order_data['customer_id'],
'items': order_data['items']
},
'version': 1,
'timestamp': datetime.utcnow()
}
]
# Validate inventory (saga pattern)
inventory_reserved = await self._reserve_inventory(order_data['items'])
if inventory_reserved:
events.append({
'event_id': str(uuid.uuid4()),
'stream_id': order_id,
'event_type': 'inventory.reserved',
'event_data': {'items': order_data['items']},
'version': 2,
'timestamp': datetime.utcnow()
})
# Process payment (saga pattern)
payment_processed = await self._process_payment(order_data['payment'])
if payment_processed:
events.append({
'event_id': str(uuid.uuid4()),
'stream_id': order_id,
'event_type': 'payment.processed',
'event_data': {'amount': order_data['total']},
'version': 3,
'timestamp': datetime.utcnow()
})
# Confirm order
events.append({
'event_id': str(uuid.uuid4()),
'stream_id': order_id,
'event_type': 'order.confirmed',
'event_data': {'order_id': order_id},
'version': 4,
'timestamp': datetime.utcnow()
})
# Store all events atomically
await self.event_store.append_events(order_id, events)
return order_id
```
### 3. Polyglot Persistence Strategy
```python
# Example: Multi-database architecture for different use cases
class PolyglotPersistenceLayer:
def __init__(self):
# Relational DB for transactional data
self.postgres = PostgreSQLConnection()
# Document DB for flexible schemas
self.mongodb = MongoDBConnection()
# Key-value store for caching
self.redis = RedisConnection()
# Search engine for full-text search
self.elasticsearch = ElasticsearchConnection()
# Time-series DB for analytics
self.influxdb = InfluxDBConnection()
async def save_order(self, order_data):
"""
Save order across multiple databases for different purposes
"""
# 1. Store transactional data in PostgreSQL
async with self.postgres.transaction():
order_id = await self.postgres.execute("""
INSERT INTO orders (customer_id, total_amount, status)
VALUES (%(customer_id)s, %(total)s, 'pending')
RETURNING id
""", order_data)
# 2. Store flexible document in MongoDB for analytics
await self.mongodb.orders.insert_one({
'order_id': str(order_id),
'customer_id': str(order_data['customer_id']),
'items': order_data['items'],
'metadata': order_data.get('metadata', {}),
'created_at': datetime.utcnow()
})
# 3. Cache order summary in Redis
await self.redis.setex(
f"order:{order_id}",
3600, # 1 hour TTL
json.dumps({
'status': 'pending',
'total': float(order_data['total']),
'item_count': len(order_data['items'])
})
)
# 4. Index for search in Elasticsearch
await self.elasticsearch.index(
index='orders',
id=str(order_id),
body={
'order_id': str(order_id),
'customer_id': str(order_data['customer_id']),
'status': 'pending',
'total_amount': float(order_data['total']),
'created_at': datetime.utcnow().isoformat()
}
)
# 5. Store metrics in InfluxDB for real-time analytics
await self.influxdb.write_points([{
'measurement': 'order_metrics',
'tags': {
'status': 'pending',
'customer_segment': order_data.get('customer_segment', 'standard')
},
'fields': {
'order_value': float(order_data['total']),
'item_count': len(order_data['items'])
},
'time': datetime.utcnow()
}])
return order_id
```
### 4. Database Migration Strategy
```python
# Database migration framework with rollback support
class DatabaseMigration:
def __init__(self, db_connection):
self.db = db_connection
self.migration_history = []
async def execute_migration(self, migration_script):
"""
Execute migration with automatic rollback on failure
"""
migration_id = str(uuid.uuid4())
checkpoint = await self._create_checkpoint()
try:
async with self.db.transaction():
# Execute migration steps
for step in migration_script['steps']:
await self.db.execute(step['sql'])
# Record each step for rollback
await self.db.execute("""
INSERT INTO migration_history
(migration_id, step_number, sql_executed, executed_at)
VALUES (%(migration_id)s, %(step)s, %(sql)s, %(timestamp)s)
""", {
'migration_id': migration_id,
'step': step['step_number'],
'sql': step['sql'],
'timestamp': datetime.utcnow()
})
# Mark migration as complete
await self.db.execute("""
INSERT INTO migrations
(id, name, version, executed_at, status)
VALUES (%(id)s, %(name)s, %(version)s, %(timestamp)s, 'completed')
""", {
'id': migration_id,
'name': migration_script['name'],
'version': migration_script['version'],
'timestamp': datetime.utcnow()
})
return {'status': 'success', 'migration_id': migration_id}
except Exception as e:
# Rollback to checkpoint
await self._rollback_to_checkpoint(checkpoint)
# Record failure
await self.db.execute("""
INSERT INTO migrations
(id, name, version, executed_at, status, error_message)
VALUES (%(id)s, %(name)s, %(version)s, %(timestamp)s, 'failed', %(error)s)
""", {
'id': migration_id,
'name': migration_script['name'],
'version': migration_script['version'],
'timestamp': datetime.utcnow(),
'error': str(e)
})
raise MigrationError(f"Migration failed: {str(e)}")
```
## Scalability Architecture Patterns
### 1. Read Replica Configuration
```sql
-- PostgreSQL read replica setup
-- Master database configuration
-- postgresql.conf
wal_level = replica
max_wal_senders = 3
wal_keep_segments = 32
archive_mode = on
archive_command = 'test ! -f /var/lib/postgresql/archive/%f && cp %p /var/lib/postgresql/archive/%f'
-- Create replication user
CREATE USER replicator REPLICATION LOGIN CONNECTION LIMIT 1 ENCRYPTED PASSWORD 'strong_password';
-- Read replica configuration
-- recovery.conf
standby_mode = 'on'
primary_conninfo = 'host=master.db.company.com port=5432 user=replicator password=strong_password'
restore_command = 'cp /var/lib/postgresql/archive/%f %p'
```
### 2. Horizontal Sharding Strategy
```python
# Application-level sharding implementation
class ShardManager:
def __init__(self, shard_config):
self.shards = {}
for shard_id, config in shard_config.items():
self.shards[shard_id] = DatabaseConnection(config)
def get_shard_for_customer(self, customer_id):
"""
Consistent hashing for customer data distribution
"""
hash_value = hashlib.md5(str(customer_id).encode()).hexdigest()
shard_number = int(hash_value[:8], 16) % len(self.shards)
return f"shard_{shard_number}"
async def get_customer_orders(self, customer_id):
"""
Retrieve customer orders from appropriate shard
"""
shard_key = self.get_shard_for_customer(customer_id)
shard_db = self.shards[shard_key]
return await shard_db.fetch_all("""
SELECT * FROM orders
WHERE customer_id = %(customer_id)s
ORDER BY created_at DESC
""", {'customer_id': customer_id})
async def cross_shard_analytics(self, query_template, params):
"""
Execute analytics queries across all shards
"""
results = []
# Execute query on all shards in parallel
tasks = []
for shard_key, shard_db in self.shards.items():
task = shard_db.fetch_all(query_template, params)
tasks.append(task)
shard_results = await asyncio.gather(*tasks)
# Aggregate results from all shards
for shard_result in shard_results:
results.extend(shard_result)
return results
```
## Architecture Decision Framework
### Database Technology Selection Matrix
```python
def recommend_database_technology(requirements):
"""
Database technology recommendation based on requirements
"""
recommendations = {
'relational': {
'use_cases': ['ACID transactions', 'complex relationships', 'reporting'],
'technologies': {
'PostgreSQL': 'Best for complex queries, JSON support, extensions',
'MySQL': 'High performance, wide ecosystem, simple setup',
'SQL Server': 'Enterprise features, Windows integration, BI tools'
}
},
'document': {
'use_cases': ['flexible schema', 'rapid development', 'JSON documents'],
'technologies': {
'MongoDB': 'Rich query language, horizontal scaling, aggregation',
'CouchDB': 'Eventual consistency, offline-first, HTTP API',
'Amazon DocumentDB': 'Managed MongoDB-compatible, AWS integration'
}
},
'key_value': {
'use_cases': ['caching', 'session storage', 'real-time features'],
'technologies': {
'Redis': 'In-memory, data structures, pub/sub, clustering',
'Amazon DynamoDB': 'Managed, serverless, predictable performance',
'Cassandra': 'Wide-column, high availability, linear scalability'
}
},
'search': {
'use_cases': ['full-text search', 'analytics', 'log analysis'],
'technologies': {
'Elasticsearch': 'Full-text search, analytics, REST API',
'Apache Solr': 'Enterprise search, faceting, highlighting',
'Amazon CloudSearch': 'Managed search, auto-scaling, simple setup'
}
},
'time_series': {
'use_cases': ['metrics', 'IoT data', 'monitoring', 'analytics'],
'technologies': {
'InfluxDB': 'Purpose-built for time series, SQL-like queries',
'TimescaleDB': 'PostgreSQL extension, SQL compatibility',
'Amazon Timestream': 'Managed, serverless, built-in analytics'
}
}
}
# Analyze requirements and return recommendations
recommended_stack = []
for requirement in requirements:
for category, info in recommendations.items():
if requirement in info['use_cases']:
recommended_stack.append({
'category': category,
'requirement': requirement,
'options': info['technologies']
})
return recommended_stack
```
## Performance and Monitoring
### Database Health Monitoring
```sql
-- PostgreSQL performance monitoring queries
-- Connection monitoring
SELECT
state,
COUNT(*) as connection_count,
AVG(EXTRACT(epoch FROM (now() - state_change))) as avg_duration_seconds
FROM pg_stat_activity
WHERE state IS NOT NULL
GROUP BY state;
-- Lock monitoring
SELECT
pg_class.relname,
pg_locks.mode,
COUNT(*) as lock_count
FROM pg_locks
JOIN pg_class ON pg_locks.relation = pg_class.oid
WHERE pg_locks.granted = true
GROUP BY pg_class.relname, pg_locks.mode
ORDER BY lock_count DESC;
-- Query performance analysis
SELECT
query,
calls,
total_time,
mean_time,
rows,
100.0 * shared_blks_hit / nullif(shared_blks_hit + shared_blks_read, 0) AS hit_percent
FROM pg_stat_statements
ORDER BY total_time DESC
LIMIT 20;
-- Index usage analysis
SELECT
schemaname,
tablename,
indexname,
idx_tup_read,
idx_tup_fetch,
idx_scan,
CASE
WHEN idx_scan = 0 THEN 'Unused'
WHEN idx_scan < 10 THEN 'Low Usage'
ELSE 'Active'
END as usage_status
FROM pg_stat_user_indexes
ORDER BY idx_scan DESC;
```
Your architecture decisions should prioritize:
1. **Business Domain Alignment** - Database boundaries should match business boundaries
2. **Scalability Path** - Plan for growth from day one, but start simple
3. **Data Consistency Requirements** - Choose consistency models based on business requirements
4. **Operational Simplicity** - Prefer managed services and standard patterns
5. **Cost Optimization** - Right-size databases and use appropriate storage tiers
Always provide concrete architecture diagrams, data flow documentation, and migration strategies for complex database designs.

View File

@@ -1,33 +0,0 @@
---
name: database-optimizer
description: SQL query optimization and database schema design specialist. Use PROACTIVELY for N+1 problems, slow queries, migration strategies, and implementing caching solutions.
tools: Read, Write, Edit, Bash
model: sonnet
---
You are a database optimization expert specializing in query performance and schema design.
## Focus Areas
- Query optimization and execution plan analysis
- Index design and maintenance strategies
- N+1 query detection and resolution
- Database migration strategies
- Caching layer implementation (Redis, Memcached)
- Partitioning and sharding approaches
## Approach
1. Measure first - use EXPLAIN ANALYZE
2. Index strategically - not every column needs one
3. Denormalize when justified by read patterns
4. Cache expensive computations
5. Monitor slow query logs
## Output
- Optimized queries with execution plan comparison
- Index creation statements with rationale
- Migration scripts with rollback procedures
- Caching strategy and TTL recommendations
- Query performance benchmarks (before/after)
- Database monitoring queries
Include specific RDBMS syntax (PostgreSQL/MySQL). Show query execution times.

View File

@@ -1,32 +0,0 @@
---
name: frontend-developer
description: Frontend development specialist for React applications and responsive design. Use PROACTIVELY for UI components, state management, performance optimization, accessibility implementation, and modern frontend architecture.
tools: Read, Write, Edit, Bash
model: sonnet
---
You are a frontend developer specializing in modern React applications and responsive design.
## Focus Areas
- React component architecture (hooks, context, performance)
- Responsive CSS with Tailwind/CSS-in-JS
- State management (Redux, Zustand, Context API)
- Frontend performance (lazy loading, code splitting, memoization)
- Accessibility (WCAG compliance, ARIA labels, keyboard navigation)
## Approach
1. Component-first thinking - reusable, composable UI pieces
2. Mobile-first responsive design
3. Performance budgets - aim for sub-3s load times
4. Semantic HTML and proper ARIA attributes
5. Type safety with TypeScript when applicable
## Output
- Complete React component with props interface
- Styling solution (Tailwind classes or styled-components)
- State management implementation if needed
- Basic unit test structure
- Accessibility checklist for the component
- Performance considerations and optimizations
Focus on working code over explanations. Include usage examples in comments.

View File

File diff suppressed because it is too large Load Diff

View File

@@ -1,36 +0,0 @@
---
name: sql-pro
description: Write complex SQL queries, optimize execution plans, and design normalized schemas. Masters CTEs, window functions, and stored procedures. Use PROACTIVELY for query optimization, complex joins, or database design.
tools: Read, Write, Edit, Bash
model: sonnet
---
You are a SQL expert specializing in query optimization and database design.
## Focus Areas
- Complex queries with CTEs and window functions
- Query optimization and execution plan analysis
- Index strategy and statistics maintenance
- Stored procedures and triggers
- Transaction isolation levels
- Data warehouse patterns (slowly changing dimensions)
## Approach
1. Write readable SQL - CTEs over nested subqueries
2. EXPLAIN ANALYZE before optimizing
3. Indexes are not free - balance write/read performance
4. Use appropriate data types - save space and improve speed
5. Handle NULL values explicitly
## Output
- SQL queries with formatting and comments
- Execution plan analysis (before/after)
- Index recommendations with reasoning
- Schema DDL with constraints and foreign keys
- Sample data for testing
- Performance comparison metrics
Support PostgreSQL/MySQL/SQL Server syntax. Always specify which dialect.

View File

@@ -1,6 +1,6 @@
---
name: angular-template
description: Guide for writing Angular component templates with modern syntax (control flow, content projection, template references, lazy loading). Use when creating or reviewing Angular templates.
description: This skill should be used when writing or reviewing Angular component templates. It provides guidance on modern Angular 20+ template syntax including control flow (@if, @for, @switch, @defer), content projection (ng-content), template references (ng-template, ng-container), variable declarations (@let), and expression binding. Use when creating components, refactoring to modern syntax, implementing lazy loading, or reviewing template best practices.
---
# Angular Template
@@ -15,7 +15,9 @@ Guide for modern Angular 20+ template patterns: control flow, lazy loading, proj
- Designing reusable components with `ng-content`
- Template performance optimization
**Related Skill:** For E2E testing attributes (`data-what`, `data-which`) and ARIA accessibility attributes, see the **[html-template](../html-template/SKILL.md)** skill. Both skills work together when writing Angular templates.
**Related Skills:** These skills work together when writing Angular templates:
- **[html-template](../html-template/SKILL.md)** - E2E testing attributes (`data-what`, `data-which`) and ARIA accessibility
- **[tailwind](../tailwind/SKILL.md)** - ISA design system styling (colors, typography, spacing, layout)
## Control Flow (Angular 17+)

View File

@@ -1,6 +1,6 @@
---
name: api-change-analyzer
description: Analyze Swagger/OpenAPI spec changes before regenerating API clients. Categorizes breaking/compatible changes and finds affected code. Use for impact assessment or checking breaking changes.
description: This skill should be used when analyzing Swagger/OpenAPI specification changes BEFORE regenerating API clients. It compares old vs new specs, categorizes changes as breaking/compatible/warnings, finds affected code, and generates migration strategies. Use this skill when the user wants to check API changes safely before sync, mentions "check breaking changes", or needs impact assessment.
---
# API Change Analyzer

View File

@@ -1,6 +1,6 @@
---
name: architecture-enforcer
description: Validate import boundaries and architectural rules (layer violations, domain violations, relative imports). Use when checking architecture, validating boundaries, or analyzing dependencies.
description: This skill should be used when validating import boundaries and architectural rules in the ISA-Frontend monorepo. It checks for circular dependencies, layer violations (Feature→Feature), domain violations (OMS→Remission), and relative imports. Use this skill when the user wants to check architecture, mentions "validate boundaries", "check imports", or needs dependency analysis.
---
# Architecture Enforcer

View File

@@ -1,6 +1,6 @@
---
name: circular-dependency-resolver
description: Detect and resolve circular dependencies in the monorepo. Use when the user mentions circular dependencies, dependency cycles, or has build/runtime issues from circular imports.
description: This skill should be used when detecting and resolving circular dependencies in the ISA-Frontend monorepo. It uses graph algorithms to find A→B→C→A cycles, categorizes by severity, provides multiple fix strategies (DI, interface extraction, shared code), and validates fixes. Use this skill when the user mentions "circular dependencies", "dependency cycles", or has build/runtime issues from circular imports.
---
# Circular Dependency Resolver

View File

@@ -1,6 +1,6 @@
---
name: html-template
description: This skill should be used when writing or reviewing HTML templates to ensure proper testing and accessibility attributes are included.
description: This skill should be used when writing or reviewing HTML templates to ensure proper E2E testing attributes (data-what, data-which) and ARIA accessibility attributes are included. Use when creating interactive elements like buttons, inputs, links, forms, dialogs, or any HTML markup requiring testing and accessibility compliance. Works seamlessly with the angular-template skill.
---
# HTML Template - Testing & Accessibility Attributes
@@ -16,7 +16,9 @@ Use this skill when:
- Adding interactive elements (buttons, inputs, links, etc.)
- Implementing forms, lists, navigation, or dialogs
**Works seamlessly with:** `angular-template` skill for complete Angular template guidance.
**Works seamlessly with:**
- **[angular-template](../angular-template/SKILL.md)** - Angular template syntax, control flow, and modern patterns
- **[tailwind](../tailwind/SKILL.md)** - ISA design system styling for visual design
## Overview

View File

@@ -1,6 +1,6 @@
---
name: library-scaffolder
description: Create new Angular libraries in the monorepo with Nx generation, Vitest configuration, and path alias setup. Use when creating or scaffolding new libraries.
description: This skill should be used when creating new Angular libraries in the ISA-Frontend monorepo. It handles Nx library generation with proper naming conventions, Vitest configuration with JUnit/Cobertura reporters, path alias setup, and validation. Use this skill when the user wants to create a new library, scaffold a feature/data-access/ui/util library, or requests "new library" creation.
---
# Library Scaffolder

View File

@@ -1,6 +1,6 @@
---
name: logging-helper
description: Ensures consistent usage of the @isa/core/logging library across the codebase with best practices for performance and maintainability
description: This skill should be used when working with Angular components, directives, services, pipes, guards, or TypeScript classes. Logging is MANDATORY in all Angular files. Implements @isa/core/logging with logger() factory pattern, appropriate log levels, lazy evaluation for performance, error handling, and avoids console.log and common mistakes.
---
# Logging Helper Skill

View File

@@ -1,6 +1,6 @@
---
name: swagger-sync-manager
description: Regenerate Swagger/OpenAPI TypeScript API clients with Unicode cleanup, breaking change detection, and validation. Use when regenerating API clients or backend APIs change.
description: This skill should be used when regenerating Swagger/OpenAPI TypeScript API clients in the ISA-Frontend monorepo. It handles generation of all 10 API clients (or specific ones), Unicode cleanup, breaking change detection, TypeScript validation, and affected test execution. Use this skill when the user requests API sync, mentions "regenerate swagger", or indicates backend API changes.
---
# Swagger Sync Manager

View File

@@ -23,6 +23,15 @@ Invoke this skill when:
**Important**: This skill provides Tailwind utilities. Always prefer using components from `@isa/ui/*` libraries before applying custom Tailwind styles.
**Works together with:**
- **[angular-template](../angular-template/SKILL.md)** - Angular template syntax, control flow (@if, @for, @defer), and binding patterns
- **[html-template](../html-template/SKILL.md)** - E2E testing attributes (`data-what`, `data-which`) and ARIA accessibility
When building Angular components, these three skills work together:
1. Use **angular-template** for Angular syntax and control flow
2. Use **html-template** for `data-*` and ARIA attributes
3. Use **tailwind** (this skill) for styling with the ISA design system
## Core Design System Principles
### 0. Component Libraries First (Most Important)

View File

@@ -1,6 +1,6 @@
---
name: test-migration-specialist
description: Migrate Angular libraries from Jest + Spectator to Vitest + Angular Testing Utilities. Handles test configuration, refactoring, and validation. Use for test framework migration.
description: This skill should be used when migrating Angular libraries from Jest + Spectator to Vitest + Angular Testing Utilities. It handles test configuration updates, test file refactoring, mock pattern conversion, and validation. Use this skill when the user requests test framework migration, specifically for the 40 remaining Jest-based libraries in the ISA-Frontend monorepo.
---
# Test Migration Specialist

View File

@@ -1,8 +1,8 @@
{
"mcpServers": {
"context7": {
"type": "sse",
"url": "https://mcp.context7.com/sse"
"type": "http",
"url": "https://mcp.context7.com/mcp"
},
"nx-mcp": {
"type": "stdio",

View File

@@ -1,4 +1,8 @@
import type { Preview } from '@storybook/angular';
import { registerLocaleData } from '@angular/common';
import localeDe from '@angular/common/locales/de';
registerLocaleData(localeDe);
const preview: Preview = {
tags: ['autodocs'],

View File

@@ -5,7 +5,7 @@ import {
VATValueDTO,
} from '@generated/swagger/checkout-api';
import { PurchaseOption } from './store';
import { OrderType } from '@isa/checkout/data-access';
import { OrderTypeFeature } from '@isa/checkout/data-access';
export const PURCHASE_OPTIONS: PurchaseOption[] = [
'in-store',
@@ -23,7 +23,7 @@ export const DELIVERY_PURCHASE_OPTIONS: PurchaseOption[] = [
];
export const PURCHASE_OPTION_TO_ORDER_TYPE: {
[purchaseOption: string]: OrderType;
[purchaseOption: string]: OrderTypeFeature;
} = {
'in-store': 'Rücklage',
'pickup': 'Abholung',

View File

@@ -13,7 +13,7 @@ import {
ItemPayloadWithSourceId,
PurchaseOption,
} from './purchase-options.types';
import { OrderType } from '@isa/checkout/data-access';
import { OrderTypeFeature } from '@isa/checkout/data-access';
export function isItemDTO(item: any, type: ActionType): item is ItemDTO {
return type === 'add';
@@ -145,7 +145,7 @@ export function mapToOlaAvailability({
export function getOrderTypeForPurchaseOption(
purchaseOption: PurchaseOption,
): OrderType | undefined {
): OrderTypeFeature | undefined {
switch (purchaseOption) {
case 'delivery':
case 'dig-delivery':
@@ -163,7 +163,7 @@ export function getOrderTypeForPurchaseOption(
}
export function getPurchaseOptionForOrderType(
orderType: OrderType,
orderType: OrderTypeFeature,
): PurchaseOption | undefined {
switch (orderType) {
case 'Versand':

View File

@@ -17,7 +17,10 @@ import { memorize } from '@utils/common';
import { AuthService } from '@core/auth';
import { ApplicationService } from '@core/application';
import { DomainOmsService } from '@domain/oms';
import { OrderType, PurchaseOptionsFacade } from '@isa/checkout/data-access';
import {
OrderTypeFeature,
PurchaseOptionsFacade,
} from '@isa/checkout/data-access';
@Injectable({ providedIn: 'root' })
export class PurchaseOptionsService {
@@ -122,7 +125,7 @@ export class PurchaseOptionsService {
fetchCanAdd(
shoppingCartId: number,
orderType: OrderType,
orderType: OrderTypeFeature,
payload: ItemPayload[],
customerFeatures: Record<string, string>,
): Promise<ItemsResult[]> {

View File

@@ -40,8 +40,11 @@ import { uniqueId } from 'lodash';
import { VATDTO } from '@generated/swagger/oms-api';
import { DomainCatalogService } from '@domain/catalog';
import { ItemDTO } from '@generated/swagger/cat-search-api';
import { Loyalty, OrderType, Promotion } from '@isa/checkout/data-access';
import { ensureCurrencyDefaults } from '@isa/common/data-access';
import {
Loyalty,
OrderTypeFeature,
Promotion,
} from '@isa/checkout/data-access';
@Injectable()
export class PurchaseOptionsStore extends ComponentStore<PurchaseOptionsState> {
@@ -724,7 +727,7 @@ export class PurchaseOptionsStore extends ComponentStore<PurchaseOptionsState> {
try {
const res = await this._service.fetchCanAdd(
this.shoppingCartId,
key as OrderType,
key as OrderTypeFeature,
itemPayloads,
this.customerFeatures,
);
@@ -733,7 +736,9 @@ export class PurchaseOptionsStore extends ComponentStore<PurchaseOptionsState> {
this._addCanAddResult({
canAdd: canAdd.status === 0,
itemId: item.sourceId,
purchaseOption: getPurchaseOptionForOrderType(key as OrderType),
purchaseOption: getPurchaseOptionForOrderType(
key as OrderTypeFeature,
),
message: canAdd.message,
});
});

View File

@@ -1,53 +1,47 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ArticleDetailsComponent } from './article-details.component';
import { ProductImageModule } from '@cdn/product-image';
import { UiIconModule } from '@ui/icon';
import { RouterModule } from '@angular/router';
import { UiStarsModule } from '@ui/stars';
import { UiSliderModule } from '@ui/slider';
import { ArticleRecommendationsComponent } from './recommendations/article-recommendations.component';
import { PipesModule } from '../shared/pipes/pipes.module';
import { UiTooltipModule } from '@ui/tooltip';
import { UiCommonModule } from '@ui/common';
import { OrderDeadlinePipeModule } from '@shared/pipes/order-deadline';
import { IconModule } from '@shared/components/icon';
import { ArticleDetailsTextComponent } from './article-details-text/article-details-text.component';
import { IconBadgeComponent } from 'apps/isa-app/src/shared/components/icon/badge/icon-badge.component';
import { MatomoModule } from 'ngx-matomo-client';
import {
SelectedRewardShoppingCartResource,
SelectedShoppingCartResource,
} from '@isa/checkout/data-access';
import {
RewardSelectionService,
RewardSelectionPopUpService,
} from '@isa/checkout/shared/reward-selection-dialog';
@NgModule({
imports: [
CommonModule,
ProductImageModule,
UiIconModule,
RouterModule,
UiStarsModule,
UiSliderModule,
UiCommonModule,
UiTooltipModule,
IconModule,
PipesModule,
OrderDeadlinePipeModule,
ArticleDetailsTextComponent,
IconBadgeComponent,
MatomoModule,
],
exports: [ArticleDetailsComponent, ArticleRecommendationsComponent],
declarations: [ArticleDetailsComponent, ArticleRecommendationsComponent],
providers: [
SelectedShoppingCartResource,
SelectedRewardShoppingCartResource,
RewardSelectionService,
RewardSelectionPopUpService,
],
})
export class ArticleDetailsModule {}
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ArticleDetailsComponent } from './article-details.component';
import { ProductImageModule } from '@cdn/product-image';
import { UiIconModule } from '@ui/icon';
import { RouterModule } from '@angular/router';
import { UiStarsModule } from '@ui/stars';
import { UiSliderModule } from '@ui/slider';
import { ArticleRecommendationsComponent } from './recommendations/article-recommendations.component';
import { PipesModule } from '../shared/pipes/pipes.module';
import { UiTooltipModule } from '@ui/tooltip';
import { UiCommonModule } from '@ui/common';
import { OrderDeadlinePipeModule } from '@shared/pipes/order-deadline';
import { IconModule } from '@shared/components/icon';
import { ArticleDetailsTextComponent } from './article-details-text/article-details-text.component';
import { IconBadgeComponent } from 'apps/isa-app/src/shared/components/icon/badge/icon-badge.component';
import { MatomoModule } from 'ngx-matomo-client';
import {
RewardSelectionService,
RewardSelectionPopUpService,
} from '@isa/checkout/shared/reward-selection-dialog';
@NgModule({
imports: [
CommonModule,
ProductImageModule,
UiIconModule,
RouterModule,
UiStarsModule,
UiSliderModule,
UiCommonModule,
UiTooltipModule,
IconModule,
PipesModule,
OrderDeadlinePipeModule,
ArticleDetailsTextComponent,
IconBadgeComponent,
MatomoModule,
],
exports: [ArticleDetailsComponent, ArticleRecommendationsComponent],
declarations: [ArticleDetailsComponent, ArticleRecommendationsComponent],
providers: [
RewardSelectionService,
RewardSelectionPopUpService,
],
})
export class ArticleDetailsModule {}

View File

@@ -1,32 +1,31 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { CheckoutSummaryComponent } from './checkout-summary.component';
import { PageCheckoutPipeModule } from '../pipes/page-checkout-pipe.module';
import { ProductImageModule } from '@cdn/product-image';
import { RouterModule } from '@angular/router';
import { UiCommonModule } from '@ui/common';
import { UiSpinnerModule } from '@ui/spinner';
import { UiDatepickerModule } from '@ui/datepicker';
import { IconModule } from '@shared/components/icon';
import { AuthModule } from '@core/auth';
import { SelectedRewardShoppingCartResource } from '@isa/checkout/data-access';
@NgModule({
imports: [
CommonModule,
RouterModule,
PageCheckoutPipeModule,
ProductImageModule,
IconModule,
UiCommonModule,
UiSpinnerModule,
UiDatepickerModule,
AuthModule,
UiSpinnerModule,
],
exports: [CheckoutSummaryComponent],
declarations: [CheckoutSummaryComponent],
providers: [SelectedRewardShoppingCartResource],
})
export class CheckoutSummaryModule {}
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { CheckoutSummaryComponent } from './checkout-summary.component';
import { PageCheckoutPipeModule } from '../pipes/page-checkout-pipe.module';
import { ProductImageModule } from '@cdn/product-image';
import { RouterModule } from '@angular/router';
import { UiCommonModule } from '@ui/common';
import { UiSpinnerModule } from '@ui/spinner';
import { UiDatepickerModule } from '@ui/datepicker';
import { IconModule } from '@shared/components/icon';
import { AuthModule } from '@core/auth';
@NgModule({
imports: [
CommonModule,
RouterModule,
PageCheckoutPipeModule,
ProductImageModule,
IconModule,
UiCommonModule,
UiSpinnerModule,
UiDatepickerModule,
AuthModule,
UiSpinnerModule,
],
exports: [CheckoutSummaryComponent],
declarations: [CheckoutSummaryComponent],
providers: [],
})
export class CheckoutSummaryModule {}

View File

@@ -120,7 +120,7 @@ export class CustomerOrderDetailsHeaderComponent implements OnChanges {
),
);
openAddresses: boolean = false;
openAddresses = false;
get digOrderNumber(): string {
return this.order?.linkedRecords?.find((_) => true)?.number;

View File

@@ -21,6 +21,4 @@ export class CustomerResultListItemFullComponent {
@Input()
customer: CustomerInfoDTO;
constructor() {}
}

View File

@@ -21,6 +21,4 @@ export class CustomerResultListItemComponent {
@Input()
customer: CustomerInfoDTO;
constructor() {}
}

View File

@@ -1,47 +1,41 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { CustomerSearchComponent } from './customer-search.component';
import { CustomerResultsSideViewModule } from './results-side-view/results-side-view.module';
import { RouterModule } from '@angular/router';
import { CustomerResultsMainViewModule } from './results-main-view/results-main-view.module';
import { CustomerDetailsMainViewModule } from './details-main-view/details-main-view.module';
import { CustomerHistoryMainViewModule } from './history-main-view/history-main-view.module';
import { CustomerFilterMainViewModule } from './filter-main-view/filter-main-view.module';
import { MainSideViewModule } from './main-side-view/main-side-view.module';
import { OrderDetailsSideViewComponent } from './order-details-side-view/order-details-side-view.component';
import { CustomerMainViewComponent } from './main-view/main-view.component';
import { SharedSplitscreenComponent } from '@shared/components/splitscreen';
import {
SelectedShoppingCartResource,
SelectedRewardShoppingCartResource,
} from '@isa/checkout/data-access';
import {
RewardSelectionService,
RewardSelectionPopUpService,
} from '@isa/checkout/shared/reward-selection-dialog';
@NgModule({
imports: [
CommonModule,
RouterModule,
SharedSplitscreenComponent,
CustomerResultsSideViewModule,
CustomerResultsMainViewModule,
CustomerDetailsMainViewModule,
CustomerHistoryMainViewModule,
CustomerFilterMainViewModule,
MainSideViewModule,
OrderDetailsSideViewComponent,
CustomerMainViewComponent,
],
exports: [CustomerSearchComponent],
declarations: [CustomerSearchComponent],
providers: [
SelectedShoppingCartResource,
SelectedRewardShoppingCartResource,
RewardSelectionService,
RewardSelectionPopUpService,
],
})
export class CustomerSearchModule {}
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { CustomerSearchComponent } from './customer-search.component';
import { CustomerResultsSideViewModule } from './results-side-view/results-side-view.module';
import { RouterModule } from '@angular/router';
import { CustomerResultsMainViewModule } from './results-main-view/results-main-view.module';
import { CustomerDetailsMainViewModule } from './details-main-view/details-main-view.module';
import { CustomerHistoryMainViewModule } from './history-main-view/history-main-view.module';
import { CustomerFilterMainViewModule } from './filter-main-view/filter-main-view.module';
import { MainSideViewModule } from './main-side-view/main-side-view.module';
import { OrderDetailsSideViewComponent } from './order-details-side-view/order-details-side-view.component';
import { CustomerMainViewComponent } from './main-view/main-view.component';
import { SharedSplitscreenComponent } from '@shared/components/splitscreen';
import {
RewardSelectionService,
RewardSelectionPopUpService,
} from '@isa/checkout/shared/reward-selection-dialog';
@NgModule({
imports: [
CommonModule,
RouterModule,
SharedSplitscreenComponent,
CustomerResultsSideViewModule,
CustomerResultsMainViewModule,
CustomerDetailsMainViewModule,
CustomerHistoryMainViewModule,
CustomerFilterMainViewModule,
MainSideViewModule,
OrderDetailsSideViewComponent,
CustomerMainViewComponent,
],
exports: [CustomerSearchComponent],
declarations: [CustomerSearchComponent],
providers: [
RewardSelectionService,
RewardSelectionPopUpService,
],
})
export class CustomerSearchModule {}

View File

@@ -11,7 +11,6 @@ import {
inject,
computed,
input,
effect,
} from '@angular/core';
import { Router } from '@angular/router';
import { ApplicationProcess, ApplicationService } from '@core/application';
@@ -27,7 +26,10 @@ import {
} from 'rxjs';
import { map, tap } from 'rxjs/operators';
import { TabService } from '@isa/core/tabs';
import { CheckoutMetadataService } from '@isa/checkout/data-access';
import {
CheckoutMetadataService,
ShoppingCartResource,
} from '@isa/checkout/data-access';
@Component({
selector: 'shell-process-bar-item',
@@ -35,6 +37,7 @@ import { CheckoutMetadataService } from '@isa/checkout/data-access';
styleUrls: ['process-bar-item.component.css'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: false,
providers: [ShoppingCartResource],
})
export class ShellProcessBarItemComponent
implements OnInit, OnDestroy, OnChanges
@@ -72,11 +75,8 @@ export class ShellProcessBarItemComponent
});
cartCount = computed(() => {
const tab = this.tab();
const pdata = tab.metadata?.process_data as { count?: number };
return pdata?.count ?? 0;
// TODO: Use implementation from develop
return 0;
});
currentLocationUrlTree = computed(() => {

View File

@@ -1,72 +1,74 @@
<div
class="flex flex-row justify-start items-center h-full max-w-[1920px] desktop-xx-large:max-w-[2448px] relative"
(mouseenter)="hovered = true"
(mouseleave)="hovered = false"
>
@if (showScrollArrows) {
<button
class="scroll-button prev-button"
[class.invisible]="!this.hovered || showArrowLeft"
(click)="scrollLeft()"
>
<ui-icon icon="arrow_head" size="22px" rotate="180deg"></ui-icon>
</button>
}
<div
#processContainer
class="grid grid-flow-col max-w-[calc(100vw-9.5rem)] overflow-x-scroll"
(wheel)="onMouseWheel($event)"
(scroll)="checkScrollArrowVisibility()"
>
@for (process of processes$ | async; track trackByFn($index, process)) {
<shell-process-bar-item
[process]="process"
(closed)="checkScrollArrowVisibility()"
></shell-process-bar-item>
}
</div>
@if (showScrollArrows) {
<button
class="scroll-button next-button"
[class.invisible]="!this.hovered || showArrowRight"
(click)="scrollRight()"
>
<ui-icon icon="arrow_head" size="22px"></ui-icon>
</button>
}
<button
type="button"
class="grid px-3 shell-process-bar__create-process-btn-desktop start-process-btn grid-flow-col items-center justify-center gap-[0.625rem] grow-0 shrink-0"
(click)="createProcess('product')"
type="button"
>
<ng-container *ngTemplateOutlet="createProcessButtonContent"></ng-container>
</button>
<div class="grow"></div>
<button
type="button"
[disabled]="!(processes$ | async)?.length"
class="grow-0 shrink-0 px-3 mr-[.125rem] shell-process-bar__close-processes"
(click)="closeAllProcesses()"
>
<div
class="rounded border border-solid flex flex-row pl-3 pr-[0.625rem] py-[0.375rem]"
[class.text-brand]="(processes$ | async)?.length"
[class.border-brand]="(processes$ | async)?.length"
[class.text-[#AEB7C1]]="!(processes$ | async)?.length"
[class.border-[#AEB7C1]]="!(processes$ | async)?.length"
>
<span class="mr-1">{{ (processes$ | async)?.length }}</span>
<shared-icon icon="close"></shared-icon>
</div>
</button>
</div>
<ng-template #createProcessButtonContent>
<div class="bg-brand text-white w-[2.375rem] h-[2.375rem] rounded-full grid items-center justify-center mx-auto mb-1">
<shared-icon icon="add"></shared-icon>
</div>
@if (showStartProcessText$ | async) {
<span class="text-brand create-process-btn-text">Vorgang starten</span>
}
</ng-template>
<div
class="flex flex-row justify-start items-center h-full max-w-[1920px] desktop-xx-large:max-w-[2448px] relative"
(mouseenter)="hovered = true"
(mouseleave)="hovered = false"
>
@if (showScrollArrows) {
<button
class="scroll-button prev-button"
[class.invisible]="!this.hovered || showArrowLeft"
(click)="scrollLeft()"
>
<ui-icon icon="arrow_head" size="22px" rotate="180deg"></ui-icon>
</button>
}
<div
#processContainer
class="grid grid-flow-col max-w-[calc(100vw-9.5rem)] overflow-x-scroll"
(wheel)="onMouseWheel($event)"
(scroll)="checkScrollArrowVisibility()"
>
@for (process of processes$ | async; track process.id) {
<shell-process-bar-item
[process]="process"
(closed)="checkScrollArrowVisibility()"
></shell-process-bar-item>
}
</div>
@if (showScrollArrows) {
<button
class="scroll-button next-button"
[class.invisible]="!this.hovered || showArrowRight"
(click)="scrollRight()"
>
<ui-icon icon="arrow_head" size="22px"></ui-icon>
</button>
}
<button
type="button"
class="grid px-3 shell-process-bar__create-process-btn-desktop start-process-btn grid-flow-col items-center justify-center gap-[0.625rem] grow-0 shrink-0"
(click)="createProcess('product')"
type="button"
>
<ng-container *ngTemplateOutlet="createProcessButtonContent"></ng-container>
</button>
<div class="grow"></div>
<button
type="button"
[disabled]="!(processes$ | async)?.length"
class="grow-0 shrink-0 px-3 mr-[.125rem] shell-process-bar__close-processes"
(click)="closeAllProcesses()"
>
<div
class="rounded border border-solid flex flex-row pl-3 pr-[0.625rem] py-[0.375rem]"
[class.text-brand]="(processes$ | async)?.length"
[class.border-brand]="(processes$ | async)?.length"
[class.text-[#AEB7C1]]="!(processes$ | async)?.length"
[class.border-[#AEB7C1]]="!(processes$ | async)?.length"
>
<span class="mr-1">{{ (processes$ | async)?.length }}</span>
<shared-icon icon="close"></shared-icon>
</div>
</button>
</div>
<ng-template #createProcessButtonContent>
<div
class="bg-brand text-white w-[2.375rem] h-[2.375rem] rounded-full grid items-center justify-center mx-auto mb-1"
>
<shared-icon icon="add"></shared-icon>
</div>
@if (showStartProcessText$ | async) {
<span class="text-brand create-process-btn-text">Vorgang starten</span>
}
</ng-template>

View File

@@ -1,4 +1,3 @@
import { coerceArray } from '@angular/cdk/coercion';
import {
Component,
ChangeDetectionStrategy,
@@ -65,6 +64,7 @@ export class ShellProcessBarComponent implements OnInit {
}
initProcesses$() {
// TODO: Use implementation from develop
this.processes$ = this.section$.pipe(
switchMap((section) => this._app.getProcesses$(section)),
// TODO: Nach Prämie release kann der Filter rausgenommen werden

View File

@@ -86,7 +86,15 @@
sharedRegexRouterLinkActive="active"
sharedRegexRouterLinkActiveTest="^\/\d*\/reward"
>
<span class="side-menu-group-item-icon"> </span>
<span class="side-menu-group-item-icon">
<shell-reward-shopping-cart-indicator />
@if (hasShoppingCartItems()) {
<span
class="w-2 h-2 bg-isa-accent-red rounded-full"
data-what="open-reward-tasks-indicator"
></span>
}
</span>
<span class="side-menu-group-item-label">Prämienshop</span>
</a>
}
@@ -272,11 +280,7 @@
<a
class="side-menu-group-item"
(click)="closeSideMenu(); focusSearchBox()"
[routerLink]="[
'/',
tabId(),
'remission',
]"
[routerLink]="['/', tabId(), 'remission']"
(isActiveChange)="focusSearchBox(); remissionExpanded.set($event)"
routerLinkActive="active"
#rlActive="routerLinkActive"

View File

@@ -35,6 +35,7 @@ import { TabService } from '@isa/core/tabs';
import { NgIconComponent, provideIcons } from '@ng-icons/core';
import { isaNavigationRemission2, isaNavigationReturn } from '@isa/icons';
import z from 'zod';
import { SelectedRewardShoppingCartResource } from '@isa/checkout/data-access';
@Component({
selector: 'shell-side-menu',
@@ -71,6 +72,7 @@ export class ShellSideMenuComponent {
#cdr = inject(ChangeDetectorRef);
#document = inject(DOCUMENT);
tabService = inject(TabService);
#shoppingCartResource = inject(SelectedRewardShoppingCartResource);
staticTabIds = Object.values(
this.#config.get('process.ids', z.record(z.coerce.number())),
@@ -151,6 +153,10 @@ export class ShellSideMenuComponent {
return this.#router.createUrlTree(['/', tabId || this.nextId(), routeName]);
});
hasShoppingCartItems = computed(() => {
return this.#shoppingCartResource.resource.value()?.items?.length > 0;
});
pickUpShelfOutRoutePath$ = this.getLastActivatedCustomerProcessId$().pipe(
map((processId) => {
if (processId) {

View File

@@ -1,9 +1,9 @@
import { NgModule } from '@angular/core';
import { ShellSideMenuComponent } from './side-menu.component';
@NgModule({
imports: [ShellSideMenuComponent],
exports: [ShellSideMenuComponent],
})
export class ShellSideMenuModule {}
import { NgModule } from '@angular/core';
import { ShellSideMenuComponent } from './side-menu.component';
@NgModule({
imports: [ShellSideMenuComponent],
exports: [ShellSideMenuComponent],
})
export class ShellSideMenuModule {}

View File

@@ -2,9 +2,9 @@ import {
type Meta,
type StoryObj,
applicationConfig,
argsToTemplate,
moduleMetadata,
} from '@storybook/angular';
import { provideHttpClient } from '@angular/common/http';
import { DestinationInfoComponent } from '@isa/checkout/shared/product-info';
import { ShippingTarget } from '@isa/checkout/data-access';
@@ -14,7 +14,7 @@ const meta: Meta<DestinationInfoComponent> = {
component: DestinationInfoComponent,
decorators: [
applicationConfig({
providers: [],
providers: [provideHttpClient()],
}),
moduleMetadata({
imports: [],
@@ -29,6 +29,7 @@ type Story = StoryObj<DestinationInfoComponent>;
export const Delivery: Story = {
args: {
underline: true,
shoppingCartItem: {
availability: {
estimatedDelivery: {
@@ -83,6 +84,12 @@ export const Pickup: Story = {
export const InStore: Story = {
args: {
shoppingCartItem: {
availability: {
estimatedDelivery: {
start: '2024-06-10T00:00:00+02:00',
stop: '2024-06-12T00:00:00+02:00',
},
},
destination: {
data: {
target: ShippingTarget.Branch,

View File

@@ -6,7 +6,7 @@
| Date | 29.09.2025 |
| Owners | Lorenz, Nino |
| Participants | N/A |
| Related ADRs | N/A |
| Related ADRs | [ADR-0002](./0002-models-schemas-dtos-architecture.md) |
| Tags | architecture, data-access, library, swagger |
---
@@ -35,9 +35,11 @@ Implement a **three-layer architecture** for all data-access libraries:
- Pattern: `<Operation>Schema` with `<Operation>` and `<Operation>Input` types
- Example: `SearchItemsSchema`, `SearchItems`, `SearchItemsInput`
2. **Model Layer** (`models/`): Domain-specific interfaces extending generated DTOs
- Pattern: `interface MyModel extends GeneratedDTO { ... }`
2. **Model Layer** (`models/`): Domain-specific types based on generated DTOs
- **Simple re-export pattern** (default): `export type Product = ProductDTO;`
- **Extension pattern** (when domain enhancements needed): `interface MyModel extends GeneratedDTO { ... }`
- Use `EntityContainer<T>` for lazy-loaded relationships
- **Rule**: Generated DTOs MUST NOT be imported outside data-access libraries (see ADR-0002)
3. **Service Layer** (`services/`): Injectable services integrating Swagger clients
- Pattern: Async methods with AbortSignal support
@@ -79,9 +81,12 @@ export * from './lib/services';
## Detailed Design Elements
### Schema Validation Pattern
**Structure:**
**Two schema patterns coexist:**
**Pattern A: Operation-based schemas** (for query/search operations)
```typescript
// Input validation schema
// Input validation schema for search operation
export const SearchByTermSchema = z.object({
searchTerm: z.string().min(1, 'Search term must not be empty'),
skip: z.number().int().min(0).default(0),
@@ -93,27 +98,67 @@ export type SearchByTerm = z.infer<typeof SearchByTermSchema>;
export type SearchByTermInput = z.input<typeof SearchByTermSchema>;
```
### Model Extension Pattern
**Generated DTO Extension:**
**Pattern B: Entity-based schemas** (for CRUD operations, see ADR-0002)
```typescript
// Full entity schema defining all fields
export const ProductSchema = z.object({
id: z.string().optional(),
name: z.string().min(1).max(255),
contributor: z.string().optional(),
price: z.number().positive().optional(),
// ... all fields
});
// Derived validation schemas for specific operations
export const CreateProductSchema = ProductSchema.pick({ name: true });
export const UpdateProductSchema = ProductSchema.pick({ id: true, name: true }).required();
// Validate requests only, not responses
```
### Model Pattern
**Pattern A: Simple Re-export** (default, recommended - see ADR-0002)
```typescript
import { ProductDTO } from '@generated/swagger/catalogue-api';
/**
* Product model for catalogue domain.
* Simple re-export of generated DTO.
*/
export type Product = ProductDTO;
```
**Pattern B: Extension** (when domain-specific enhancements needed)
```typescript
import { ProductDTO } from '@generated/swagger/cat-search-api';
/**
* Enhanced product with computed/derived fields.
*/
export interface Product extends ProductDTO {
name: string;
contributors: string;
catalogProductNumber: string;
// Domain-specific enhancements
// Domain-specific computed fields
displayName: string;
formattedPrice: string;
}
```
**Entity Container Pattern:**
**Entity Container Pattern** (for lazy-loaded relationships)
```typescript
import { ReturnDTO } from '@generated/swagger/remission-api';
import { EntityContainer } from '@isa/common/data-access';
export interface Return extends ReturnDTO {
id: number;
receipts: EntityContainer<Receipt>[]; // Lazy-loaded relationships
}
```
**Important:** Generated DTOs (`@generated/swagger/*`) MUST NOT be imported directly in feature/UI libraries. Always import models from data-access.
### Service Implementation Pattern
**Standard service structure:**
```typescript
@@ -323,6 +368,7 @@ When and how this ADR will be re-evaluated (date, trigger conditions, metrics th
## Status Log
| Date | Change | Author |
|------|--------|--------|
| 2025-11-03 | Updated model/schema patterns to align with ADR-0002, added entity-based schemas, clarified DTO encapsulation | System |
| 2025-10-02 | Condensed for readability | Lorenz, Nino |
| 2025-09-29 | Created (Draft) | Lorenz |
| 2025-09-25 | Analysis completed, comprehensive patterns documented | Lorenz, Nino |
@@ -340,6 +386,9 @@ When and how this ADR will be re-evaluated (date, trigger conditions, metrics th
- `@isa/core/logging` - Structured logging infrastructure
- `@isa/common/data-access` - Shared utilities and types
**Related ADRs:**
- [ADR-0002: Models, Schemas, and DTOs Architecture](./0002-models-schemas-dtos-architecture.md) - Detailed guidance on model patterns, DTO encapsulation, and validation strategies
**Related Documentation:**
- ISA Frontend Copilot Instructions - Data-access patterns
- Tech Stack Documentation - Architecture overview

View File

@@ -0,0 +1,854 @@
# ADR 0002: Models, Schemas, and DTOs Architecture
| Field | Value |
|-------|-------|
| Status | Draft |
| Date | 2025-11-03 |
| Owners | TBD |
| Participants | TBD |
| Related ADRs | [ADR-0001](./0001-implement-data-access-api-requests.md) |
| Tags | architecture, data-access, models, schemas, dto, validation |
---
## Summary (Decision in One Sentence)
Encapsulate all generated Swagger DTOs within data-access libraries, use domain-specific models even when names collide, define full Zod schemas with partial validation at service-level for request data only, and export identical cross-domain models from common/data-access.
## Context & Problem Statement
**Current Issues:**
- Generated DTOs (`@generated/swagger/*`) directly imported in 50+ feature/UI files
- Same interface names (e.g., `PayerDTO`, `BranchDTO`) with different properties across 10 APIs
- Union type workarounds (`Product = CatProductDTO | CheckoutProductDTO | OmsProductDTO`) lose type safety
- Inconsistent Zod schema coverage - some types validated, others not
- Type compatibility issues between models and schemas with identical interfaces
- Component-local type redefinitions instead of shared models
- No clear pattern for partial validation (validate some fields, send all data)
**Example Conflicts:**
```typescript
// checkout-api: Minimal PayerDTO (3 properties)
export interface PayerDTO {
payerNumber?: string;
payerStatus?: PayerStatus;
payerType?: PayerType;
}
// crm-api: Full PayerDTO (17 properties)
export interface PayerDTO {
payerNumber?: string;
address?: AddressDTO;
communicationDetails?: CommunicationDetailsDTO;
// ... 14 more fields
}
// Feature components use aliasing as workaround
import { PayerDTO as CheckoutPayer } from '@generated/swagger/checkout-api';
import { PayerDTO as CrmPayer } from '@generated/swagger/crm-api';
```
**Goals:**
- Encapsulate generated code as implementation detail
- Eliminate type name conflicts across domains
- Standardize validation patterns
- Support partial validation while sending complete data
- Improve type safety and developer experience
**Constraints:**
- Must integrate with 10 existing Swagger generated clients
- Cannot break existing feature/UI components
- Must support domain-driven architecture
- Validation overhead must remain minimal
**Scope:**
- Model definitions and exports
- Schema architecture and validation strategy
- DTO encapsulation boundaries
- Common vs domain-specific type organization
## Decision
Implement a **four-layer type architecture** for all data-access libraries:
### 1. Generated Layer (Hidden)
- **Location:** `/generated/swagger/[api-name]/`
- **Visibility:** NEVER imported outside data-access libraries
- **Purpose:** Implementation detail, source of truth from backend
### 2. Model Layer (Public API)
- **Location:** `libs/[domain]/data-access/src/lib/models/`
- **Pattern:** Type aliases re-exporting generated DTOs
- **Naming:** Use domain context (e.g., `Product` in both catalogue and checkout)
- **Rule:** Each domain has its own models, even if names collide across domains
### 3. Schema Layer (Validation)
- **Location:** `libs/[domain]/data-access/src/lib/schemas/`
- **Pattern:** Full Zod schemas defining ALL fields
- **Validation:** Derive partial validation schemas using `.pick()` or `.partial()`
- **Purpose:** Runtime validation + type inference
### 4. Common Layer (Shared Types)
- **Location:** `libs/common/data-access/src/lib/models/` and `schemas/`
- **Rule:** Only for models **identical across all APIs** (same name, properties, types, optionality)
- **Examples:** `EntityStatus`, `NotificationChannel` (if truly identical)
### Validation Strategy
1. **Request Validation Only:** Validate data BEFORE sending to backend
2. **Service-Level Validation:** Perform validation in service methods
3. **Partial Validation:** Validate only required fields, send all data
4. **Full Schema Definition:** Define complete schemas even when partial validation used
5. **No Response Validation:** Trust backend responses without validation
### Export Rules
```typescript
// ✅ Data-access exports
export * from './lib/models'; // Type aliases over DTOs
export * from './lib/schemas'; // Zod schemas
export * from './lib/services'; // Business logic
// ❌ NEVER export generated code
// export * from '@generated/swagger/catalogue-api';
```
## Rationale
**Why Encapsulate Generated DTOs:**
- **Single Responsibility:** Data-access owns API integration details
- **Change Isolation:** API changes don't ripple through feature layers
- **Clear Boundaries:** Domain logic separated from transport layer
- **Migration Safety:** Can swap generated clients without breaking features
**Why Domain-Specific Models (Not Shared):**
- **Type Safety:** Each domain gets exact DTO shape from its API
- **No Name Conflicts:** `Payer` in checkout vs CRM have different meanings
- **Semantic Clarity:** Same name doesn't mean same concept across domains
- **Avoids Union Types:** Union types lose specificity and auto-completion
**Why Full Schemas with Partial Validation:**
- **Documentation:** Full schema serves as reference for all available fields
- **Flexibility:** Can derive different validation schemas (create vs update vs patch)
- **Type Safety:** `z.infer` provides complete type information
- **Reusability:** Pick different fields for different operations
- **Future-Proof:** New validations can be added without schema rewrites
**Why Service-Level Validation:**
- **Centralized Logic:** All API calls validated consistently
- **Early Failure:** Errors caught before network requests
- **Logged Context:** Validation failures logged with structured data
- **User Feedback:** Services can map validation errors to user messages
**Why No Response Validation:**
- **Performance:** No overhead on every API response
- **Backend Trust:** Backend is source of truth, already validated
- **Simpler Code:** Less boilerplate in services
- **Faster Development:** Focus on request contract, not response parsing
**Evidence Supporting Decision:**
- Analysis shows 50+ files importing generated DTOs (architecture violation)
- `BranchDTO` exists in 7 APIs with subtle differences
- Existing ADR-0001 establishes service patterns this extends
- Current union type patterns (`Product = A | B | C`) cause type narrowing issues
## Consequences
### Positive
- **Clear Architecture:** Generated code hidden behind stable public API
- **No Name Conflicts:** Domain models isolated by library boundaries
- **Type Safety:** Each domain gets precise types from its API
- **Validation Consistency:** All requests validated, responses trusted
- **Developer Experience:** Auto-completion works, no aliasing needed
- **Maintainability:** API changes isolated to data-access layer
- **Performance:** Minimal validation overhead, no response parsing
### Negative
- **Migration Effort:** 50+ files need import updates
- **Learning Curve:** Team must understand model vs DTO distinction
- **Schema Maintenance:** Every model needs corresponding full schema
- **Potential Duplication:** Similar models across domains (by design)
- **Validation Cost:** ~1-2ms overhead per validated request
### Neutral
- **Code Volume:** More files (models + schemas) but better organized
- **Common Models Rare:** Most types will be domain-specific, not common
### Risks & Mitigation
- **Risk:** Developers might accidentally import generated DTOs
- **Mitigation:** ESLint rule to prevent `@generated/swagger/*` imports outside data-access
- **Risk:** Unclear when model should be common vs domain-specific
- **Mitigation:** Decision tree in documentation (see below)
- **Risk:** Partial validation might miss critical fields
- **Mitigation:** Code review focus on validation schemas, tests for edge cases
## Detailed Design Elements
### Decision Tree: Where Should a Model Live?
```
┌─────────────────────────────────────────────────────────────┐
│ Is the DTO IDENTICAL in all generated APIs? │
│ (same name, properties, types, optional/required status) │
└──┬───────────────────────────────────────────────┬──────────┘
│ YES │ NO
│ │
▼ ▼
┌──────────────────────────────┐ ┌───────────────────────────────┐
│ libs/common/data-access/ │ │ Is it used in multiple │
│ models/[type].ts │ │ domains? │
│ │ └───┬───────────────────────┬───┘
│ Export once, import in all │ │ YES │ NO
│ domain data-access libs │ │ │
└──────────────────────────────┘ ▼ ▼
┌──────────────────┐ ┌─────────────────┐
│ Create separate │ │ Single domain's │
│ model in EACH │ │ data-access │
│ domain's │ │ library │
│ data-access │ └─────────────────┘
└──────────────────┘
Example: Product exists in
catalogue, checkout, oms
with different shapes
```
### Pattern 1: Domain-Specific Model (Most Common)
```typescript
// libs/catalogue/data-access/src/lib/models/product.ts
import { ProductDTO } from '@generated/swagger/catalogue-api';
/**
* Product model for catalogue domain.
*
* Represents a product in the product catalogue with full details
* including pricing, availability, and metadata.
*/
export type Product = ProductDTO;
```
```typescript
// libs/catalogue/data-access/src/lib/schemas/product.schema.ts
import { z } from 'zod';
/**
* Full Zod schema for Product entity.
* Defines all fields available in the Product model.
*/
export const ProductSchema = z.object({
id: z.string().optional(),
name: z.string().min(1).max(255),
contributor: z.string().optional(),
price: z.number().positive().optional(),
description: z.string().max(2000).optional(),
categoryId: z.string().optional(),
stockQuantity: z.number().int().nonnegative().optional(),
imageUrl: z.string().url().optional(),
isActive: z.boolean().optional(),
metadata: z.record(z.string(), z.unknown()).optional(),
});
/**
* Validation schema for creating a product.
* Validates only required fields: name must be present and valid.
* Other fields are sent to API but not validated.
*/
export const CreateProductSchema = ProductSchema.pick({
name: true,
});
/**
* Validation schema for updating a product.
* Requires id and name, other fields optional.
*/
export const UpdateProductSchema = ProductSchema.pick({
id: true,
name: true,
}).required();
/**
* Inferred types from schemas
*/
export type Product = z.infer<typeof ProductSchema>;
export type CreateProductInput = z.input<typeof CreateProductSchema>;
export type UpdateProductInput = z.input<typeof UpdateProductSchema>;
```
```typescript
// libs/catalogue/data-access/src/lib/services/products.service.ts
import { inject, Injectable } from '@angular/core';
import { firstValueFrom } from 'rxjs';
import { ProductService, ProductDTO } from '@generated/swagger/catalogue-api';
import { CreateProductSchema, UpdateProductSchema } from '../schemas';
import { logger } from '@isa/core/logging';
@Injectable({ providedIn: 'root' })
export class ProductsService {
readonly #log = logger(ProductsService);
readonly #productService = inject(ProductService);
/**
* Creates a new product.
* Validates required fields before sending to API.
* Sends all product data (validated and unvalidated fields).
*/
async createProduct(product: ProductDTO): Promise<ProductDTO | null> {
// Validate only required fields (name)
const validationResult = CreateProductSchema.safeParse(product);
if (!validationResult.success) {
this.#log.error('Product validation failed', {
errors: validationResult.error.format(),
product
});
throw new Error(
`Invalid product data: ${validationResult.error.message}`
);
}
this.#log.debug('Creating product', { name: product.name });
// Send ALL product data to API (including unvalidated fields)
const response = await firstValueFrom(
this.#productService.createProduct(product)
);
// No response validation - trust backend
if (response.error) {
this.#log.error('Failed to create product', {
error: response.message
});
throw new Error(response.message);
}
return response.result ?? null;
}
/**
* Updates an existing product.
* Validates id and name are present.
*/
async updateProduct(
id: string,
product: ProductDTO
): Promise<ProductDTO | null> {
// Validate required fields for update (id + name)
const validationResult = UpdateProductSchema.safeParse({ id, ...product });
if (!validationResult.success) {
this.#log.error('Product update validation failed', {
errors: validationResult.error.format()
});
throw new Error(
`Invalid product update: ${validationResult.error.message}`
);
}
this.#log.debug('Updating product', { id, name: product.name });
const response = await firstValueFrom(
this.#productService.updateProduct(id, product)
);
if (response.error) {
this.#log.error('Failed to update product', {
error: response.message,
id
});
throw new Error(response.message);
}
return response.result ?? null;
}
/**
* Fetches a product by ID.
* No validation needed for GET requests.
*/
async getProduct(id: string): Promise<ProductDTO | null> {
this.#log.debug('Fetching product', { id });
const response = await firstValueFrom(
this.#productService.getProduct(id)
);
if (response.error) {
this.#log.error('Failed to fetch product', {
error: response.message,
id
});
throw new Error(response.message);
}
// No response validation
return response.result ?? null;
}
}
```
### Pattern 2: Common Model (Identical Across All APIs)
```typescript
// libs/common/data-access/src/lib/models/notification-channel.ts
import { NotificationChannel as CheckoutNotificationChannel } from '@generated/swagger/checkout-api';
import { NotificationChannel as CrmNotificationChannel } from '@generated/swagger/crm-api';
import { NotificationChannel as OmsNotificationChannel } from '@generated/swagger/oms-api';
/**
* NotificationChannel is identical across all APIs.
*
* Verification:
* - checkout-api: type NotificationChannel = 0 | 1 | 2 | 4
* - crm-api: type NotificationChannel = 0 | 1 | 2 | 4
* - oms-api: type NotificationChannel = 0 | 1 | 2 | 4
*
* All three definitions are identical, so we export once from common.
*/
export type NotificationChannel =
| CheckoutNotificationChannel
| CrmNotificationChannel
| OmsNotificationChannel;
// Alternative if truly identical (pick one as canonical):
// export type NotificationChannel = CheckoutNotificationChannel;
```
```typescript
// libs/common/data-access/src/lib/schemas/notification-channel.schema.ts
import { z } from 'zod';
/**
* Zod schema for NotificationChannel enum.
*
* Values:
* - 0: Email
* - 1: SMS
* - 2: Push Notification
* - 4: Phone Call
*/
export const NotificationChannelSchema = z.union([
z.literal(0),
z.literal(1),
z.literal(2),
z.literal(4),
]);
export type NotificationChannel = z.infer<typeof NotificationChannelSchema>;
```
```typescript
// Domain data-access libs re-export from common
// libs/checkout/data-access/src/lib/models/index.ts
export { NotificationChannel } from '@isa/common/data-access';
// libs/crm/data-access/src/lib/schemas/index.ts
export { NotificationChannelSchema } from '@isa/common/data-access';
```
### Pattern 3: Multiple Domain Models (Same Name, Different Structure)
When DTOs with the same name have different structures, keep them separate:
```typescript
// libs/checkout/data-access/src/lib/models/payer.ts
import { PayerDTO } from '@generated/swagger/checkout-api';
/**
* Payer model for checkout domain.
*
* Minimal payer information needed during checkout flow.
* Contains only basic identification and status.
*/
export type Payer = PayerDTO;
```
```typescript
// libs/crm/data-access/src/lib/models/payer.ts
import { PayerDTO } from '@generated/swagger/crm-api';
/**
* Payer model for CRM domain.
*
* Full payer entity with complete address, organization,
* communication details, and payment settings.
* Used for payer management and administration.
*/
export type Payer = PayerDTO;
```
**Components import from their respective domain:**
```typescript
// libs/checkout/feature/cart/src/lib/cart.component.ts
import { Payer } from '@isa/checkout/data-access'; // 3-field version
// libs/crm/feature/payers/src/lib/payer-details.component.ts
import { Payer } from '@isa/crm/data-access'; // 17-field version
```
### Pattern 4: Partial Validation with Full Schema
**Scenario:** Product has many fields, but only `name` is required for creation.
```typescript
// Full schema defines ALL fields
export const ProductSchema = z.object({
id: z.string().optional(),
name: z.string().min(1).max(255),
contributor: z.string().optional(),
price: z.number().positive().optional(),
description: z.string().optional(),
categoryId: z.string().optional(),
stockQuantity: z.number().int().nonnegative().optional(),
imageUrl: z.string().url().optional(),
isActive: z.boolean().optional(),
metadata: z.record(z.string(), z.unknown()).optional(),
// ... potentially 20+ more fields
});
// Validation schema picks only required field
export const CreateProductSchema = ProductSchema.pick({
name: true,
});
// Service validates partial, sends complete
async createProduct(product: ProductDTO): Promise<ProductDTO | null> {
// Validate: only name is checked
CreateProductSchema.parse(product);
// Send: all fields (name, contributor, price, description, etc.)
const response = await this.api.createProduct(product);
return response.result;
}
```
**Why not `.passthrough()`?**
```typescript
// ❌ Avoid this approach
const CreateProductSchema = z.object({
name: z.string().min(1),
}).passthrough();
// Type inference loses other fields
type Inferred = z.infer<typeof CreateProductSchema>;
// Result: { name: string } & { [key: string]: unknown }
// Lost: contributor, price, description types
// ✅ Prefer this approach
const CreateProductSchema = ProductSchema.pick({ name: true });
// Full ProductSchema defined elsewhere provides complete type
type Product = z.infer<typeof ProductSchema>;
// Result: { id?: string; name: string; contributor?: string; ... }
```
### Export Structure
```typescript
// libs/catalogue/data-access/src/index.ts
// Public API exports
export * from './lib/models'; // Type aliases over DTOs
export * from './lib/schemas'; // Zod schemas
export * from './lib/services'; // Business logic
export * from './lib/resources'; // Angular resources (optional)
export * from './lib/stores'; // State management (optional)
export * from './lib/helpers'; // Utilities (optional)
// ❌ NEVER export generated code
// This would break encapsulation:
// export * from '@generated/swagger/catalogue-api';
```
```typescript
// libs/catalogue/data-access/src/lib/models/index.ts
// Re-export all domain models
export * from './product';
export * from './category';
export * from './supplier';
export * from './inventory';
// May also re-export common models
export { EntityStatus, NotificationChannel } from '@isa/common/data-access';
```
```typescript
// libs/catalogue/data-access/src/lib/schemas/index.ts
// Re-export all domain schemas
export * from './product.schema';
export * from './category.schema';
export * from './supplier.schema';
export * from './inventory.schema';
// May also re-export common schemas
export { EntityStatusSchema } from '@isa/common/data-access';
```
## Code Examples
### Complete Example: Order in OMS Domain
```typescript
// libs/oms/data-access/src/lib/models/order.ts
import { OrderDTO } from '@generated/swagger/oms-api';
export type Order = OrderDTO;
```
```typescript
// libs/oms/data-access/src/lib/schemas/order.schema.ts
import { z } from 'zod';
import { OrderItemSchema } from './order-item.schema';
import { OrderStatusSchema } from '@isa/common/data-access';
export const OrderSchema = z.object({
id: z.string().optional(),
orderNumber: z.string().min(1),
customerId: z.string().uuid(),
items: z.array(OrderItemSchema).min(1),
status: OrderStatusSchema.optional(),
totalAmount: z.number().nonnegative().optional(),
shippingAddress: z.string().optional(),
billingAddress: z.string().optional(),
createdAt: z.string().datetime().optional(),
updatedAt: z.string().datetime().optional(),
});
export const CreateOrderSchema = OrderSchema.pick({
customerId: true,
items: true,
});
export const UpdateOrderStatusSchema = OrderSchema.pick({
id: true,
status: true,
}).required();
export type Order = z.infer<typeof OrderSchema>;
export type CreateOrderInput = z.input<typeof CreateOrderSchema>;
export type UpdateOrderStatusInput = z.input<typeof UpdateOrderStatusSchema>;
```
```typescript
// libs/oms/data-access/src/lib/services/orders.service.ts
import { inject, Injectable } from '@angular/core';
import { firstValueFrom } from 'rxjs';
import { OrderService, OrderDTO } from '@generated/swagger/oms-api';
import { CreateOrderSchema, UpdateOrderStatusSchema } from '../schemas';
import { logger } from '@isa/core/logging';
@Injectable({ providedIn: 'root' })
export class OrdersService {
readonly #log = logger(OrdersService);
readonly #orderService = inject(OrderService);
async createOrder(order: OrderDTO): Promise<OrderDTO | null> {
const validationResult = CreateOrderSchema.safeParse(order);
if (!validationResult.success) {
this.#log.error('Order validation failed', {
errors: validationResult.error.format()
});
throw new Error(
`Invalid order: ${validationResult.error.message}`
);
}
this.#log.debug('Creating order', {
customerId: order.customerId,
itemCount: order.items?.length
});
const response = await firstValueFrom(
this.#orderService.createOrder(order)
);
if (response.error) {
this.#log.error('Failed to create order', {
error: response.message
});
throw new Error(response.message);
}
return response.result ?? null;
}
async updateOrderStatus(
id: string,
status: string
): Promise<OrderDTO | null> {
UpdateOrderStatusSchema.parse({ id, status });
this.#log.debug('Updating order status', { id, status });
const response = await firstValueFrom(
this.#orderService.updateOrderStatus(id, status)
);
if (response.error) {
this.#log.error('Failed to update order status', {
error: response.message,
id
});
throw new Error(response.message);
}
return response.result ?? null;
}
}
```
### Usage in Feature Component
```typescript
// libs/oms/feature/orders/src/lib/create-order.component.ts
import { Component, inject, signal } from '@angular/core';
import { OrdersService, Order, CreateOrderInput } from '@isa/oms/data-access';
@Component({
selector: 'app-create-order',
template: `
<form (ngSubmit)="submit()">
<!-- Form fields -->
@if (error()) {
<div class="error">{{ error() }}</div>
}
<button type="submit">Create Order</button>
</form>
`
})
export class CreateOrderComponent {
readonly #ordersService = inject(OrdersService);
error = signal<string | null>(null);
async submit() {
try {
// Build order data
const orderInput: CreateOrderInput = {
customerId: this.customerId,
items: this.items,
// Optional fields can be included
shippingAddress: this.shippingAddress,
billingAddress: this.billingAddress,
};
// Service validates required fields, sends all data
const created = await this.#ordersService.createOrder(orderInput);
// Navigate to order details...
} catch (err) {
this.error.set(err instanceof Error ? err.message : 'Failed to create order');
}
}
}
```
## Migration Strategy
### Phase 1: New Development (Immediate)
- All new models follow this ADR
- All new schemas use full definition + partial validation
- All new services validate requests only
### Phase 2: Incremental Migration (Ongoing)
- When touching existing code, update imports
- Replace generated DTO imports with data-access model imports
- Add validation schemas for existing services
### Phase 3: Cleanup (Future)
- Add ESLint rule preventing `@generated/swagger/*` imports outside data-access
- Automated codemod to fix remaining violations
- Remove union type workarounds
### Migration Checklist (Per Domain)
- [ ] Create `models/` folder with type aliases over generated DTOs
- [ ] Create `schemas/` folder with full Zod schemas
- [ ] Add partial validation schemas (`.pick()` for required fields)
- [ ] Update services to validate before API calls
- [ ] Export models and schemas from data-access index
- [ ] Update feature components to import from data-access
- [ ] Remove direct `@generated/swagger/*` imports
- [ ] Verify no union types for different shapes
- [ ] Move truly identical models to common/data-access
## Open Questions / Follow-Ups
### For Team Discussion
1. **ESLint Rule Priority:** Should we add the ESLint rule immediately or after migration?
- Immediate: Prevents new violations
- After migration: Less friction during transition
2. **Validation Error Handling:** How should services communicate validation errors to UI?
- Throw generic Error (current approach)
- Custom ValidationError class with structured field errors
- Return Result<T, E> pattern instead of throwing
3. **Common Model Criteria:** Should we require 100% identical or allow minor differences?
- Strict: Must be byte-for-byte identical
- Lenient: Same semantic meaning, slight type differences OK
4. **Schema Generation:** Should we auto-generate Zod schemas from Swagger specs?
- Pro: Less manual work, stays in sync
- Con: Generated schemas might not match domain needs
5. **Response Validation:** Any exceptions where we SHOULD validate responses?
- Critical paths (payments, checkout)?
- External APIs (not our backend)?
### Dependent Decisions
- [ ] Define custom ValidationError class structure
- [ ] Decide on ESLint rule configuration
- [ ] Document common model approval process
- [ ] Create code generation tooling (if desired)
## Decision Review & Revalidation
**Review Triggers:**
- After 3 months of adoption (2025-02-03)
- When migration >50% complete
- If validation overhead becomes measurable performance issue
- If new backend API patterns emerge
**Success Metrics:**
- Zero `@generated/swagger/*` imports outside data-access (ESLint violations)
- 100% of services have request validation
- <5% of models in common/data-access (most are domain-specific)
- Developer survey shows improved clarity (>80% satisfaction)
**Failure Criteria (Revert Decision):**
- Validation overhead >10ms per request (current: ~1-2ms)
- Common models >30% (suggests wrong criteria)
- Excessive developer friction (>50% negative feedback)
## Status Log
| Date | Change | Author |
|------|--------|--------|
| 2025-11-03 | Created (Draft) | TBD |
## References
**Related ADRs:**
- [ADR-0001: Implement data-access API Requests](./0001-implement-data-access-api-requests.md) - Establishes service patterns this extends
**Existing Codebase:**
- `/generated/swagger/` - 10 generated API clients
- `libs/*/data-access/` - 7 existing data-access libraries
- `libs/common/data-access/` - Shared types and utilities
**External Documentation:**
- [Zod Documentation](https://zod.dev/) - Schema validation library
- [ng-swagger-gen](https://github.com/cyclosproject/ng-swagger-gen) - OpenAPI client generator
**Migration Resources:**
- Comprehensive guide: `/docs/architecture/models-schemas-dtos-guide.md`
- Example implementations in catalogue, oms, crm data-access libraries
---
> Document updates MUST reference this ADR number in commit messages: `ADR-0002:` prefix.
> Keep this document updated through all lifecycle stages.

View File

@@ -198,6 +198,7 @@ export { RangeDTO } from './models/range-dto';
export { AvailableFor } from './models/available-for';
export { ResponseArgsOfIEnumerableOfOrderDTO } from './models/response-args-of-ienumerable-of-order-dto';
export { ResponseArgsOfOrderDTO } from './models/response-args-of-order-dto';
export { ResponseArgsOfDisplayOrderDTO } from './models/response-args-of-display-order-dto';
export { ListResponseArgsOfOrderListItemDTO } from './models/list-response-args-of-order-list-item-dto';
export { ResponseArgsOfIEnumerableOfOrderListItemDTO } from './models/response-args-of-ienumerable-of-order-list-item-dto';
export { OrderListItemDTO } from './models/order-list-item-dto';

View File

@@ -0,0 +1,10 @@
/* tslint:disable */
import { ResponseArgs } from './response-args';
import { DisplayOrderDTO } from './display-order-dto';
export interface ResponseArgsOfDisplayOrderDTO extends ResponseArgs{
/**
* Wert
*/
result?: DisplayOrderDTO;
}

View File

@@ -1,22 +1,22 @@
/* tslint:disable */
/**
* Gr<EFBFBD><EFBFBD>e / Volumen
* Gre / Volumen
*/
export interface SizeOfString {
/**
* H<EFBFBD>he
* Hhe
*/
height: number;
/**
* L<EFBFBD>nge / Tiefe
* Lnge / Tiefe
*/
length: number;
/**
* Ma<EFBFBD>einheit
* Maeinheit
*/
unit?: string;

View File

@@ -14,6 +14,7 @@ import { ResponseArgsOfOrderItemDTO } from '../models/response-args-of-order-ite
import { OrderItemSubsetDTO } from '../models/order-item-subset-dto';
import { ResponseArgsOfOrderItemSubsetDTO } from '../models/response-args-of-order-item-subset-dto';
import { ResponseArgsOfIEnumerableOfOrderDTO } from '../models/response-args-of-ienumerable-of-order-dto';
import { ResponseArgsOfDisplayOrderDTO } from '../models/response-args-of-display-order-dto';
import { ListResponseArgsOfOrderListItemDTO } from '../models/list-response-args-of-order-list-item-dto';
import { QueryTokenDTO } from '../models/query-token-dto';
import { ResponseArgsOfIEnumerableOfOrderItemSubsetTaskDTO } from '../models/response-args-of-ienumerable-of-order-item-subset-task-dto';
@@ -50,6 +51,7 @@ class OrderService extends __BaseService {
static readonly OrderUpdateOrderItemSubsetPath = '/order/{orderId}/orderitem/{orderItemId}/orderitemsubset/{orderItemSubsetId}';
static readonly OrderPatchOrderItemSubsetPath = '/order/{orderId}/orderitem/{orderItemId}/orderitemsubset/{orderItemSubsetId}';
static readonly OrderGetOrdersByCompartmentPath = '/order/compartment';
static readonly OrderGetDisplayOrderPath = '/order/{orderId}/display';
static readonly OrderGetOrdersByBuyerNumberPath = '/buyer/order';
static readonly OrderQueryOrdersPath = '/order/s';
static readonly OrderOrderConfirmationTaskPath = '/order/{orderId}/confirmationtask';
@@ -416,6 +418,42 @@ class OrderService extends __BaseService {
);
}
/**
* @param orderId undefined
* @return or
*/
OrderGetDisplayOrderResponse(orderId: number): __Observable<__StrictHttpResponse<ResponseArgsOfDisplayOrderDTO | ResponseArgsOfDisplayOrderDTO>> {
let __params = this.newParams();
let __headers = new HttpHeaders();
let __body: any = null;
let req = new HttpRequest<any>(
'GET',
this.rootUrl + `/order/${encodeURIComponent(String(orderId))}/display`,
__body,
{
headers: __headers,
params: __params,
responseType: 'json'
});
return this.http.request<any>(req).pipe(
__filter(_r => _r instanceof HttpResponse),
__map((_r) => {
return _r as __StrictHttpResponse<ResponseArgsOfDisplayOrderDTO | ResponseArgsOfDisplayOrderDTO>;
})
);
}
/**
* @param orderId undefined
* @return or
*/
OrderGetDisplayOrder(orderId: number): __Observable<ResponseArgsOfDisplayOrderDTO | ResponseArgsOfDisplayOrderDTO> {
return this.OrderGetDisplayOrderResponse(orderId).pipe(
__map(_r => _r.body as ResponseArgsOfDisplayOrderDTO | ResponseArgsOfDisplayOrderDTO)
);
}
/**
* @param params The `OrderService.OrderGetOrdersByBuyerNumberParams` containing the following parameters:
*

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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