mirror of
https://dev.azure.com/hugendubel/ISA/_git/ISA-Frontend
synced 2025-12-28 22:42:11 +01:00
committed by
Nino Righi
parent
1d4c900d3a
commit
89b3d9aa60
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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.
|
||||
@@ -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+)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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[]> {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -120,7 +120,7 @@ export class CustomerOrderDetailsHeaderComponent implements OnChanges {
|
||||
),
|
||||
);
|
||||
|
||||
openAddresses: boolean = false;
|
||||
openAddresses = false;
|
||||
|
||||
get digOrderNumber(): string {
|
||||
return this.order?.linkedRecords?.find((_) => true)?.number;
|
||||
|
||||
@@ -21,6 +21,4 @@ export class CustomerResultListItemFullComponent {
|
||||
|
||||
@Input()
|
||||
customer: CustomerInfoDTO;
|
||||
|
||||
constructor() {}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,4 @@ export class CustomerResultListItemComponent {
|
||||
|
||||
@Input()
|
||||
customer: CustomerInfoDTO;
|
||||
|
||||
constructor() {}
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
854
docs/architecture/adr/0002-models-schemas-dtos-architecture.md
Normal file
854
docs/architecture/adr/0002-models-schemas-dtos-architecture.md
Normal 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.
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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:
|
||||
*
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {
|
||||
getOrderTypeFeature,
|
||||
OrderType,
|
||||
OrderTypeFeature,
|
||||
ShoppingCartItem,
|
||||
} from '@isa/checkout/data-access';
|
||||
import {
|
||||
@@ -57,47 +57,47 @@ export class GetAvailabilityParamsAdapter {
|
||||
];
|
||||
|
||||
switch (orderType) {
|
||||
case OrderType.InStore:
|
||||
case OrderTypeFeature.InStore:
|
||||
if (!targetBranch) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
orderType: OrderType.InStore,
|
||||
orderType: OrderTypeFeature.InStore,
|
||||
branchId: targetBranch,
|
||||
itemsIds: baseItems.map((item) => item.itemId), // Note: itemsIds is array of numbers
|
||||
};
|
||||
|
||||
case OrderType.Pickup:
|
||||
case OrderTypeFeature.Pickup:
|
||||
if (!targetBranch) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
orderType: OrderType.Pickup,
|
||||
orderType: OrderTypeFeature.Pickup,
|
||||
branchId: targetBranch,
|
||||
items: baseItems,
|
||||
};
|
||||
|
||||
case OrderType.Delivery:
|
||||
case OrderTypeFeature.Delivery:
|
||||
return {
|
||||
orderType: OrderType.Delivery,
|
||||
orderType: OrderTypeFeature.Delivery,
|
||||
items: baseItems,
|
||||
};
|
||||
|
||||
case OrderType.DigitalShipping:
|
||||
case OrderTypeFeature.DigitalShipping:
|
||||
return {
|
||||
orderType: OrderType.DigitalShipping,
|
||||
orderType: OrderTypeFeature.DigitalShipping,
|
||||
items: baseItems,
|
||||
};
|
||||
|
||||
case OrderType.B2BShipping:
|
||||
case OrderTypeFeature.B2BShipping:
|
||||
return {
|
||||
orderType: OrderType.B2BShipping,
|
||||
orderType: OrderTypeFeature.B2BShipping,
|
||||
items: baseItems,
|
||||
};
|
||||
|
||||
case OrderType.Download:
|
||||
case OrderTypeFeature.Download:
|
||||
return {
|
||||
orderType: OrderType.Download,
|
||||
orderType: OrderTypeFeature.Download,
|
||||
items: baseItems.map((item) => ({
|
||||
itemId: item.itemId,
|
||||
ean: item.ean,
|
||||
@@ -141,7 +141,7 @@ export class GetAvailabilityParamsAdapter {
|
||||
|
||||
// Build single-item params based on order type
|
||||
switch (orderType) {
|
||||
case OrderType.InStore:
|
||||
case OrderTypeFeature.InStore:
|
||||
if (!targetBranch) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -150,7 +150,7 @@ export class GetAvailabilityParamsAdapter {
|
||||
branchId: targetBranch,
|
||||
itemId: itemObj.itemId,
|
||||
};
|
||||
case OrderType.Pickup:
|
||||
case OrderTypeFeature.Pickup:
|
||||
if (!targetBranch) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -160,10 +160,10 @@ export class GetAvailabilityParamsAdapter {
|
||||
item: itemObj,
|
||||
};
|
||||
|
||||
case OrderType.Delivery:
|
||||
case OrderType.DigitalShipping:
|
||||
case OrderType.B2BShipping:
|
||||
case OrderType.Download:
|
||||
case OrderTypeFeature.Delivery:
|
||||
case OrderTypeFeature.DigitalShipping:
|
||||
case OrderTypeFeature.B2BShipping:
|
||||
case OrderTypeFeature.Download:
|
||||
return {
|
||||
orderType,
|
||||
item: itemObj,
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
export * from './availability-type';
|
||||
export * from './availability';
|
||||
export * from './order-type';
|
||||
export * from './order-type-feature';
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { OrderTypeFeature } from '@isa/common/data-access';
|
||||
@@ -1 +0,0 @@
|
||||
export { OrderType } from '@isa/common/data-access';
|
||||
@@ -1,5 +1,5 @@
|
||||
import z from 'zod';
|
||||
import { OrderType } from '../models';
|
||||
import { OrderTypeFeature } from '../models';
|
||||
import { PriceSchema } from '@isa/common/data-access';
|
||||
|
||||
// TODO: [Schema Refactoring - Critical Priority] Eliminate single-item schema duplication
|
||||
@@ -35,7 +35,12 @@ const ItemSchema = z.object({
|
||||
itemId: z.coerce.number().int().positive().describe('Unique item identifier'),
|
||||
ean: z.string().describe('European Article Number barcode'),
|
||||
price: PriceSchema.describe('Item price information').optional(),
|
||||
quantity: z.coerce.number().int().positive().default(1).describe('Quantity of items to check availability for'),
|
||||
quantity: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.default(1)
|
||||
.describe('Quantity of items to check availability for'),
|
||||
});
|
||||
|
||||
// Download items don't require quantity (always 1)
|
||||
@@ -45,44 +50,76 @@ const DownloadItemSchema = z.object({
|
||||
price: PriceSchema.describe('Item price information').optional(),
|
||||
});
|
||||
|
||||
const ItemsSchema = z.array(ItemSchema).min(1).describe('List of items to check availability for');
|
||||
const DownloadItemsSchema = z.array(DownloadItemSchema).min(1).describe('List of download items to check availability for');
|
||||
const ItemsSchema = z
|
||||
.array(ItemSchema)
|
||||
.min(1)
|
||||
.describe('List of items to check availability for');
|
||||
const DownloadItemsSchema = z
|
||||
.array(DownloadItemSchema)
|
||||
.min(1)
|
||||
.describe('List of download items to check availability for');
|
||||
|
||||
// In-Store availability (Rücklage) - requires branch context
|
||||
export const GetInStoreAvailabilityParamsSchema = z.object({
|
||||
orderType: z.literal(OrderType.InStore).describe('Order type specifying in-store availability check'),
|
||||
branchId: z.coerce.number().int().positive().describe('Branch identifier for in-store availability').optional(),
|
||||
itemsIds: z.array(z.coerce.number().int().positive()).min(1).describe('List of item identifiers to check in-store availability'),
|
||||
orderType: z
|
||||
.literal(OrderTypeFeature.InStore)
|
||||
.describe('Order type specifying in-store availability check'),
|
||||
branchId: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.describe('Branch identifier for in-store availability')
|
||||
.optional(),
|
||||
itemsIds: z
|
||||
.array(z.coerce.number().int().positive())
|
||||
.min(1)
|
||||
.describe('List of item identifiers to check in-store availability'),
|
||||
});
|
||||
|
||||
// Pickup availability (Abholung) - requires branch context
|
||||
export const GetPickupAvailabilityParamsSchema = z.object({
|
||||
orderType: z.literal(OrderType.Pickup).describe('Order type specifying pickup availability check'),
|
||||
branchId: z.coerce.number().int().positive().describe('Branch identifier where items will be picked up'),
|
||||
orderType: z
|
||||
.literal(OrderTypeFeature.Pickup)
|
||||
.describe('Order type specifying pickup availability check'),
|
||||
branchId: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.describe('Branch identifier where items will be picked up'),
|
||||
items: ItemsSchema,
|
||||
});
|
||||
|
||||
// Standard delivery availability (Versand)
|
||||
export const GetDeliveryAvailabilityParamsSchema = z.object({
|
||||
orderType: z.literal(OrderType.Delivery).describe('Order type specifying standard delivery availability check'),
|
||||
orderType: z
|
||||
.literal(OrderTypeFeature.Delivery)
|
||||
.describe('Order type specifying standard delivery availability check'),
|
||||
items: ItemsSchema,
|
||||
});
|
||||
|
||||
// DIG delivery availability (DIG-Versand) - for webshop customers
|
||||
export const GetDigDeliveryAvailabilityParamsSchema = z.object({
|
||||
orderType: z.literal(OrderType.DigitalShipping).describe('Order type specifying DIG delivery availability check for webshop customers'),
|
||||
orderType: z
|
||||
.literal(OrderTypeFeature.DigitalShipping)
|
||||
.describe(
|
||||
'Order type specifying DIG delivery availability check for webshop customers',
|
||||
),
|
||||
items: ItemsSchema,
|
||||
});
|
||||
|
||||
// B2B delivery availability (B2B-Versand) - uses default branch
|
||||
export const GetB2bDeliveryAvailabilityParamsSchema = z.object({
|
||||
orderType: z.literal(OrderType.B2BShipping).describe('Order type specifying B2B delivery availability check'),
|
||||
orderType: z
|
||||
.literal(OrderTypeFeature.B2BShipping)
|
||||
.describe('Order type specifying B2B delivery availability check'),
|
||||
items: ItemsSchema,
|
||||
});
|
||||
|
||||
// Download availability - quantity always 1
|
||||
export const GetDownloadAvailabilityParamsSchema = z.object({
|
||||
orderType: z.literal(OrderType.Download).describe('Order type specifying download availability check'),
|
||||
orderType: z
|
||||
.literal(OrderTypeFeature.Download)
|
||||
.describe('Order type specifying download availability check'),
|
||||
items: DownloadItemsSchema,
|
||||
});
|
||||
|
||||
@@ -125,34 +162,61 @@ export type GetDownloadAvailabilityParams = z.infer<
|
||||
|
||||
// Single-item schemas use the same structure but accept a single item instead of an array
|
||||
const SingleInStoreAvailabilityParamsSchema = z.object({
|
||||
orderType: z.literal(OrderType.InStore).describe('Order type specifying in-store availability check'),
|
||||
branchId: z.coerce.number().int().positive().describe('Branch identifier for in-store availability').optional(),
|
||||
itemId: z.number().int().positive().describe('Unique item identifier to check in-store availability'),
|
||||
orderType: z
|
||||
.literal(OrderTypeFeature.InStore)
|
||||
.describe('Order type specifying in-store availability check'),
|
||||
branchId: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.describe('Branch identifier for in-store availability')
|
||||
.optional(),
|
||||
itemId: z
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.describe('Unique item identifier to check in-store availability'),
|
||||
});
|
||||
|
||||
const SinglePickupAvailabilityParamsSchema = z.object({
|
||||
orderType: z.literal(OrderType.Pickup).describe('Order type specifying pickup availability check'),
|
||||
branchId: z.coerce.number().int().positive().describe('Branch identifier where item will be picked up'),
|
||||
orderType: z
|
||||
.literal(OrderTypeFeature.Pickup)
|
||||
.describe('Order type specifying pickup availability check'),
|
||||
branchId: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.describe('Branch identifier where item will be picked up'),
|
||||
item: ItemSchema,
|
||||
});
|
||||
|
||||
const SingleDeliveryAvailabilityParamsSchema = z.object({
|
||||
orderType: z.literal(OrderType.Delivery).describe('Order type specifying standard delivery availability check'),
|
||||
orderType: z
|
||||
.literal(OrderTypeFeature.Delivery)
|
||||
.describe('Order type specifying standard delivery availability check'),
|
||||
item: ItemSchema,
|
||||
});
|
||||
|
||||
const SingleDigDeliveryAvailabilityParamsSchema = z.object({
|
||||
orderType: z.literal(OrderType.DigitalShipping).describe('Order type specifying DIG delivery availability check for webshop customers'),
|
||||
orderType: z
|
||||
.literal(OrderTypeFeature.DigitalShipping)
|
||||
.describe(
|
||||
'Order type specifying DIG delivery availability check for webshop customers',
|
||||
),
|
||||
item: ItemSchema,
|
||||
});
|
||||
|
||||
const SingleB2bDeliveryAvailabilityParamsSchema = z.object({
|
||||
orderType: z.literal(OrderType.B2BShipping).describe('Order type specifying B2B delivery availability check'),
|
||||
orderType: z
|
||||
.literal(OrderTypeFeature.B2BShipping)
|
||||
.describe('Order type specifying B2B delivery availability check'),
|
||||
item: ItemSchema,
|
||||
});
|
||||
|
||||
const SingleDownloadAvailabilityParamsSchema = z.object({
|
||||
orderType: z.literal(OrderType.Download).describe('Order type specifying download availability check'),
|
||||
orderType: z
|
||||
.literal(OrderTypeFeature.Download)
|
||||
.describe('Order type specifying download availability check'),
|
||||
item: DownloadItemSchema,
|
||||
});
|
||||
|
||||
|
||||
@@ -22,19 +22,22 @@ export class ShoppingCartFacade {
|
||||
return this.#shoppingCartService.createShoppingCart();
|
||||
}
|
||||
|
||||
getShoppingCart(shoppingCartId: number, abortSignal?: AbortSignal) {
|
||||
return this.#shoppingCartService.getShoppingCart(
|
||||
async getShoppingCart(shoppingCartId: number, abortSignal?: AbortSignal) {
|
||||
const sc = await this.#shoppingCartService.getShoppingCart(
|
||||
shoppingCartId,
|
||||
abortSignal,
|
||||
);
|
||||
return sc;
|
||||
}
|
||||
|
||||
removeItem(params: RemoveShoppingCartItemParams) {
|
||||
return this.#shoppingCartService.removeItem(params);
|
||||
async removeItem(params: RemoveShoppingCartItemParams) {
|
||||
const sc = await this.#shoppingCartService.removeItem(params);
|
||||
return sc;
|
||||
}
|
||||
|
||||
updateItem(params: UpdateShoppingCartItemParams) {
|
||||
return this.#shoppingCartService.updateItem(params);
|
||||
async updateItem(params: UpdateShoppingCartItemParams) {
|
||||
const sc = await this.#shoppingCartService.updateItem(params);
|
||||
return sc;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { OrderType } from '../models';
|
||||
import { OrderTypeFeature } from '@isa/common/data-access';
|
||||
|
||||
export function getOrderTypeFeature(
|
||||
features: Record<string, string> = {},
|
||||
): OrderType | undefined {
|
||||
): OrderTypeFeature | undefined {
|
||||
const orderType = features['orderType'];
|
||||
|
||||
if (orderType && Object.values(OrderType).includes(orderType as OrderType)) {
|
||||
return orderType as OrderType;
|
||||
if (
|
||||
orderType &&
|
||||
Object.values(OrderTypeFeature).includes(orderType as OrderTypeFeature)
|
||||
) {
|
||||
return orderType as OrderTypeFeature;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
import { DisplayOrderItem } from '@isa/oms/data-access';
|
||||
import { OrderType } from '../models';
|
||||
|
||||
/**
|
||||
* Represents a group of order items sharing the same branch (for pickup/in-store)
|
||||
* or all items for delivery types that don't have branch-specific grouping.
|
||||
*/
|
||||
export type OrderItemBranchGroup = {
|
||||
/** Branch ID (undefined for delivery types without branch grouping) */
|
||||
branchId?: number;
|
||||
/** Branch name (undefined for delivery types without branch grouping) */
|
||||
branchName?: string;
|
||||
/** Array of items in this branch group */
|
||||
items: DisplayOrderItem[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Order types that require grouping by branch/filiale.
|
||||
* Other order types (Versand, DIG-Versand, etc.) are grouped together without branch subdivision.
|
||||
*/
|
||||
const ORDER_TYPES_WITH_BRANCH_GROUPING = [
|
||||
OrderType.Pickup,
|
||||
OrderType.InStore,
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Sorts branch groups by branch ID (ascending).
|
||||
*/
|
||||
const sortByBranchId = (
|
||||
entries: [number, DisplayOrderItem[]][],
|
||||
): [number, DisplayOrderItem[]][] => {
|
||||
return [...entries].sort(([a], [b]) => a - b);
|
||||
};
|
||||
|
||||
/**
|
||||
* Groups display order items by their target branch.
|
||||
* Only applies branch-level grouping for Abholung (Pickup) and Rücklage (InStore) order types.
|
||||
* For other delivery types (Versand, DIG-Versand, etc.), returns a single group with all items.
|
||||
*
|
||||
* Uses item.order.targetBranch for grouping since items inherit their parent order's branch information.
|
||||
*
|
||||
* @param orderType - The order type to determine if branch grouping is needed
|
||||
* @param items - Array of DisplayOrderItem objects to group
|
||||
* @returns Array of OrderItemBranchGroup objects, each containing items for a specific branch
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // For Abholung (pickup) items from different branches
|
||||
* const pickupItems = [
|
||||
* { id: 1, order: { targetBranch: { id: 1, name: 'München' } } },
|
||||
* { id: 2, order: { targetBranch: { id: 2, name: 'Berlin' } } }
|
||||
* ];
|
||||
* const groups = groupDisplayOrderItemsByBranch('Abholung', pickupItems);
|
||||
* // [
|
||||
* // { branchId: 1, branchName: 'München', items: [item1] },
|
||||
* // { branchId: 2, branchName: 'Berlin', items: [item2] }
|
||||
* // ]
|
||||
*
|
||||
* // For Versand (delivery) items
|
||||
* const deliveryItems = [
|
||||
* { id: 1, ... },
|
||||
* { id: 2, ... }
|
||||
* ];
|
||||
* const groups = groupDisplayOrderItemsByBranch('Versand', deliveryItems);
|
||||
* // [
|
||||
* // { branchId: undefined, branchName: undefined, items: [item1, item2] }
|
||||
* // ]
|
||||
* ```
|
||||
*/
|
||||
export function groupDisplayOrderItemsByBranch(
|
||||
orderType: OrderType | string,
|
||||
items: DisplayOrderItem[],
|
||||
): OrderItemBranchGroup[] {
|
||||
const needsBranchGrouping =
|
||||
orderType === OrderType.Pickup || orderType === OrderType.InStore;
|
||||
|
||||
if (!needsBranchGrouping) {
|
||||
// For delivery types without branch grouping, return single group with all items
|
||||
return [
|
||||
{
|
||||
branchId: undefined,
|
||||
branchName: undefined,
|
||||
items,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// For Abholung/Rücklage, group by item.order.targetBranch.id
|
||||
const branchGroups = items.reduce((map, item) => {
|
||||
const branchId = item.order?.targetBranch?.id ?? 0;
|
||||
if (!map.has(branchId)) {
|
||||
map.set(branchId, []);
|
||||
}
|
||||
map.get(branchId)!.push(item);
|
||||
return map;
|
||||
}, new Map<number, DisplayOrderItem[]>());
|
||||
|
||||
// Convert Map to array of OrderItemBranchGroup, sorted by branch ID
|
||||
return sortByBranchId(Array.from(branchGroups.entries())).map(
|
||||
([branchId, branchItems]) => {
|
||||
const branch = branchItems[0]?.order?.targetBranch;
|
||||
|
||||
return {
|
||||
branchId: branchId || undefined,
|
||||
branchName: branch?.name,
|
||||
items: branchItems,
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
import { DisplayOrder, DisplayOrderItem } from '@isa/oms/data-access';
|
||||
import { getOrderTypeFeature } from './get-order-type-feature.helper';
|
||||
import { OrderType } from '../models';
|
||||
|
||||
/**
|
||||
* Groups display order items by their delivery type (item.features.orderType).
|
||||
*
|
||||
* Unlike groupDisplayOrdersByDeliveryType which groups entire orders,
|
||||
* this groups individual items since items within one order can have different delivery types.
|
||||
*
|
||||
* @param orders - Array of DisplayOrder objects containing items to group
|
||||
* @returns Map where keys are order types (Abholung, Rücklage, Versand, etc.)
|
||||
* and values are arrays of items with that type
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const orders = [
|
||||
* {
|
||||
* id: 1,
|
||||
* features: { orderType: 'Abholung' },
|
||||
* items: [
|
||||
* { id: 1, features: { orderType: 'Abholung' }, ... },
|
||||
* { id: 2, features: { orderType: 'Rücklage' }, ... }
|
||||
* ]
|
||||
* }
|
||||
* ];
|
||||
*
|
||||
* const grouped = groupDisplayOrderItemsByDeliveryType(orders);
|
||||
* // Map {
|
||||
* // 'Abholung' => [item1],
|
||||
* // 'Rücklage' => [item2]
|
||||
* // }
|
||||
* ```
|
||||
*/
|
||||
export function groupDisplayOrderItemsByDeliveryType(
|
||||
orders: DisplayOrder[],
|
||||
): Map<OrderType | string, DisplayOrderItem[]> {
|
||||
const allItems = orders.flatMap((order) => order.items ?? []);
|
||||
|
||||
return allItems.reduce((map, item) => {
|
||||
const orderType = getOrderTypeFeature(item.features) ?? 'Unbekannt';
|
||||
if (!map.has(orderType)) {
|
||||
map.set(orderType, []);
|
||||
}
|
||||
map.get(orderType)!.push(item);
|
||||
return map;
|
||||
}, new Map<OrderType | string, DisplayOrderItem[]>());
|
||||
}
|
||||
@@ -1,114 +1,115 @@
|
||||
import { DisplayOrder, DisplayOrderItem } from '@isa/oms/data-access';
|
||||
import { OrderType } from '../models';
|
||||
|
||||
/**
|
||||
* Represents a group of orders sharing the same branch (for pickup/in-store)
|
||||
* or all orders for delivery types that don't have branch-specific grouping.
|
||||
*/
|
||||
export type OrderBranchGroup = {
|
||||
/** Branch ID (undefined for delivery types without branch grouping) */
|
||||
branchId?: number;
|
||||
/** Branch name (undefined for delivery types without branch grouping) */
|
||||
branchName?: string;
|
||||
/** Array of orders in this branch group */
|
||||
orders: DisplayOrder[];
|
||||
/** Flattened array of all items from all orders in this group */
|
||||
allItems: DisplayOrderItem[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Order types that require grouping by branch/filiale.
|
||||
* Other order types (Versand, DIG-Versand, etc.) are grouped together without branch subdivision.
|
||||
*/
|
||||
const ORDER_TYPES_WITH_BRANCH_GROUPING = [
|
||||
OrderType.Pickup,
|
||||
OrderType.InStore,
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Sorts orders by branch ID (ascending).
|
||||
*/
|
||||
const sortByBranchId = (
|
||||
entries: [number, DisplayOrder[]][],
|
||||
): [number, DisplayOrder[]][] => {
|
||||
return [...entries].sort(([a], [b]) => a - b);
|
||||
};
|
||||
|
||||
/**
|
||||
* Groups display orders by their target branch.
|
||||
* Only applies branch-level grouping for Abholung (Pickup) and Rücklage (InStore) order types.
|
||||
* For other delivery types (Versand, DIG-Versand, etc.), returns a single group with all orders.
|
||||
*
|
||||
* @param orderType - The order type to determine if branch grouping is needed
|
||||
* @param orders - Array of DisplayOrder objects to group
|
||||
* @returns Array of OrderBranchGroup objects, each containing orders and items for a specific branch
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // For Abholung (pickup) orders
|
||||
* const pickupOrders = [
|
||||
* { targetBranch: { id: 1, name: 'München' }, items: [item1, item2] },
|
||||
* { targetBranch: { id: 2, name: 'Berlin' }, items: [item3] }
|
||||
* ];
|
||||
* const groups = groupDisplayOrdersByBranch('Abholung', pickupOrders);
|
||||
* // [
|
||||
* // { branchId: 1, branchName: 'München', orders: [...], allItems: [item1, item2] },
|
||||
* // { branchId: 2, branchName: 'Berlin', orders: [...], allItems: [item3] }
|
||||
* // ]
|
||||
*
|
||||
* // For Versand (delivery) orders
|
||||
* const deliveryOrders = [
|
||||
* { items: [item1] },
|
||||
* { items: [item2, item3] }
|
||||
* ];
|
||||
* const groups = groupDisplayOrdersByBranch('Versand', deliveryOrders);
|
||||
* // [
|
||||
* // { branchId: undefined, branchName: undefined, orders: [...], allItems: [item1, item2, item3] }
|
||||
* // ]
|
||||
* ```
|
||||
*/
|
||||
export function groupDisplayOrdersByBranch(
|
||||
orderType: OrderType | string,
|
||||
orders: DisplayOrder[],
|
||||
): OrderBranchGroup[] {
|
||||
const needsBranchGrouping =
|
||||
orderType === OrderType.Pickup || orderType === OrderType.InStore;
|
||||
|
||||
if (!needsBranchGrouping) {
|
||||
// For delivery types without branch grouping, return single group with all orders
|
||||
const allItems = orders.flatMap((order) => order.items ?? []);
|
||||
return [
|
||||
{
|
||||
branchId: undefined,
|
||||
branchName: undefined,
|
||||
orders,
|
||||
allItems,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// For Abholung/Rücklage, group by targetBranch.id
|
||||
const branchGroups = orders.reduce((map, order) => {
|
||||
const branchId = order.targetBranch?.id ?? 0;
|
||||
if (!map.has(branchId)) {
|
||||
map.set(branchId, []);
|
||||
}
|
||||
map.get(branchId)!.push(order);
|
||||
return map;
|
||||
}, new Map<number, DisplayOrder[]>());
|
||||
|
||||
// Convert Map to array of OrderBranchGroup, sorted by branch ID
|
||||
return sortByBranchId(Array.from(branchGroups.entries())).map(
|
||||
([branchId, branchOrders]) => {
|
||||
const branch = branchOrders[0]?.targetBranch;
|
||||
const allItems = branchOrders.flatMap((order) => order.items ?? []);
|
||||
|
||||
return {
|
||||
branchId: branchId || undefined,
|
||||
branchName: branch?.name,
|
||||
orders: branchOrders,
|
||||
allItems,
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
import { DisplayOrder, DisplayOrderItem } from '@isa/oms/data-access';
|
||||
import { OrderTypeFeature } from '../models';
|
||||
|
||||
/**
|
||||
* Represents a group of orders sharing the same branch (for pickup/in-store)
|
||||
* or all orders for delivery types that don't have branch-specific grouping.
|
||||
*/
|
||||
export type OrderBranchGroup = {
|
||||
/** Branch ID (undefined for delivery types without branch grouping) */
|
||||
branchId?: number;
|
||||
/** Branch name (undefined for delivery types without branch grouping) */
|
||||
branchName?: string;
|
||||
/** Array of orders in this branch group */
|
||||
orders: DisplayOrder[];
|
||||
/** Flattened array of all items from all orders in this group */
|
||||
allItems: DisplayOrderItem[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Order types that require grouping by branch/filiale.
|
||||
* Other order types (Versand, DIG-Versand, etc.) are grouped together without branch subdivision.
|
||||
*/
|
||||
const ORDER_TYPES_WITH_BRANCH_GROUPING = [
|
||||
OrderTypeFeature.Pickup,
|
||||
OrderTypeFeature.InStore,
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Sorts orders by branch ID (ascending).
|
||||
*/
|
||||
const sortByBranchId = (
|
||||
entries: [number, DisplayOrder[]][],
|
||||
): [number, DisplayOrder[]][] => {
|
||||
return [...entries].sort(([a], [b]) => a - b);
|
||||
};
|
||||
|
||||
/**
|
||||
* Groups display orders by their target branch.
|
||||
* Only applies branch-level grouping for Abholung (Pickup) and Rücklage (InStore) order types.
|
||||
* For other delivery types (Versand, DIG-Versand, etc.), returns a single group with all orders.
|
||||
*
|
||||
* @param orderType - The order type to determine if branch grouping is needed
|
||||
* @param orders - Array of DisplayOrder objects to group
|
||||
* @returns Array of OrderBranchGroup objects, each containing orders and items for a specific branch
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // For Abholung (pickup) orders
|
||||
* const pickupOrders = [
|
||||
* { targetBranch: { id: 1, name: 'München' }, items: [item1, item2] },
|
||||
* { targetBranch: { id: 2, name: 'Berlin' }, items: [item3] }
|
||||
* ];
|
||||
* const groups = groupDisplayOrdersByBranch('Abholung', pickupOrders);
|
||||
* // [
|
||||
* // { branchId: 1, branchName: 'München', orders: [...], allItems: [item1, item2] },
|
||||
* // { branchId: 2, branchName: 'Berlin', orders: [...], allItems: [item3] }
|
||||
* // ]
|
||||
*
|
||||
* // For Versand (delivery) orders
|
||||
* const deliveryOrders = [
|
||||
* { items: [item1] },
|
||||
* { items: [item2, item3] }
|
||||
* ];
|
||||
* const groups = groupDisplayOrdersByBranch('Versand', deliveryOrders);
|
||||
* // [
|
||||
* // { branchId: undefined, branchName: undefined, orders: [...], allItems: [item1, item2, item3] }
|
||||
* // ]
|
||||
* ```
|
||||
*/
|
||||
export function groupDisplayOrdersByBranch(
|
||||
orderType: OrderTypeFeature | string,
|
||||
orders: DisplayOrder[],
|
||||
): OrderBranchGroup[] {
|
||||
const needsBranchGrouping =
|
||||
orderType === OrderTypeFeature.Pickup ||
|
||||
orderType === OrderTypeFeature.InStore;
|
||||
|
||||
if (!needsBranchGrouping) {
|
||||
// For delivery types without branch grouping, return single group with all orders
|
||||
const allItems = orders.flatMap((order) => order.items ?? []);
|
||||
return [
|
||||
{
|
||||
branchId: undefined,
|
||||
branchName: undefined,
|
||||
orders,
|
||||
allItems,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// For Abholung/Rücklage, group by targetBranch.id
|
||||
const branchGroups = orders.reduce((map, order) => {
|
||||
const branchId = order.targetBranch?.id ?? 0;
|
||||
if (!map.has(branchId)) {
|
||||
map.set(branchId, []);
|
||||
}
|
||||
map.get(branchId)!.push(order);
|
||||
return map;
|
||||
}, new Map<number, DisplayOrder[]>());
|
||||
|
||||
// Convert Map to array of OrderBranchGroup, sorted by branch ID
|
||||
return sortByBranchId(Array.from(branchGroups.entries())).map(
|
||||
([branchId, branchOrders]) => {
|
||||
const branch = branchOrders[0]?.targetBranch;
|
||||
const allItems = branchOrders.flatMap((order) => order.items ?? []);
|
||||
|
||||
return {
|
||||
branchId: branchId || undefined,
|
||||
branchName: branch?.name,
|
||||
orders: branchOrders,
|
||||
allItems,
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,409 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
groupItemsByDeliveryDestination,
|
||||
SHIPPING_ORDER_TYPE_FEATURES,
|
||||
BRANCH_ORDER_TYPE_FEATURES,
|
||||
} from './group-items-by-delivery-destination.helper';
|
||||
import {
|
||||
DisplayOrderDTO,
|
||||
DisplayOrderItemDTO,
|
||||
BranchDTO,
|
||||
DisplayAddresseeDTO,
|
||||
} from '@generated/swagger/oms-api';
|
||||
import { OrderTypeFeature } from '@isa/common/data-access';
|
||||
|
||||
describe('groupItemsByDeliveryDestination', () => {
|
||||
it('should return empty array when orders array is empty', () => {
|
||||
const result = groupItemsByDeliveryDestination([]);
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should return empty array when orders is undefined', () => {
|
||||
const result = groupItemsByDeliveryDestination(undefined);
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should return empty array when orders is null', () => {
|
||||
const result = groupItemsByDeliveryDestination(null);
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should skip orders without items', () => {
|
||||
const orders: DisplayOrderDTO[] = [
|
||||
{
|
||||
id: '1',
|
||||
items: [],
|
||||
features: { orderType: OrderTypeFeature.Delivery },
|
||||
} as DisplayOrderDTO,
|
||||
];
|
||||
|
||||
const result = groupItemsByDeliveryDestination(orders);
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should skip items without valid order type', () => {
|
||||
const orders: DisplayOrderDTO[] = [
|
||||
{
|
||||
id: '1',
|
||||
items: [
|
||||
{ id: 'item1', features: {} } as DisplayOrderItemDTO,
|
||||
],
|
||||
features: {},
|
||||
} as DisplayOrderDTO,
|
||||
];
|
||||
|
||||
const result = groupItemsByDeliveryDestination(orders);
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should group single order with single item', () => {
|
||||
const orders: DisplayOrderDTO[] = [
|
||||
{
|
||||
id: '1',
|
||||
items: [
|
||||
{
|
||||
id: 'item1',
|
||||
features: { orderType: OrderTypeFeature.Delivery },
|
||||
} as DisplayOrderItemDTO,
|
||||
],
|
||||
features: { orderType: OrderTypeFeature.Delivery },
|
||||
} as DisplayOrderDTO,
|
||||
];
|
||||
|
||||
const result = groupItemsByDeliveryDestination(orders);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].orderType).toBe(OrderTypeFeature.Delivery);
|
||||
expect(result[0].orderTypeIcon).toBe('isaDeliveryVersand');
|
||||
expect(result[0].items).toHaveLength(1);
|
||||
expect(result[0].items[0].id).toBe('item1');
|
||||
});
|
||||
|
||||
it('should group items by shipping address for delivery orders', () => {
|
||||
const address1: DisplayAddresseeDTO = {
|
||||
firstName: 'John',
|
||||
lastName: 'Doe',
|
||||
gender: 0,
|
||||
address: {
|
||||
street: 'Main Street',
|
||||
streetNumber: '123',
|
||||
city: 'Berlin',
|
||||
zipCode: '10115',
|
||||
},
|
||||
} as DisplayAddresseeDTO;
|
||||
|
||||
const address2: DisplayAddresseeDTO = {
|
||||
firstName: 'Jane',
|
||||
lastName: 'Smith',
|
||||
gender: 0,
|
||||
address: {
|
||||
street: 'Second Street',
|
||||
streetNumber: '456',
|
||||
city: 'Munich',
|
||||
zipCode: '80331',
|
||||
},
|
||||
} as DisplayAddresseeDTO;
|
||||
|
||||
const orders: DisplayOrderDTO[] = [
|
||||
{
|
||||
id: '1',
|
||||
items: [
|
||||
{
|
||||
id: 'item1',
|
||||
features: { orderType: OrderTypeFeature.Delivery },
|
||||
} as DisplayOrderItemDTO,
|
||||
{
|
||||
id: 'item2',
|
||||
features: { orderType: OrderTypeFeature.Delivery },
|
||||
} as DisplayOrderItemDTO,
|
||||
],
|
||||
features: { orderType: OrderTypeFeature.Delivery },
|
||||
shippingAddress: address1,
|
||||
} as DisplayOrderDTO,
|
||||
{
|
||||
id: '2',
|
||||
items: [
|
||||
{
|
||||
id: 'item3',
|
||||
features: { orderType: OrderTypeFeature.Delivery },
|
||||
} as DisplayOrderItemDTO,
|
||||
],
|
||||
features: { orderType: OrderTypeFeature.Delivery },
|
||||
shippingAddress: address2,
|
||||
} as DisplayOrderDTO,
|
||||
];
|
||||
|
||||
const result = groupItemsByDeliveryDestination(orders);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
|
||||
const group1 = result.find(
|
||||
(g) => g.shippingAddress?.firstName === 'John' && g.shippingAddress?.lastName === 'Doe',
|
||||
);
|
||||
expect(group1).toBeDefined();
|
||||
expect(group1!.items).toHaveLength(2);
|
||||
expect(group1!.orderType).toBe(OrderTypeFeature.Delivery);
|
||||
expect(group1!.shippingAddress).toEqual(address1);
|
||||
|
||||
const group2 = result.find(
|
||||
(g) => g.shippingAddress?.firstName === 'Jane' && g.shippingAddress?.lastName === 'Smith',
|
||||
);
|
||||
expect(group2).toBeDefined();
|
||||
expect(group2!.items).toHaveLength(1);
|
||||
expect(group2!.orderType).toBe(OrderTypeFeature.Delivery);
|
||||
expect(group2!.shippingAddress).toEqual(address2);
|
||||
});
|
||||
|
||||
it('should group items by target branch for pickup orders', () => {
|
||||
const branch1: BranchDTO = {
|
||||
id: 'branch1',
|
||||
name: 'Branch 1',
|
||||
} as BranchDTO;
|
||||
|
||||
const branch2: BranchDTO = {
|
||||
id: 'branch2',
|
||||
name: 'Branch 2',
|
||||
} as BranchDTO;
|
||||
|
||||
const orders: DisplayOrderDTO[] = [
|
||||
{
|
||||
id: '1',
|
||||
items: [
|
||||
{
|
||||
id: 'item1',
|
||||
features: { orderType: OrderTypeFeature.Pickup },
|
||||
} as DisplayOrderItemDTO,
|
||||
],
|
||||
features: { orderType: OrderTypeFeature.Pickup },
|
||||
targetBranch: branch1,
|
||||
} as DisplayOrderDTO,
|
||||
{
|
||||
id: '2',
|
||||
items: [
|
||||
{
|
||||
id: 'item2',
|
||||
features: { orderType: OrderTypeFeature.Pickup },
|
||||
} as DisplayOrderItemDTO,
|
||||
],
|
||||
features: { orderType: OrderTypeFeature.Pickup },
|
||||
targetBranch: branch2,
|
||||
} as DisplayOrderDTO,
|
||||
];
|
||||
|
||||
const result = groupItemsByDeliveryDestination(orders);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
|
||||
const group1 = result.find((g) => g.targetBranch?.id === 'branch1');
|
||||
expect(group1).toBeDefined();
|
||||
expect(group1!.items).toHaveLength(1);
|
||||
expect(group1!.orderType).toBe(OrderTypeFeature.Pickup);
|
||||
|
||||
const group2 = result.find((g) => g.targetBranch?.id === 'branch2');
|
||||
expect(group2).toBeDefined();
|
||||
expect(group2!.items).toHaveLength(1);
|
||||
expect(group2!.orderType).toBe(OrderTypeFeature.Pickup);
|
||||
});
|
||||
|
||||
it('should group multiple items with same destination together', () => {
|
||||
const branch1: BranchDTO = {
|
||||
id: 'branch1',
|
||||
name: 'Branch 1',
|
||||
} as BranchDTO;
|
||||
|
||||
const orders: DisplayOrderDTO[] = [
|
||||
{
|
||||
id: '1',
|
||||
items: [
|
||||
{
|
||||
id: 'item1',
|
||||
features: { orderType: OrderTypeFeature.Pickup },
|
||||
} as DisplayOrderItemDTO,
|
||||
],
|
||||
features: { orderType: OrderTypeFeature.Pickup },
|
||||
targetBranch: branch1,
|
||||
} as DisplayOrderDTO,
|
||||
{
|
||||
id: '2',
|
||||
items: [
|
||||
{
|
||||
id: 'item2',
|
||||
features: { orderType: OrderTypeFeature.Pickup },
|
||||
} as DisplayOrderItemDTO,
|
||||
],
|
||||
features: { orderType: OrderTypeFeature.Pickup },
|
||||
targetBranch: branch1,
|
||||
} as DisplayOrderDTO,
|
||||
];
|
||||
|
||||
const result = groupItemsByDeliveryDestination(orders);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].items).toHaveLength(2);
|
||||
expect(result[0].targetBranch?.id).toBe('branch1');
|
||||
expect(result[0].orderType).toBe(OrderTypeFeature.Pickup);
|
||||
});
|
||||
|
||||
it('should handle mixed order types in different orders', () => {
|
||||
const branch1: BranchDTO = {
|
||||
id: 'branch1',
|
||||
name: 'Branch 1',
|
||||
} as BranchDTO;
|
||||
|
||||
const address1: DisplayAddresseeDTO = {
|
||||
firstName: 'Alice',
|
||||
lastName: 'Johnson',
|
||||
gender: 0,
|
||||
address: {
|
||||
street: 'Oak Street',
|
||||
streetNumber: '789',
|
||||
city: 'Hamburg',
|
||||
zipCode: '20095',
|
||||
},
|
||||
} as DisplayAddresseeDTO;
|
||||
|
||||
const orders: DisplayOrderDTO[] = [
|
||||
{
|
||||
id: '1',
|
||||
items: [
|
||||
{
|
||||
id: 'item1',
|
||||
features: { orderType: OrderTypeFeature.Pickup },
|
||||
} as DisplayOrderItemDTO,
|
||||
],
|
||||
features: { orderType: OrderTypeFeature.Pickup },
|
||||
targetBranch: branch1,
|
||||
} as DisplayOrderDTO,
|
||||
{
|
||||
id: '2',
|
||||
items: [
|
||||
{
|
||||
id: 'item2',
|
||||
features: { orderType: OrderTypeFeature.Delivery },
|
||||
} as DisplayOrderItemDTO,
|
||||
],
|
||||
features: { orderType: OrderTypeFeature.Delivery },
|
||||
shippingAddress: address1,
|
||||
} as DisplayOrderDTO,
|
||||
];
|
||||
|
||||
const result = groupItemsByDeliveryDestination(orders);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
|
||||
const pickupGroup = result.find(
|
||||
(g) => g.orderType === OrderTypeFeature.Pickup,
|
||||
);
|
||||
expect(pickupGroup).toBeDefined();
|
||||
expect(pickupGroup!.targetBranch?.id).toBe('branch1');
|
||||
|
||||
const deliveryGroup = result.find(
|
||||
(g) => g.orderType === OrderTypeFeature.Delivery,
|
||||
);
|
||||
expect(deliveryGroup).toBeDefined();
|
||||
expect(deliveryGroup!.shippingAddress).toEqual(address1);
|
||||
});
|
||||
|
||||
it('should include order features in the group', () => {
|
||||
const orders: DisplayOrderDTO[] = [
|
||||
{
|
||||
id: '1',
|
||||
items: [
|
||||
{
|
||||
id: 'item1',
|
||||
features: { orderType: OrderTypeFeature.Delivery },
|
||||
} as DisplayOrderItemDTO,
|
||||
],
|
||||
features: { orderType: OrderTypeFeature.Delivery, customFeature: 'value' },
|
||||
} as DisplayOrderDTO,
|
||||
];
|
||||
|
||||
const result = groupItemsByDeliveryDestination(orders);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].features).toEqual({
|
||||
orderType: OrderTypeFeature.Delivery,
|
||||
customFeature: 'value',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle B2BShipping as shipping order type', () => {
|
||||
const address1: DisplayAddresseeDTO = {
|
||||
firstName: 'Bob',
|
||||
lastName: 'Williams',
|
||||
gender: 0,
|
||||
address: {
|
||||
street: 'Business Ave',
|
||||
streetNumber: '100',
|
||||
city: 'Frankfurt',
|
||||
zipCode: '60311',
|
||||
},
|
||||
} as DisplayAddresseeDTO;
|
||||
|
||||
const orders: DisplayOrderDTO[] = [
|
||||
{
|
||||
id: '1',
|
||||
items: [
|
||||
{
|
||||
id: 'item1',
|
||||
features: { orderType: OrderTypeFeature.B2BShipping },
|
||||
} as DisplayOrderItemDTO,
|
||||
],
|
||||
features: { orderType: OrderTypeFeature.B2BShipping },
|
||||
shippingAddress: address1,
|
||||
} as DisplayOrderDTO,
|
||||
];
|
||||
|
||||
const result = groupItemsByDeliveryDestination(orders);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].orderType).toBe(OrderTypeFeature.B2BShipping);
|
||||
expect(result[0].orderTypeIcon).toBe('isaDeliveryB2BVersand1');
|
||||
expect(result[0].shippingAddress).toEqual(address1);
|
||||
});
|
||||
|
||||
it('should handle InStore as branch order type', () => {
|
||||
const branch1: BranchDTO = {
|
||||
id: 'branch1',
|
||||
name: 'Branch 1',
|
||||
} as BranchDTO;
|
||||
|
||||
const orders: DisplayOrderDTO[] = [
|
||||
{
|
||||
id: '1',
|
||||
items: [
|
||||
{
|
||||
id: 'item1',
|
||||
features: { orderType: OrderTypeFeature.InStore },
|
||||
} as DisplayOrderItemDTO,
|
||||
],
|
||||
features: { orderType: OrderTypeFeature.InStore },
|
||||
targetBranch: branch1,
|
||||
} as DisplayOrderDTO,
|
||||
];
|
||||
|
||||
const result = groupItemsByDeliveryDestination(orders);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].orderType).toBe(OrderTypeFeature.InStore);
|
||||
expect(result[0].orderTypeIcon).toBe('isaDeliveryRuecklage1');
|
||||
expect(result[0].targetBranch?.id).toBe('branch1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('SHIPPING_ORDER_TYPE_FEATURES', () => {
|
||||
it('should contain expected shipping order types', () => {
|
||||
expect(SHIPPING_ORDER_TYPE_FEATURES).toContain(OrderTypeFeature.Delivery);
|
||||
expect(SHIPPING_ORDER_TYPE_FEATURES).toContain(OrderTypeFeature.B2BShipping);
|
||||
expect(SHIPPING_ORDER_TYPE_FEATURES).toContain(OrderTypeFeature.DigitalShipping);
|
||||
expect(SHIPPING_ORDER_TYPE_FEATURES).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('BRANCH_ORDER_TYPE_FEATURES', () => {
|
||||
it('should contain expected branch order types', () => {
|
||||
expect(BRANCH_ORDER_TYPE_FEATURES).toContain(OrderTypeFeature.Pickup);
|
||||
expect(BRANCH_ORDER_TYPE_FEATURES).toContain(OrderTypeFeature.InStore);
|
||||
expect(BRANCH_ORDER_TYPE_FEATURES).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
import {
|
||||
BranchDTO,
|
||||
DisplayAddresseeDTO,
|
||||
DisplayOrderDTO,
|
||||
DisplayOrderItemDTO,
|
||||
} from '@generated/swagger/oms-api';
|
||||
import { getOrderTypeFeature } from './get-order-type-feature.helper';
|
||||
import { getOrderTypeIcon } from './get-order-type-icon.helper';
|
||||
import { OrderTypeFeature } from '@isa/common/data-access';
|
||||
|
||||
/**
|
||||
* Order types that are associated with shipping addresses
|
||||
*/
|
||||
export const SHIPPING_ORDER_TYPE_FEATURES: readonly OrderTypeFeature[] = [
|
||||
OrderTypeFeature.Delivery,
|
||||
OrderTypeFeature.B2BShipping,
|
||||
OrderTypeFeature.DigitalShipping,
|
||||
];
|
||||
|
||||
/**
|
||||
* Order types that are associated with physical branch locations
|
||||
*/
|
||||
export const BRANCH_ORDER_TYPE_FEATURES: readonly OrderTypeFeature[] = [
|
||||
OrderTypeFeature.Pickup,
|
||||
OrderTypeFeature.InStore,
|
||||
];
|
||||
|
||||
/**
|
||||
* Represents a group of order items with the same delivery type and destination
|
||||
*/
|
||||
export type OrderItemGroup = {
|
||||
/** The order type feature (e.g., 'Versand', 'Abholung') */
|
||||
orderType: OrderTypeFeature;
|
||||
/** The icon name for this order type */
|
||||
orderTypeIcon: string;
|
||||
/** The target branch for pickup/in-store orders */
|
||||
targetBranch?: BranchDTO;
|
||||
/** The shipping address for delivery orders */
|
||||
shippingAddress?: DisplayAddresseeDTO;
|
||||
/** Order features from the DisplayOrderDTO */
|
||||
features: Record<string, string>;
|
||||
/** Array of items in this group */
|
||||
items: DisplayOrderItemDTO[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Groups display order items by their delivery type and destination.
|
||||
*
|
||||
* Items are grouped by:
|
||||
* - Order type (Versand, Abholung, Rücklage, etc.)
|
||||
* - Destination (shipping address for delivery orders, branch for pickup/in-store orders)
|
||||
*
|
||||
* Uses Map-based lookups for O(1) performance instead of array.find().
|
||||
*
|
||||
* @param orders - Array of DisplayOrderDTO objects to process
|
||||
* @returns Array of OrderItemGroup objects, each containing items with the same delivery type and destination
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const orders = [
|
||||
* { id: '1', items: [item1, item2], features: { orderType: 'Versand' }, shippingAddress: addr1 },
|
||||
* { id: '2', items: [item3], features: { orderType: 'Abholung' }, targetBranch: branch1 }
|
||||
* ];
|
||||
*
|
||||
* const grouped = groupItemsByDeliveryDestination(orders);
|
||||
* // Returns: [
|
||||
* // { orderType: 'Versand', items: [item1, item2], shippingAddress: addr1, ... },
|
||||
* // { orderType: 'Abholung', items: [item3], targetBranch: branch1, ... }
|
||||
* // ]
|
||||
* ```
|
||||
*/
|
||||
export function groupItemsByDeliveryDestination(
|
||||
orders: readonly DisplayOrderDTO[] | undefined | null,
|
||||
): OrderItemGroup[] {
|
||||
const groupMap = new Map<string, OrderItemGroup>();
|
||||
|
||||
if (!orders?.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
for (const order of orders) {
|
||||
const targetBranch = order.targetBranch;
|
||||
const shippingAddress = order.shippingAddress;
|
||||
const features = order.features ?? {};
|
||||
|
||||
if (!order.items?.length) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const item of order.items) {
|
||||
const orderType = getOrderTypeFeature(item.features);
|
||||
|
||||
if (!orderType) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Generate unique key for this group
|
||||
let groupKey = orderType;
|
||||
|
||||
if (
|
||||
SHIPPING_ORDER_TYPE_FEATURES.includes(orderType) &&
|
||||
shippingAddress
|
||||
) {
|
||||
groupKey += `-shippingAddress-${JSON.stringify(shippingAddress)}`;
|
||||
}
|
||||
|
||||
if (BRANCH_ORDER_TYPE_FEATURES.includes(orderType) && targetBranch) {
|
||||
groupKey += `-targetBranch-${targetBranch.id}`;
|
||||
}
|
||||
|
||||
// Get or create group
|
||||
let group = groupMap.get(groupKey);
|
||||
|
||||
if (!group) {
|
||||
group = {
|
||||
orderType: orderType,
|
||||
orderTypeIcon: getOrderTypeIcon(orderType),
|
||||
targetBranch: targetBranch,
|
||||
shippingAddress: shippingAddress,
|
||||
features: features,
|
||||
items: [],
|
||||
};
|
||||
|
||||
groupMap.set(groupKey, group);
|
||||
}
|
||||
|
||||
group.items.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(groupMap.values());
|
||||
}
|
||||
@@ -1,38 +1,38 @@
|
||||
import { OrderType } from '../models';
|
||||
import { getOrderTypeFeature } from './get-order-type-feature.helper';
|
||||
|
||||
/**
|
||||
* Checks if the order type feature in the provided features record matches any of the specified order types.
|
||||
*
|
||||
* @param features - Record containing feature flags with an 'orderType' key
|
||||
* @param orderTypes - Array of order types to check against
|
||||
* @returns true if the feature's order type is one of the provided types, false otherwise
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Check if order type is a delivery type
|
||||
* const isDelivery = hasOrderTypeFeature(features, [
|
||||
* OrderType.Delivery,
|
||||
* OrderType.DigitalShipping,
|
||||
* OrderType.B2BShipping
|
||||
* ]);
|
||||
*
|
||||
* // Check if order type requires a target branch
|
||||
* const hasTargetBranch = hasOrderTypeFeature(features, [
|
||||
* OrderType.InStore,
|
||||
* OrderType.Pickup
|
||||
* ]);
|
||||
* ```
|
||||
*/
|
||||
export function hasOrderTypeFeature(
|
||||
features: Record<string, string> | undefined,
|
||||
orderTypes: readonly OrderType[],
|
||||
): boolean {
|
||||
const orderType = getOrderTypeFeature(features);
|
||||
|
||||
if (!orderType) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return orderTypes.includes(orderType);
|
||||
}
|
||||
import { OrderTypeFeature } from '../models';
|
||||
import { getOrderTypeFeature } from './get-order-type-feature.helper';
|
||||
|
||||
/**
|
||||
* Checks if the order type feature in the provided features record matches any of the specified order types.
|
||||
*
|
||||
* @param features - Record containing feature flags with an 'orderType' key
|
||||
* @param orderTypes - Array of order types to check against
|
||||
* @returns true if the feature's order type is one of the provided types, false otherwise
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Check if order type is a delivery type
|
||||
* const isDelivery = hasOrderTypeFeature(features, [
|
||||
* OrderType.Delivery,
|
||||
* OrderType.DigitalShipping,
|
||||
* OrderType.B2BShipping
|
||||
* ]);
|
||||
*
|
||||
* // Check if order type requires a target branch
|
||||
* const hasTargetBranch = hasOrderTypeFeature(features, [
|
||||
* OrderType.InStore,
|
||||
* OrderType.Pickup
|
||||
* ]);
|
||||
* ```
|
||||
*/
|
||||
export function hasOrderTypeFeature(
|
||||
features: Record<string, string> | undefined,
|
||||
orderTypes: readonly OrderTypeFeature[],
|
||||
): boolean {
|
||||
const orderType = getOrderTypeFeature(features);
|
||||
|
||||
if (!orderType) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return orderTypes.includes(orderType);
|
||||
}
|
||||
|
||||
@@ -12,8 +12,7 @@ export * from './group-by-branch.helper';
|
||||
export * from './group-by-order-type.helper';
|
||||
export * from './group-display-orders-by-branch.helper';
|
||||
export * from './group-display-orders-by-delivery-type.helper';
|
||||
export * from './group-display-order-items-by-branch.helper';
|
||||
export * from './group-display-order-items-by-delivery-type.helper';
|
||||
export * from './group-items-by-delivery-destination.helper';
|
||||
export * from './item-selection-changed.helper';
|
||||
export * from './merge-reward-selection-items.helper';
|
||||
export * from './should-show-grouping.helper';
|
||||
|
||||
@@ -3,10 +3,12 @@ export * from './campaign';
|
||||
export * from './checkout-item';
|
||||
export * from './checkout';
|
||||
export * from './customer-type-analysis';
|
||||
export * from './destination';
|
||||
export * from './gender';
|
||||
export * from './loyalty';
|
||||
export * from './ola-availability';
|
||||
export * from './order-options';
|
||||
export * from './order-type-feature';
|
||||
export * from './order-type';
|
||||
export * from './order';
|
||||
export * from './price';
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { OrderTypeFeature } from '@isa/common/data-access';
|
||||
@@ -7,27 +7,24 @@ import { ShoppingCartService } from '../services';
|
||||
export class ShoppingCartResource {
|
||||
#shoppingCartService = inject(ShoppingCartService);
|
||||
|
||||
#params = signal<{ shoppingCartId: number | undefined }>({
|
||||
shoppingCartId: undefined,
|
||||
});
|
||||
#params = signal<number | undefined>(undefined);
|
||||
|
||||
params(params: { shoppingCartId: number | undefined }) {
|
||||
this.#params.set(params);
|
||||
setShoppingCartId(shoppingCartId: number | undefined) {
|
||||
this.#params.set(shoppingCartId);
|
||||
|
||||
if (shoppingCartId === undefined) {
|
||||
this.resource.set(undefined);
|
||||
}
|
||||
}
|
||||
|
||||
readonly resource = resource({
|
||||
params: () => this.#params(),
|
||||
loader: ({ params, abortSignal }) =>
|
||||
params?.shoppingCartId
|
||||
? this.#shoppingCartService.getShoppingCart(
|
||||
params.shoppingCartId!,
|
||||
abortSignal,
|
||||
)
|
||||
: Promise.resolve(null),
|
||||
this.#shoppingCartService.getShoppingCart(params, abortSignal),
|
||||
});
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class SelectedShoppingCartResource extends ShoppingCartResource {
|
||||
#tabId = inject(TabService).activatedTabId;
|
||||
#checkoutMetadata = inject(CheckoutMetadataService);
|
||||
@@ -41,12 +38,12 @@ export class SelectedShoppingCartResource extends ShoppingCartResource {
|
||||
? this.#checkoutMetadata.getShoppingCartId(tabId)
|
||||
: undefined;
|
||||
|
||||
this.params({ shoppingCartId });
|
||||
this.setShoppingCartId(shoppingCartId);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class SelectedRewardShoppingCartResource extends ShoppingCartResource {
|
||||
#tabId = inject(TabService).activatedTabId;
|
||||
#checkoutMetadata = inject(CheckoutMetadataService);
|
||||
@@ -60,7 +57,7 @@ export class SelectedRewardShoppingCartResource extends ShoppingCartResource {
|
||||
? this.#checkoutMetadata.getRewardShoppingCartId(tabId)
|
||||
: undefined;
|
||||
|
||||
this.params({ shoppingCartId });
|
||||
this.setShoppingCartId(shoppingCartId);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,5 +30,3 @@ export const DestinationSchema = z.object({
|
||||
.describe('Target branch')
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export type Destination = z.infer<typeof DestinationSchema>;
|
||||
|
||||
@@ -76,20 +76,4 @@ export class CheckoutMetadataService {
|
||||
this.#tabService.entityMap(),
|
||||
);
|
||||
}
|
||||
|
||||
getCompletedShoppingCarts(tabId: number): ShoppingCart[] | undefined {
|
||||
return getMetadataHelper(
|
||||
tabId,
|
||||
COMPLETED_SHOPPING_CARTS_METADATA_KEY,
|
||||
z.array(z.any()).optional(),
|
||||
this.#tabService.entityMap(),
|
||||
);
|
||||
}
|
||||
|
||||
addCompletedShoppingCart(tabId: number, shoppingCart: ShoppingCart) {
|
||||
const existingCarts = this.getCompletedShoppingCarts(tabId) || [];
|
||||
this.#tabService.patchTabMetadata(tabId, {
|
||||
[COMPLETED_SHOPPING_CARTS_METADATA_KEY]: [...existingCarts, shoppingCart],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ import {
|
||||
import { CheckoutCompletionError } from '../errors';
|
||||
import {
|
||||
AvailabilityService,
|
||||
OrderType,
|
||||
OrderTypeFeature,
|
||||
Availability as AvailabilityModel,
|
||||
} from '@isa/availability/data-access';
|
||||
import { AvailabilityAdapter } from '../adapters';
|
||||
@@ -451,7 +451,7 @@ export class CheckoutService {
|
||||
const availabilitiesDict =
|
||||
await this.#availabilityService.getAvailabilities(
|
||||
{
|
||||
orderType: OrderType.Download,
|
||||
orderType: OrderTypeFeature.Download,
|
||||
items: availabilityItems,
|
||||
},
|
||||
abortSignal,
|
||||
@@ -546,7 +546,7 @@ export class CheckoutService {
|
||||
if (orderType === 'DIG-Versand') {
|
||||
availability = await this.#availabilityService.getAvailability(
|
||||
{
|
||||
orderType: OrderType.DigitalShipping,
|
||||
orderType: OrderTypeFeature.DigitalShipping,
|
||||
item: availabilityItem,
|
||||
},
|
||||
abortSignal,
|
||||
@@ -554,7 +554,7 @@ export class CheckoutService {
|
||||
} else if (orderType === 'B2B-Versand') {
|
||||
availability = await this.#availabilityService.getAvailability(
|
||||
{
|
||||
orderType: OrderType.B2BShipping,
|
||||
orderType: OrderTypeFeature.B2BShipping,
|
||||
item: availabilityItem,
|
||||
},
|
||||
abortSignal,
|
||||
@@ -576,7 +576,8 @@ export class CheckoutService {
|
||||
availabilityDTO.price.value = {
|
||||
value: originalPrice,
|
||||
currency: availabilityDTO.price.value?.currency ?? 'EUR',
|
||||
currencySymbol: availabilityDTO.price.value?.currencySymbol ?? '€',
|
||||
currencySymbol:
|
||||
availabilityDTO.price.value?.currencySymbol ?? '€',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { ChangeDetectionStrategy, Component, computed, inject, input } from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { DBHOrderItemListItemDTO } from '@generated/swagger/oms-api';
|
||||
import { TabService } from '@isa/core/tabs';
|
||||
import { IconButtonComponent } from '@isa/ui/buttons';
|
||||
import { provideIcons } from '@ng-icons/core';
|
||||
import { isaActionChevronRight } from '@isa/icons';
|
||||
|
||||
/**
|
||||
* Card component displaying a single open reward task (Prämienausgabe).
|
||||
*
|
||||
* Shows customer name and a chevron button for navigation to the order completion page.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'reward-catalog-open-task-card',
|
||||
standalone: true,
|
||||
imports: [IconButtonComponent, RouterLink],
|
||||
providers: [provideIcons({ isaActionChevronRight })],
|
||||
template: `
|
||||
<a
|
||||
class="bg-isa-white flex items-center justify-between px-[22px] py-[20px] rounded-2xl w-[334px] cursor-pointer no-underline"
|
||||
data-what="open-task-card"
|
||||
[attr.data-which]="task().orderItemId"
|
||||
[routerLink]="routePath()"
|
||||
>
|
||||
<div class="flex flex-col gap-1">
|
||||
<p class="isa-text-body-1-regular text-isa-neutral-900">
|
||||
Offene Prämienausgabe
|
||||
</p>
|
||||
<p class="isa-text-body-1-bold text-isa-neutral-900">
|
||||
{{ customerName() }}
|
||||
</p>
|
||||
</div>
|
||||
<ui-icon-button
|
||||
name="isaActionChevronRight"
|
||||
[color]="'secondary'"
|
||||
size="medium"
|
||||
data-what="open-task-card-button"
|
||||
/>
|
||||
</a>
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class OpenTaskCardComponent {
|
||||
readonly #tabService = inject(TabService);
|
||||
|
||||
/**
|
||||
* The open task data to display
|
||||
*/
|
||||
readonly task = input.required<DBHOrderItemListItemDTO>();
|
||||
|
||||
/**
|
||||
* Computed customer name from first and last name
|
||||
*/
|
||||
readonly customerName = (): string => {
|
||||
const t = this.task();
|
||||
const firstName = t.firstName || '';
|
||||
const lastName = t.lastName || '';
|
||||
return `${firstName} ${lastName}`.trim() || 'Unbekannt';
|
||||
};
|
||||
|
||||
/**
|
||||
* Current tab ID for navigation
|
||||
*/
|
||||
readonly #tabId = computed(() => this.#tabService.activatedTab()?.id ?? Date.now());
|
||||
|
||||
/**
|
||||
* Route path to the reward order confirmation page.
|
||||
* Route: /:tabId/reward/order-confirmation/:orderId
|
||||
*/
|
||||
readonly routePath = computed(() => {
|
||||
const orderId = this.task().orderId;
|
||||
if (!orderId) {
|
||||
console.warn('Missing orderId in task', this.task());
|
||||
return [];
|
||||
}
|
||||
return ['/', this.#tabId(), 'reward', 'order-confirmation', orderId.toString()];
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
|
||||
import { OpenRewardTasksResource } from '@isa/oms/data-access';
|
||||
import { CarouselComponent } from '@isa/ui/carousel';
|
||||
import { OpenTaskCardComponent } from './open-task-card.component';
|
||||
|
||||
/**
|
||||
* Carousel component displaying open reward distribution tasks (Prämienausgabe).
|
||||
*
|
||||
* Shows a horizontal scrollable list of unfinished reward orders at the top of the
|
||||
* reward catalog. Hidden when no open tasks exist.
|
||||
*
|
||||
* Features:
|
||||
* - Keyboard navigation (Arrow Left/Right)
|
||||
* - Automatic visibility based on task availability
|
||||
* - Shared global resource for consistent data across app
|
||||
*/
|
||||
@Component({
|
||||
selector: 'reward-catalog-open-tasks-carousel',
|
||||
standalone: true,
|
||||
imports: [CarouselComponent, OpenTaskCardComponent],
|
||||
template: `
|
||||
@if (openTasksResource.hasOpenTasks()) {
|
||||
<div class="mb-4" data-what="open-tasks-carousel">
|
||||
<ui-carousel [gap]="'1rem'" [arrowAutoHide]="true">
|
||||
@for (task of openTasksResource.tasks(); track task.orderItemId) {
|
||||
<reward-catalog-open-task-card [task]="task" />
|
||||
}
|
||||
</ui-carousel>
|
||||
</div>
|
||||
}
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class OpenTasksCarouselComponent {
|
||||
/**
|
||||
* Global resource managing open reward tasks data
|
||||
*/
|
||||
readonly openTasksResource = inject(OpenRewardTasksResource);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
RewardCatalogStore,
|
||||
CheckoutMetadataService,
|
||||
ShoppingCartFacade,
|
||||
SelectedRewardShoppingCartResource,
|
||||
} from '@isa/checkout/data-access';
|
||||
import { injectTabId } from '@isa/core/tabs';
|
||||
import { StatefulButtonComponent, StatefulButtonState } from '@isa/ui/buttons';
|
||||
@@ -36,6 +37,9 @@ export class RewardActionComponent {
|
||||
#shoppingCartFacade = inject(ShoppingCartFacade);
|
||||
#checkoutMetadataService = inject(CheckoutMetadataService);
|
||||
#primaryBonudCardResource = inject(PrimaryCustomerCardResource);
|
||||
#selectedRewardShoppingCartResource = inject(
|
||||
SelectedRewardShoppingCartResource,
|
||||
);
|
||||
|
||||
readonly primaryCustomerCardValue =
|
||||
this.#primaryBonudCardResource.primaryCustomerCard;
|
||||
@@ -89,12 +93,13 @@ export class RewardActionComponent {
|
||||
});
|
||||
|
||||
const result = await firstValueFrom(modalRef.afterClosed$);
|
||||
this.addRewardButtonState.set('success');
|
||||
|
||||
if (!result?.data) {
|
||||
return;
|
||||
}
|
||||
this.addRewardButtonState.set('success');
|
||||
|
||||
this.#selectedRewardShoppingCartResource.resource.reload();
|
||||
if (result.data !== 'continue-shopping') {
|
||||
await this.#navigation(tabId);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
<reward-catalog-open-tasks-carousel></reward-catalog-open-tasks-carousel>
|
||||
<reward-header></reward-header>
|
||||
<filter-controls-panel
|
||||
[switchFilters]="displayStockFilterSwitch()"
|
||||
|
||||
@@ -1,83 +1,85 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
inject,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import {
|
||||
provideFilter,
|
||||
withQuerySettingsFactory,
|
||||
withQueryParamsSync,
|
||||
FilterControlsPanelComponent,
|
||||
SearchTrigger,
|
||||
FilterService,
|
||||
FilterInput,
|
||||
} from '@isa/shared/filter';
|
||||
import { RewardHeaderComponent } from './reward-header/reward-header.component';
|
||||
import { RewardListComponent } from './reward-list/reward-list.component';
|
||||
import { injectRestoreScrollPosition } from '@isa/utils/scroll-position';
|
||||
import { RewardActionComponent } from './reward-action/reward-action.component';
|
||||
import { SelectedRewardShoppingCartResource } from '@isa/checkout/data-access';
|
||||
import { SelectedCustomerResource } from '@isa/crm/data-access';
|
||||
|
||||
/**
|
||||
* Factory function to retrieve query settings from the activated route data.
|
||||
* @returns The query settings from the activated route data.
|
||||
*/
|
||||
function querySettingsFactory() {
|
||||
return inject(ActivatedRoute).snapshot.data['querySettings'];
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'reward-catalog',
|
||||
templateUrl: './reward-catalog.component.html',
|
||||
styleUrl: './reward-catalog.component.css',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
providers: [
|
||||
SelectedRewardShoppingCartResource,
|
||||
SelectedCustomerResource,
|
||||
provideFilter(
|
||||
withQuerySettingsFactory(querySettingsFactory),
|
||||
withQueryParamsSync(),
|
||||
),
|
||||
],
|
||||
imports: [
|
||||
FilterControlsPanelComponent,
|
||||
RewardHeaderComponent,
|
||||
RewardListComponent,
|
||||
RewardActionComponent,
|
||||
],
|
||||
host: {
|
||||
'[class]':
|
||||
'"w-full flex flex-col gap-4 mt-5 isa-desktop:mt-6 overflow-x-hidden"',
|
||||
},
|
||||
})
|
||||
export class RewardCatalogComponent {
|
||||
restoreScrollPosition = injectRestoreScrollPosition();
|
||||
|
||||
searchTrigger = signal<SearchTrigger | 'reload' | 'initial'>('initial');
|
||||
|
||||
#filterService = inject(FilterService);
|
||||
|
||||
displayStockFilterSwitch = computed(() => {
|
||||
const stockInput = this.#filterService
|
||||
.inputs()
|
||||
?.filter((input) => input.target === 'filter')
|
||||
?.find((input) => input.key === 'stock') as FilterInput | undefined;
|
||||
return stockInput
|
||||
? [
|
||||
{
|
||||
filter: stockInput,
|
||||
icon: 'isaFiliale',
|
||||
},
|
||||
]
|
||||
: [];
|
||||
});
|
||||
|
||||
search(trigger: SearchTrigger): void {
|
||||
this.searchTrigger.set(trigger); // Ist entweder 'scan', 'input', 'filter' oder 'orderBy'
|
||||
this.#filterService.commit();
|
||||
}
|
||||
}
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
inject,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import {
|
||||
provideFilter,
|
||||
withQuerySettingsFactory,
|
||||
withQueryParamsSync,
|
||||
FilterControlsPanelComponent,
|
||||
SearchTrigger,
|
||||
FilterService,
|
||||
FilterInput,
|
||||
} from '@isa/shared/filter';
|
||||
import { RewardHeaderComponent } from './reward-header/reward-header.component';
|
||||
import { RewardListComponent } from './reward-list/reward-list.component';
|
||||
import { injectRestoreScrollPosition } from '@isa/utils/scroll-position';
|
||||
import { RewardActionComponent } from './reward-action/reward-action.component';
|
||||
import { SelectedRewardShoppingCartResource } from '@isa/checkout/data-access';
|
||||
import { SelectedCustomerResource } from '@isa/crm/data-access';
|
||||
import { OpenTasksCarouselComponent } from './open-tasks-carousel/open-tasks-carousel.component';
|
||||
|
||||
/**
|
||||
* Factory function to retrieve query settings from the activated route data.
|
||||
* @returns The query settings from the activated route data.
|
||||
*/
|
||||
function querySettingsFactory() {
|
||||
return inject(ActivatedRoute).snapshot.data['querySettings'];
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'reward-catalog',
|
||||
templateUrl: './reward-catalog.component.html',
|
||||
styleUrl: './reward-catalog.component.css',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
providers: [
|
||||
SelectedRewardShoppingCartResource,
|
||||
SelectedCustomerResource,
|
||||
provideFilter(
|
||||
withQuerySettingsFactory(querySettingsFactory),
|
||||
withQueryParamsSync(),
|
||||
),
|
||||
],
|
||||
imports: [
|
||||
FilterControlsPanelComponent,
|
||||
OpenTasksCarouselComponent,
|
||||
RewardHeaderComponent,
|
||||
RewardListComponent,
|
||||
RewardActionComponent,
|
||||
],
|
||||
host: {
|
||||
'[class]':
|
||||
'"w-full flex flex-col gap-4 mt-5 isa-desktop:mt-6 overflow-x-hidden"',
|
||||
},
|
||||
})
|
||||
export class RewardCatalogComponent {
|
||||
restoreScrollPosition = injectRestoreScrollPosition();
|
||||
|
||||
searchTrigger = signal<SearchTrigger | 'reload' | 'initial'>('initial');
|
||||
|
||||
#filterService = inject(FilterService);
|
||||
|
||||
displayStockFilterSwitch = computed(() => {
|
||||
const stockInput = this.#filterService
|
||||
.inputs()
|
||||
?.filter((input) => input.target === 'filter')
|
||||
?.find((input) => input.key === 'stock') as FilterInput | undefined;
|
||||
return stockInput
|
||||
? [
|
||||
{
|
||||
filter: stockInput,
|
||||
icon: 'isaFiliale',
|
||||
},
|
||||
]
|
||||
: [];
|
||||
});
|
||||
|
||||
search(trigger: SearchTrigger): void {
|
||||
this.searchTrigger.set(trigger); // Ist entweder 'scan', 'input', 'filter' oder 'orderBy'
|
||||
this.#filterService.commit();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,4 +29,20 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
@if (hasTargetBranchFeature()) {
|
||||
@for (targetBranch of targetBranches(); track targetBranch) {
|
||||
@if (targetBranch.address) {
|
||||
<div>
|
||||
<h3 class="isa-text-body-1-regular">Abholfiliale</h3>
|
||||
<div class="isa-text-body-1-bold mt-1">
|
||||
{{ targetBranch.name }}
|
||||
</div>
|
||||
<shared-address
|
||||
class="isa-text-body-1-bold"
|
||||
[address]="targetBranch.address"
|
||||
></shared-address>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -1,272 +1,272 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { OrderConfirmationAddressesComponent } from './order-confirmation-addresses.component';
|
||||
import { OrderConfiramtionStore } from '../reward-order-confirmation.store';
|
||||
import { signal } from '@angular/core';
|
||||
import { DebugElement } from '@angular/core';
|
||||
import { By } from '@angular/platform-browser';
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
|
||||
describe('OrderConfirmationAddressesComponent', () => {
|
||||
let component: OrderConfirmationAddressesComponent;
|
||||
let fixture: ComponentFixture<OrderConfirmationAddressesComponent>;
|
||||
let mockStore: {
|
||||
payers: ReturnType<typeof signal>;
|
||||
shippingAddresses: ReturnType<typeof signal>;
|
||||
hasDeliveryOrderTypeFeature: ReturnType<typeof signal>;
|
||||
targetBranches: ReturnType<typeof signal>;
|
||||
hasTargetBranchFeature: ReturnType<typeof signal>;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
// Create mock store with signals
|
||||
mockStore = {
|
||||
payers: signal([]),
|
||||
shippingAddresses: signal([]),
|
||||
hasDeliveryOrderTypeFeature: signal(false),
|
||||
targetBranches: signal([]),
|
||||
hasTargetBranchFeature: signal(false),
|
||||
};
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
imports: [OrderConfirmationAddressesComponent],
|
||||
providers: [
|
||||
{ provide: OrderConfiramtionStore, useValue: mockStore },
|
||||
provideHttpClient(),
|
||||
],
|
||||
});
|
||||
|
||||
fixture = TestBed.createComponent(OrderConfirmationAddressesComponent);
|
||||
component = fixture.componentInstance;
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should render payer address when available', () => {
|
||||
// Arrange
|
||||
mockStore.payers.set([
|
||||
{
|
||||
firstName: 'John',
|
||||
lastName: 'Doe',
|
||||
address: {
|
||||
street: 'Main St',
|
||||
streetNumber: '123',
|
||||
zipCode: '12345',
|
||||
city: 'Berlin',
|
||||
country: 'DE',
|
||||
},
|
||||
} as any,
|
||||
]);
|
||||
|
||||
// Act
|
||||
fixture.detectChanges();
|
||||
|
||||
// Assert
|
||||
const heading = fixture.debugElement.query(By.css('h3'));
|
||||
expect(heading).toBeTruthy();
|
||||
expect(heading.nativeElement.textContent.trim()).toBe('Rechnungsadresse');
|
||||
|
||||
const customerName = fixture.debugElement.query(
|
||||
By.css('.isa-text-body-1-bold.mt-1'),
|
||||
);
|
||||
expect(customerName).toBeTruthy();
|
||||
expect(customerName.nativeElement.textContent.trim()).toContain('John Doe');
|
||||
});
|
||||
|
||||
it('should not render payer address when address is missing', () => {
|
||||
// Arrange
|
||||
mockStore.payers.set([
|
||||
{
|
||||
firstName: 'John',
|
||||
lastName: 'Doe',
|
||||
address: undefined,
|
||||
} as any,
|
||||
]);
|
||||
|
||||
// Act
|
||||
fixture.detectChanges();
|
||||
|
||||
// Assert
|
||||
const heading = fixture.debugElement.query(By.css('h3'));
|
||||
expect(heading).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should render shipping address when hasDeliveryOrderTypeFeature is true', () => {
|
||||
// Arrange
|
||||
mockStore.hasDeliveryOrderTypeFeature.set(true);
|
||||
mockStore.shippingAddresses.set([
|
||||
{
|
||||
firstName: 'Jane',
|
||||
lastName: 'Smith',
|
||||
address: {
|
||||
street: 'Delivery St',
|
||||
streetNumber: '456',
|
||||
zipCode: '54321',
|
||||
city: 'Hamburg',
|
||||
country: 'DE',
|
||||
},
|
||||
} as any,
|
||||
]);
|
||||
|
||||
// Act
|
||||
fixture.detectChanges();
|
||||
|
||||
// Assert
|
||||
const headings: DebugElement[] = fixture.debugElement.queryAll(
|
||||
By.css('h3'),
|
||||
);
|
||||
const deliveryHeading = headings.find(
|
||||
(h) => h.nativeElement.textContent.trim() === 'Lieferadresse',
|
||||
);
|
||||
|
||||
expect(deliveryHeading).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should not render shipping address when hasDeliveryOrderTypeFeature is false', () => {
|
||||
// Arrange
|
||||
mockStore.hasDeliveryOrderTypeFeature.set(false);
|
||||
mockStore.shippingAddresses.set([
|
||||
{
|
||||
firstName: 'Jane',
|
||||
lastName: 'Smith',
|
||||
address: {
|
||||
street: 'Delivery St',
|
||||
streetNumber: '456',
|
||||
zipCode: '54321',
|
||||
city: 'Hamburg',
|
||||
country: 'DE',
|
||||
},
|
||||
} as any,
|
||||
]);
|
||||
|
||||
// Act
|
||||
fixture.detectChanges();
|
||||
|
||||
// Assert
|
||||
const headings: DebugElement[] = fixture.debugElement.queryAll(
|
||||
By.css('h3'),
|
||||
);
|
||||
const deliveryHeading = headings.find(
|
||||
(h) => h.nativeElement.textContent.trim() === 'Lieferadresse',
|
||||
);
|
||||
|
||||
expect(deliveryHeading).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should render target branch when hasTargetBranchFeature is true', () => {
|
||||
// Arrange
|
||||
mockStore.hasTargetBranchFeature.set(true);
|
||||
mockStore.targetBranches.set([
|
||||
{
|
||||
name: 'Branch Berlin',
|
||||
address: {
|
||||
street: 'Branch St',
|
||||
streetNumber: '789',
|
||||
zipCode: '10115',
|
||||
city: 'Berlin',
|
||||
country: 'DE',
|
||||
},
|
||||
} as any,
|
||||
]);
|
||||
|
||||
// Act
|
||||
fixture.detectChanges();
|
||||
|
||||
// Assert - Target branch is not yet implemented in the template
|
||||
// This test verifies that the component properties are correctly set
|
||||
expect(component.hasTargetBranchFeature()).toBe(true);
|
||||
expect(component.targetBranches().length).toBe(1);
|
||||
expect(component.targetBranches()[0].name).toBe('Branch Berlin');
|
||||
});
|
||||
|
||||
it('should not render target branch when hasTargetBranchFeature is false', () => {
|
||||
// Arrange
|
||||
mockStore.hasTargetBranchFeature.set(false);
|
||||
mockStore.targetBranches.set([
|
||||
{
|
||||
name: 'Branch Berlin',
|
||||
address: {
|
||||
street: 'Branch St',
|
||||
streetNumber: '789',
|
||||
zipCode: '10115',
|
||||
city: 'Berlin',
|
||||
country: 'DE',
|
||||
},
|
||||
} as any,
|
||||
]);
|
||||
|
||||
// Act
|
||||
fixture.detectChanges();
|
||||
|
||||
// Assert
|
||||
const headings: DebugElement[] = fixture.debugElement.queryAll(
|
||||
By.css('h3'),
|
||||
);
|
||||
const branchHeading = headings.find(
|
||||
(h) => h.nativeElement.textContent.trim() === 'Abholfiliale',
|
||||
);
|
||||
|
||||
expect(branchHeading).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should render multiple addresses when all features are enabled', () => {
|
||||
// Arrange
|
||||
mockStore.payers.set([
|
||||
{
|
||||
firstName: 'John',
|
||||
lastName: 'Doe',
|
||||
address: {
|
||||
street: 'Payer St',
|
||||
streetNumber: '1',
|
||||
zipCode: '11111',
|
||||
city: 'City1',
|
||||
country: 'DE',
|
||||
},
|
||||
} as any,
|
||||
]);
|
||||
mockStore.hasDeliveryOrderTypeFeature.set(true);
|
||||
mockStore.shippingAddresses.set([
|
||||
{
|
||||
firstName: 'Jane',
|
||||
lastName: 'Smith',
|
||||
address: {
|
||||
street: 'Delivery St',
|
||||
streetNumber: '2',
|
||||
zipCode: '22222',
|
||||
city: 'City2',
|
||||
country: 'DE',
|
||||
},
|
||||
} as any,
|
||||
]);
|
||||
mockStore.hasTargetBranchFeature.set(true);
|
||||
mockStore.targetBranches.set([
|
||||
{
|
||||
name: 'Branch Test',
|
||||
address: {
|
||||
street: 'Branch St',
|
||||
streetNumber: '3',
|
||||
zipCode: '33333',
|
||||
city: 'City3',
|
||||
country: 'DE',
|
||||
},
|
||||
} as any,
|
||||
]);
|
||||
|
||||
// Act
|
||||
fixture.detectChanges();
|
||||
|
||||
// Assert - Only Payer and Shipping addresses are rendered (target branch not yet implemented in template)
|
||||
const headings: DebugElement[] = fixture.debugElement.queryAll(
|
||||
By.css('h3'),
|
||||
);
|
||||
expect(headings.length).toBe(2);
|
||||
|
||||
const headingTexts = headings.map((h) =>
|
||||
h.nativeElement.textContent.trim(),
|
||||
);
|
||||
expect(headingTexts).toContain('Rechnungsadresse');
|
||||
expect(headingTexts).toContain('Lieferadresse');
|
||||
});
|
||||
});
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { OrderConfirmationAddressesComponent } from './order-confirmation-addresses.component';
|
||||
import { OrderConfiramtionStore } from '../reward-order-confirmation.store';
|
||||
import { signal } from '@angular/core';
|
||||
import { DebugElement } from '@angular/core';
|
||||
import { By } from '@angular/platform-browser';
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
|
||||
describe('OrderConfirmationAddressesComponent', () => {
|
||||
let component: OrderConfirmationAddressesComponent;
|
||||
let fixture: ComponentFixture<OrderConfirmationAddressesComponent>;
|
||||
let mockStore: {
|
||||
payers: ReturnType<typeof signal>;
|
||||
shippingAddresses: ReturnType<typeof signal>;
|
||||
hasDeliveryOrderTypeFeature: ReturnType<typeof signal>;
|
||||
targetBranches: ReturnType<typeof signal>;
|
||||
hasTargetBranchFeature: ReturnType<typeof signal>;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
// Create mock store with signals
|
||||
mockStore = {
|
||||
payers: signal([]),
|
||||
shippingAddresses: signal([]),
|
||||
hasDeliveryOrderTypeFeature: signal(false),
|
||||
targetBranches: signal([]),
|
||||
hasTargetBranchFeature: signal(false),
|
||||
};
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
imports: [OrderConfirmationAddressesComponent],
|
||||
providers: [
|
||||
{ provide: OrderConfiramtionStore, useValue: mockStore },
|
||||
provideHttpClient(),
|
||||
],
|
||||
});
|
||||
|
||||
fixture = TestBed.createComponent(OrderConfirmationAddressesComponent);
|
||||
component = fixture.componentInstance;
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should render payer address when available', () => {
|
||||
// Arrange
|
||||
mockStore.payers.set([
|
||||
{
|
||||
firstName: 'John',
|
||||
lastName: 'Doe',
|
||||
address: {
|
||||
street: 'Main St',
|
||||
streetNumber: '123',
|
||||
zipCode: '12345',
|
||||
city: 'Berlin',
|
||||
country: 'DE',
|
||||
},
|
||||
} as any,
|
||||
]);
|
||||
|
||||
// Act
|
||||
fixture.detectChanges();
|
||||
|
||||
// Assert
|
||||
const heading = fixture.debugElement.query(By.css('h3'));
|
||||
expect(heading).toBeTruthy();
|
||||
expect(heading.nativeElement.textContent.trim()).toBe('Rechnungsadresse');
|
||||
|
||||
const customerName = fixture.debugElement.query(
|
||||
By.css('.isa-text-body-1-bold.mt-1'),
|
||||
);
|
||||
expect(customerName).toBeTruthy();
|
||||
expect(customerName.nativeElement.textContent.trim()).toContain('John Doe');
|
||||
});
|
||||
|
||||
it('should not render payer address when address is missing', () => {
|
||||
// Arrange
|
||||
mockStore.payers.set([
|
||||
{
|
||||
firstName: 'John',
|
||||
lastName: 'Doe',
|
||||
address: undefined,
|
||||
} as any,
|
||||
]);
|
||||
|
||||
// Act
|
||||
fixture.detectChanges();
|
||||
|
||||
// Assert
|
||||
const heading = fixture.debugElement.query(By.css('h3'));
|
||||
expect(heading).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should render shipping address when hasDeliveryOrderTypeFeature is true', () => {
|
||||
// Arrange
|
||||
mockStore.hasDeliveryOrderTypeFeature.set(true);
|
||||
mockStore.shippingAddresses.set([
|
||||
{
|
||||
firstName: 'Jane',
|
||||
lastName: 'Smith',
|
||||
address: {
|
||||
street: 'Delivery St',
|
||||
streetNumber: '456',
|
||||
zipCode: '54321',
|
||||
city: 'Hamburg',
|
||||
country: 'DE',
|
||||
},
|
||||
} as any,
|
||||
]);
|
||||
|
||||
// Act
|
||||
fixture.detectChanges();
|
||||
|
||||
// Assert
|
||||
const headings: DebugElement[] = fixture.debugElement.queryAll(
|
||||
By.css('h3'),
|
||||
);
|
||||
const deliveryHeading = headings.find(
|
||||
(h) => h.nativeElement.textContent.trim() === 'Lieferadresse',
|
||||
);
|
||||
|
||||
expect(deliveryHeading).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should not render shipping address when hasDeliveryOrderTypeFeature is false', () => {
|
||||
// Arrange
|
||||
mockStore.hasDeliveryOrderTypeFeature.set(false);
|
||||
mockStore.shippingAddresses.set([
|
||||
{
|
||||
firstName: 'Jane',
|
||||
lastName: 'Smith',
|
||||
address: {
|
||||
street: 'Delivery St',
|
||||
streetNumber: '456',
|
||||
zipCode: '54321',
|
||||
city: 'Hamburg',
|
||||
country: 'DE',
|
||||
},
|
||||
} as any,
|
||||
]);
|
||||
|
||||
// Act
|
||||
fixture.detectChanges();
|
||||
|
||||
// Assert
|
||||
const headings: DebugElement[] = fixture.debugElement.queryAll(
|
||||
By.css('h3'),
|
||||
);
|
||||
const deliveryHeading = headings.find(
|
||||
(h) => h.nativeElement.textContent.trim() === 'Lieferadresse',
|
||||
);
|
||||
|
||||
expect(deliveryHeading).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should render target branch when hasTargetBranchFeature is true', () => {
|
||||
// Arrange
|
||||
mockStore.hasTargetBranchFeature.set(true);
|
||||
mockStore.targetBranches.set([
|
||||
{
|
||||
name: 'Branch Berlin',
|
||||
address: {
|
||||
street: 'Branch St',
|
||||
streetNumber: '789',
|
||||
zipCode: '10115',
|
||||
city: 'Berlin',
|
||||
country: 'DE',
|
||||
},
|
||||
} as any,
|
||||
]);
|
||||
|
||||
// Act
|
||||
fixture.detectChanges();
|
||||
|
||||
// Assert - Target branch is not yet implemented in the template
|
||||
// This test verifies that the component properties are correctly set
|
||||
expect(component.hasTargetBranchFeature()).toBe(true);
|
||||
expect(component.targetBranches().length).toBe(1);
|
||||
expect(component.targetBranches()[0].name).toBe('Branch Berlin');
|
||||
});
|
||||
|
||||
it('should not render target branch when hasTargetBranchFeature is false', () => {
|
||||
// Arrange
|
||||
mockStore.hasTargetBranchFeature.set(false);
|
||||
mockStore.targetBranches.set([
|
||||
{
|
||||
name: 'Branch Berlin',
|
||||
address: {
|
||||
street: 'Branch St',
|
||||
streetNumber: '789',
|
||||
zipCode: '10115',
|
||||
city: 'Berlin',
|
||||
country: 'DE',
|
||||
},
|
||||
} as any,
|
||||
]);
|
||||
|
||||
// Act
|
||||
fixture.detectChanges();
|
||||
|
||||
// Assert
|
||||
const headings: DebugElement[] = fixture.debugElement.queryAll(
|
||||
By.css('h3'),
|
||||
);
|
||||
const branchHeading = headings.find(
|
||||
(h) => h.nativeElement.textContent.trim() === 'Abholfiliale',
|
||||
);
|
||||
|
||||
expect(branchHeading).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should render multiple addresses when all features are enabled', () => {
|
||||
// Arrange
|
||||
mockStore.payers.set([
|
||||
{
|
||||
firstName: 'John',
|
||||
lastName: 'Doe',
|
||||
address: {
|
||||
street: 'Payer St',
|
||||
streetNumber: '1',
|
||||
zipCode: '11111',
|
||||
city: 'City1',
|
||||
country: 'DE',
|
||||
},
|
||||
} as any,
|
||||
]);
|
||||
mockStore.hasDeliveryOrderTypeFeature.set(true);
|
||||
mockStore.shippingAddresses.set([
|
||||
{
|
||||
firstName: 'Jane',
|
||||
lastName: 'Smith',
|
||||
address: {
|
||||
street: 'Delivery St',
|
||||
streetNumber: '2',
|
||||
zipCode: '22222',
|
||||
city: 'City2',
|
||||
country: 'DE',
|
||||
},
|
||||
} as any,
|
||||
]);
|
||||
mockStore.hasTargetBranchFeature.set(true);
|
||||
mockStore.targetBranches.set([
|
||||
{
|
||||
name: 'Branch Test',
|
||||
address: {
|
||||
street: 'Branch St',
|
||||
streetNumber: '3',
|
||||
zipCode: '33333',
|
||||
city: 'City3',
|
||||
country: 'DE',
|
||||
},
|
||||
} as any,
|
||||
]);
|
||||
|
||||
// Act
|
||||
fixture.detectChanges();
|
||||
|
||||
// Assert - Only Payer and Shipping addresses are rendered (target branch not yet implemented in template)
|
||||
const headings: DebugElement[] = fixture.debugElement.queryAll(
|
||||
By.css('h3'),
|
||||
);
|
||||
expect(headings.length).toBe(2);
|
||||
|
||||
const headingTexts = headings.map((h) =>
|
||||
h.nativeElement.textContent.trim(),
|
||||
);
|
||||
expect(headingTexts).toContain('Rechnungsadresse');
|
||||
expect(headingTexts).toContain('Lieferadresse');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,82 +1,82 @@
|
||||
@if (displayActionCard()) {
|
||||
<div
|
||||
class="w-72 desktop-large:w-[24.5rem] h-full p-4 flex flex-col gap-4 rounded-lg bg-isa-secondary-100"
|
||||
[class.confirmation-list-item-done]="item().status !== 1"
|
||||
data-which="action-card"
|
||||
data-what="action-card"
|
||||
>
|
||||
@if (!isComplete()) {
|
||||
<div
|
||||
data-what="confirmation-message"
|
||||
data-which="confirmation-comment"
|
||||
class="isa-text-body-2-bold"
|
||||
>
|
||||
Bitte buchen Sie die Prämie aus dem Abholfach aus oder wählen Sie eine
|
||||
andere Aktion.
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex flex-col gap-[0.62rem] desktop-large:gap-0 desktop-large:flex-row desktop-large:justify-between desktop-large:items-center"
|
||||
>
|
||||
<ui-dropdown
|
||||
class="h-8 border-none px-0 hover:bg-transparent self-end"
|
||||
[value]="selectedAction()"
|
||||
(valueChange)="setDropdownAction($event)"
|
||||
>
|
||||
<ui-dropdown-option [value]="LoyaltyCollectType.Collect"
|
||||
>Prämie ausbuchen</ui-dropdown-option
|
||||
>
|
||||
<ui-dropdown-option [value]="LoyaltyCollectType.OutOfStock"
|
||||
>Nicht gefunden</ui-dropdown-option
|
||||
>
|
||||
<ui-dropdown-option [value]="LoyaltyCollectType.Cancel"
|
||||
>Stornieren</ui-dropdown-option
|
||||
>
|
||||
</ui-dropdown>
|
||||
|
||||
<button
|
||||
class="flex items-center gap-2 self-end"
|
||||
type="button"
|
||||
uiButton
|
||||
color="primary"
|
||||
size="small"
|
||||
(click)="onCollect()"
|
||||
[pending]="resourcesLoading()"
|
||||
[disabled]="resourcesLoading()"
|
||||
data-what="button"
|
||||
data-which="complete"
|
||||
>
|
||||
<ng-icon name="isaActionCheck" uiButtonIcon></ng-icon>
|
||||
Abschließen
|
||||
</button>
|
||||
</div>
|
||||
} @else {
|
||||
<div
|
||||
data-what="done-message"
|
||||
data-which="done-comment"
|
||||
class="isa-text-body-2-bold"
|
||||
>
|
||||
@switch (processingStatus()) {
|
||||
@case (ProcessingStatusState.Cancelled) {
|
||||
Artikel wurde storniert und die Lesepunkte wieder gutgeschrieben.
|
||||
}
|
||||
@case (ProcessingStatusState.NotFound) {
|
||||
Die Prämienbestellung wurde storniert und die Lesepunkte wieder
|
||||
gutgeschrieben. Bitte korrigieren Sie bei Bedarf den Filialbestand.
|
||||
}
|
||||
@case (ProcessingStatusState.Collected) {
|
||||
Der Artikel wurde aus dem Bestand ausgebucht und kann dem Kunden
|
||||
mitgegeben werden.
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
<span
|
||||
class="flex items-center gap-2 self-end text-isa-accent-green isa-text-body-2-bold"
|
||||
>
|
||||
<ng-icon name="isaActionCheck"></ng-icon>
|
||||
Abgeschlossen
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@if (displayActionCard()) {
|
||||
<div
|
||||
class="w-72 desktop-large:w-[24.5rem] justify-between h-full p-4 flex flex-col gap-4 rounded-lg bg-isa-secondary-100"
|
||||
[class.confirmation-list-item-done]="item().status !== 1"
|
||||
data-which="action-card"
|
||||
data-what="action-card"
|
||||
>
|
||||
@if (!isComplete()) {
|
||||
<div
|
||||
data-what="confirmation-message"
|
||||
data-which="confirmation-comment"
|
||||
class="isa-text-body-2-bold"
|
||||
>
|
||||
Bitte buchen Sie die Prämie aus dem Abholfach aus oder wählen Sie eine
|
||||
andere Aktion.
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex flex-col gap-[0.62rem] desktop-large:gap-0 desktop-large:flex-row desktop-large:justify-between desktop-large:items-center"
|
||||
>
|
||||
<ui-dropdown
|
||||
class="h-8 border-none px-0 hover:bg-transparent self-end"
|
||||
[value]="selectedAction()"
|
||||
(valueChange)="setDropdownAction($event)"
|
||||
>
|
||||
<ui-dropdown-option [value]="LoyaltyCollectType.Collect"
|
||||
>Prämie ausbuchen</ui-dropdown-option
|
||||
>
|
||||
<ui-dropdown-option [value]="LoyaltyCollectType.OutOfStock"
|
||||
>Nicht gefunden</ui-dropdown-option
|
||||
>
|
||||
<ui-dropdown-option [value]="LoyaltyCollectType.Cancel"
|
||||
>Stornieren</ui-dropdown-option
|
||||
>
|
||||
</ui-dropdown>
|
||||
|
||||
<button
|
||||
class="flex items-center gap-2 self-end"
|
||||
type="button"
|
||||
uiButton
|
||||
color="primary"
|
||||
size="small"
|
||||
(click)="onCollect()"
|
||||
[pending]="resourcesLoading()"
|
||||
[disabled]="resourcesLoading()"
|
||||
data-what="button"
|
||||
data-which="complete"
|
||||
>
|
||||
<ng-icon name="isaActionCheck" uiButtonIcon></ng-icon>
|
||||
Abschließen
|
||||
</button>
|
||||
</div>
|
||||
} @else {
|
||||
<div
|
||||
data-what="done-message"
|
||||
data-which="done-comment"
|
||||
class="isa-text-body-2-bold"
|
||||
>
|
||||
@switch (processingStatus()) {
|
||||
@case (ProcessingStatusState.Cancelled) {
|
||||
Artikel wurde storniert und die Lesepunkte wieder gutgeschrieben.
|
||||
}
|
||||
@case (ProcessingStatusState.NotFound) {
|
||||
Die Prämienbestellung wurde storniert und die Lesepunkte wieder
|
||||
gutgeschrieben. Bitte korrigieren Sie bei Bedarf den Filialbestand.
|
||||
}
|
||||
@case (ProcessingStatusState.Collected) {
|
||||
Der Artikel wurde aus dem Bestand ausgebucht und kann dem Kunden
|
||||
mitgegeben werden.
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
<span
|
||||
class="flex items-center gap-2 self-end text-isa-accent-green isa-text-body-2-bold"
|
||||
>
|
||||
<ng-icon name="isaActionCheck"></ng-icon>
|
||||
Abgeschlossen
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -1,30 +1,46 @@
|
||||
:host {
|
||||
@apply grid grid-cols-[1fr,1fr] desktop-large:grid-cols-[1fr,1fr,1fr] gap-6 pt-4;
|
||||
grid-template-areas:
|
||||
'A B'
|
||||
'C B';
|
||||
}
|
||||
|
||||
:host:not(:has([data-which='action-card'][data-what='action-card'])) {
|
||||
@apply flex flex-row justify-between;
|
||||
/* grid-template-areas: 'A C'; */
|
||||
}
|
||||
|
||||
@screen desktop-large {
|
||||
:host,
|
||||
:host:not(:has([data-which='action-card'][data-what='action-card'])) {
|
||||
grid-template-areas: 'A B C';
|
||||
}
|
||||
}
|
||||
|
||||
.area-a {
|
||||
grid-area: A;
|
||||
}
|
||||
|
||||
.area-b {
|
||||
grid-area: B;
|
||||
}
|
||||
|
||||
.area-c {
|
||||
grid-area: C;
|
||||
}
|
||||
:host {
|
||||
@apply grid grid-cols-[1fr,auto] gap-6 pt-4;
|
||||
|
||||
grid-template-areas:
|
||||
'product-info action-card'
|
||||
'destination-info action-card';
|
||||
}
|
||||
|
||||
checkout-display-order-destination-info {
|
||||
@apply ml-20 desktop-large:ml-0 desktop-large:justify-self-end;
|
||||
}
|
||||
|
||||
:host:not(:has([data-which='action-card'][data-what='action-card'])) {
|
||||
@apply flex justify-between;
|
||||
|
||||
checkout-confirmation-list-item-action-card {
|
||||
display: none;
|
||||
}
|
||||
|
||||
checkout-display-order-destination-info {
|
||||
@apply ml-0;
|
||||
}
|
||||
}
|
||||
|
||||
@screen desktop-large {
|
||||
:host,
|
||||
:host:not(:has([data-which='action-card'][data-what='action-card'])) {
|
||||
@apply grid-cols-3;
|
||||
grid-template-areas: 'product-info action-card destination-info';
|
||||
}
|
||||
}
|
||||
|
||||
checkout-product-info {
|
||||
grid-area: product-info;
|
||||
@apply grow;
|
||||
}
|
||||
|
||||
checkout-confirmation-list-item-action-card {
|
||||
grid-area: action-card;
|
||||
@apply w-[18rem] desktop-large:w-[24.5rem];
|
||||
}
|
||||
|
||||
checkout-display-order-destination-info {
|
||||
grid-area: destination-info;
|
||||
@apply block w-[18rem] desktop-large:w-[24.5rem] grow-0;
|
||||
}
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
<checkout-product-info
|
||||
class="area-a"
|
||||
[item]="productItem()"
|
||||
[nameSize]="'small'"
|
||||
>
|
||||
<div class="isa-text-body-2-regular" data-what="product-points">
|
||||
<span class="isa-text-body-2-bold">{{ points() }}</span>
|
||||
Lesepunkte
|
||||
</div>
|
||||
<div class="isa-text-body-2-bold">{{ item()?.quantity }} x</div>
|
||||
</checkout-product-info>
|
||||
@let product = productItem();
|
||||
@if (product) {
|
||||
<checkout-product-info [item]="productItem()" [nameSize]="'small'">
|
||||
<div class="isa-text-body-2-regular" data-what="product-points">
|
||||
<span class="isa-text-body-2-bold">{{ points() }}</span>
|
||||
Lesepunkte
|
||||
</div>
|
||||
<div class="isa-text-body-2-bold">{{ item()?.quantity }} x</div>
|
||||
</checkout-product-info>
|
||||
}
|
||||
|
||||
<checkout-confirmation-list-item-action-card
|
||||
class="area-b justify-self-end"
|
||||
class="justify-self-end"
|
||||
[item]="item()"
|
||||
></checkout-confirmation-list-item-action-card>
|
||||
<checkout-destination-info
|
||||
class="max-w-[22.9rem] area-c ml-20 desktop-large:justify-self-end desktop-large:ml-0"
|
||||
[shoppingCartItem]="shoppingCartItem()"
|
||||
></checkout-destination-info>
|
||||
<checkout-display-order-destination-info
|
||||
[order]="order()"
|
||||
[item]="item()"
|
||||
></checkout-display-order-destination-info>
|
||||
|
||||
@@ -1,27 +1,33 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { OrderConfirmationItemListItemComponent } from './order-confirmation-item-list-item.component';
|
||||
import { OrderConfiramtionStore } from '../../reward-order-confirmation.store';
|
||||
import { DisplayOrderItem } from '@isa/oms/data-access';
|
||||
import { signal } from '@angular/core';
|
||||
import { DebugElement } from '@angular/core';
|
||||
import { DisplayOrderItem, DisplayOrder } from '@isa/oms/data-access';
|
||||
import { DebugElement, signal } from '@angular/core';
|
||||
import { By } from '@angular/platform-browser';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { provideProductImageUrl } from '@isa/shared/product-image';
|
||||
import { provideProductRouterLinkBuilder } from '@isa/shared/product-router-link';
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { OrderConfiramtionStore } from '../../reward-order-confirmation.store';
|
||||
|
||||
describe('OrderConfirmationItemListItemComponent', () => {
|
||||
let component: OrderConfirmationItemListItemComponent;
|
||||
let fixture: ComponentFixture<OrderConfirmationItemListItemComponent>;
|
||||
let mockStore: {
|
||||
shoppingCart: ReturnType<typeof signal>;
|
||||
orders: ReturnType<typeof signal>;
|
||||
};
|
||||
|
||||
const mockOrder: DisplayOrder = {
|
||||
id: 1,
|
||||
orderNumber: 'ORD-123',
|
||||
orderType: 'Reward',
|
||||
status: 'Confirmed',
|
||||
} as DisplayOrder;
|
||||
|
||||
beforeEach(() => {
|
||||
// Create mock store with signal
|
||||
// Create mock store
|
||||
mockStore = {
|
||||
shoppingCart: signal(null),
|
||||
orders: signal([mockOrder]),
|
||||
};
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
@@ -44,7 +50,7 @@ describe('OrderConfirmationItemListItemComponent', () => {
|
||||
});
|
||||
|
||||
describe('productItem computed signal', () => {
|
||||
it('should map DisplayOrderItem product to ProductInfoItem', () => {
|
||||
it('should return product from item', () => {
|
||||
// Arrange
|
||||
const item: DisplayOrderItem = {
|
||||
id: 1,
|
||||
@@ -59,39 +65,35 @@ describe('OrderConfirmationItemListItemComponent', () => {
|
||||
|
||||
// Act
|
||||
fixture.componentRef.setInput('item', item);
|
||||
fixture.componentRef.setInput('order', mockOrder);
|
||||
|
||||
// Assert
|
||||
expect(component.productItem()).toEqual({
|
||||
ean: '1234567890123',
|
||||
name: 'Test Product',
|
||||
contributors: 'Test Author',
|
||||
catalogProductNumber: 'CAT-123',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle missing product fields with empty strings', () => {
|
||||
it('should return undefined when item has no product', () => {
|
||||
// Arrange
|
||||
const item: DisplayOrderItem = {
|
||||
id: 1,
|
||||
quantity: 1,
|
||||
product: {
|
||||
catalogProductNumber: 'CAT-123',
|
||||
},
|
||||
} as DisplayOrderItem;
|
||||
|
||||
// Act
|
||||
fixture.componentRef.setInput('item', item);
|
||||
fixture.componentRef.setInput('order', mockOrder);
|
||||
|
||||
// Assert
|
||||
expect(component.productItem()).toEqual({
|
||||
ean: '',
|
||||
name: '',
|
||||
contributors: '',
|
||||
});
|
||||
expect(component.productItem()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('points computed signal', () => {
|
||||
it('should return loyalty points from shopping cart item', () => {
|
||||
it('should return loyalty points from item', () => {
|
||||
// Arrange
|
||||
const item: DisplayOrderItem = {
|
||||
id: 1,
|
||||
@@ -100,28 +102,18 @@ describe('OrderConfirmationItemListItemComponent', () => {
|
||||
catalogProductNumber: 'CAT-123',
|
||||
ean: '1234567890123',
|
||||
},
|
||||
loyalty: { value: 150 },
|
||||
} as DisplayOrderItem;
|
||||
|
||||
mockStore.shoppingCart.set({
|
||||
id: 1,
|
||||
items: [
|
||||
{
|
||||
data: {
|
||||
product: { catalogProductNumber: 'CAT-123' },
|
||||
loyalty: { value: 150 },
|
||||
},
|
||||
},
|
||||
],
|
||||
} as any);
|
||||
|
||||
// Act
|
||||
fixture.componentRef.setInput('item', item);
|
||||
fixture.componentRef.setInput('order', mockOrder);
|
||||
|
||||
// Assert
|
||||
expect(component.points()).toBe(150);
|
||||
});
|
||||
|
||||
it('should return 0 when shopping cart is null', () => {
|
||||
it('should return 0 when loyalty is missing', () => {
|
||||
// Arrange
|
||||
const item: DisplayOrderItem = {
|
||||
id: 1,
|
||||
@@ -131,39 +123,9 @@ describe('OrderConfirmationItemListItemComponent', () => {
|
||||
},
|
||||
} as DisplayOrderItem;
|
||||
|
||||
mockStore.shoppingCart.set(null);
|
||||
|
||||
// Act
|
||||
fixture.componentRef.setInput('item', item);
|
||||
|
||||
// Assert
|
||||
expect(component.points()).toBe(0);
|
||||
});
|
||||
|
||||
it('should return 0 when shopping cart item is not found', () => {
|
||||
// Arrange
|
||||
const item: DisplayOrderItem = {
|
||||
id: 1,
|
||||
quantity: 1,
|
||||
product: {
|
||||
catalogProductNumber: 'CAT-123',
|
||||
},
|
||||
} as DisplayOrderItem;
|
||||
|
||||
mockStore.shoppingCart.set({
|
||||
id: 1,
|
||||
items: [
|
||||
{
|
||||
data: {
|
||||
product: { catalogProductNumber: 'CAT-999' },
|
||||
loyalty: { value: 100 },
|
||||
},
|
||||
},
|
||||
],
|
||||
} as any);
|
||||
|
||||
// Act
|
||||
fixture.componentRef.setInput('item', item);
|
||||
fixture.componentRef.setInput('order', mockOrder);
|
||||
|
||||
// Assert
|
||||
expect(component.points()).toBe(0);
|
||||
@@ -177,104 +139,18 @@ describe('OrderConfirmationItemListItemComponent', () => {
|
||||
product: {
|
||||
catalogProductNumber: 'CAT-123',
|
||||
},
|
||||
loyalty: {},
|
||||
} as DisplayOrderItem;
|
||||
|
||||
mockStore.shoppingCart.set({
|
||||
id: 1,
|
||||
items: [
|
||||
{
|
||||
data: {
|
||||
product: { catalogProductNumber: 'CAT-123' },
|
||||
loyalty: {},
|
||||
},
|
||||
},
|
||||
],
|
||||
} as any);
|
||||
|
||||
// Act
|
||||
fixture.componentRef.setInput('item', item);
|
||||
fixture.componentRef.setInput('order', mockOrder);
|
||||
|
||||
// Assert
|
||||
expect(component.points()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('shoppingCartItem computed signal', () => {
|
||||
it('should return shopping cart item data when found', () => {
|
||||
// Arrange
|
||||
const item: DisplayOrderItem = {
|
||||
id: 1,
|
||||
quantity: 1,
|
||||
product: {
|
||||
catalogProductNumber: 'CAT-123',
|
||||
},
|
||||
} as DisplayOrderItem;
|
||||
|
||||
const shoppingCartItemData = {
|
||||
product: { catalogProductNumber: 'CAT-123' },
|
||||
loyalty: { value: 150 },
|
||||
};
|
||||
|
||||
mockStore.shoppingCart.set({
|
||||
id: 1,
|
||||
items: [{ data: shoppingCartItemData }],
|
||||
} as any);
|
||||
|
||||
// Act
|
||||
fixture.componentRef.setInput('item', item);
|
||||
|
||||
// Assert
|
||||
expect(component.shoppingCartItem()).toBe(shoppingCartItemData);
|
||||
});
|
||||
|
||||
it('should return undefined when shopping cart is null', () => {
|
||||
// Arrange
|
||||
const item: DisplayOrderItem = {
|
||||
id: 1,
|
||||
quantity: 1,
|
||||
product: {
|
||||
catalogProductNumber: 'CAT-123',
|
||||
},
|
||||
} as DisplayOrderItem;
|
||||
|
||||
mockStore.shoppingCart.set(null);
|
||||
|
||||
// Act
|
||||
fixture.componentRef.setInput('item', item);
|
||||
|
||||
// Assert
|
||||
expect(component.shoppingCartItem()).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return undefined when item is not found in shopping cart', () => {
|
||||
// Arrange
|
||||
const item: DisplayOrderItem = {
|
||||
id: 1,
|
||||
quantity: 1,
|
||||
product: {
|
||||
catalogProductNumber: 'CAT-123',
|
||||
},
|
||||
} as DisplayOrderItem;
|
||||
|
||||
mockStore.shoppingCart.set({
|
||||
id: 1,
|
||||
items: [
|
||||
{
|
||||
data: {
|
||||
product: { catalogProductNumber: 'CAT-999' },
|
||||
},
|
||||
},
|
||||
],
|
||||
} as any);
|
||||
|
||||
// Act
|
||||
fixture.componentRef.setInput('item', item);
|
||||
|
||||
// Assert
|
||||
expect(component.shoppingCartItem()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('template rendering', () => {
|
||||
it('should render product points with E2E attribute', () => {
|
||||
// Arrange
|
||||
@@ -287,22 +163,12 @@ describe('OrderConfirmationItemListItemComponent', () => {
|
||||
contributors: 'Test Author',
|
||||
catalogProductNumber: 'CAT-123',
|
||||
},
|
||||
loyalty: { value: 200 },
|
||||
} as DisplayOrderItem;
|
||||
|
||||
mockStore.shoppingCart.set({
|
||||
id: 1,
|
||||
items: [
|
||||
{
|
||||
data: {
|
||||
product: { catalogProductNumber: 'CAT-123' },
|
||||
loyalty: { value: 200 },
|
||||
},
|
||||
},
|
||||
],
|
||||
} as any);
|
||||
|
||||
// Act
|
||||
fixture.componentRef.setInput('item', item);
|
||||
fixture.componentRef.setInput('order', mockOrder);
|
||||
fixture.detectChanges();
|
||||
|
||||
// Assert
|
||||
@@ -326,21 +192,9 @@ describe('OrderConfirmationItemListItemComponent', () => {
|
||||
},
|
||||
} as DisplayOrderItem;
|
||||
|
||||
// Provide shopping cart data to avoid destination errors
|
||||
mockStore.shoppingCart.set({
|
||||
id: 1,
|
||||
items: [
|
||||
{
|
||||
data: {
|
||||
product: { catalogProductNumber: 'CAT-123' },
|
||||
destination: { type: 'InStore' },
|
||||
},
|
||||
},
|
||||
],
|
||||
} as any);
|
||||
|
||||
// Act
|
||||
fixture.componentRef.setInput('item', item);
|
||||
fixture.componentRef.setInput('order', mockOrder);
|
||||
fixture.detectChanges();
|
||||
|
||||
// Assert
|
||||
@@ -367,21 +221,9 @@ describe('OrderConfirmationItemListItemComponent', () => {
|
||||
},
|
||||
} as DisplayOrderItem;
|
||||
|
||||
// Provide shopping cart data to avoid destination errors
|
||||
mockStore.shoppingCart.set({
|
||||
id: 1,
|
||||
items: [
|
||||
{
|
||||
data: {
|
||||
product: { catalogProductNumber: 'CAT-123' },
|
||||
destination: { type: 'InStore' },
|
||||
},
|
||||
},
|
||||
],
|
||||
} as any);
|
||||
|
||||
// Act
|
||||
fixture.componentRef.setInput('item', item);
|
||||
fixture.componentRef.setInput('order', mockOrder);
|
||||
fixture.detectChanges();
|
||||
|
||||
// Assert
|
||||
@@ -390,7 +232,7 @@ describe('OrderConfirmationItemListItemComponent', () => {
|
||||
By.css('checkout-confirmation-list-item-action-card')
|
||||
);
|
||||
const destinationInfo = fixture.debugElement.query(
|
||||
By.css('checkout-destination-info')
|
||||
By.css('checkout-display-order-destination-info')
|
||||
);
|
||||
|
||||
expect(productInfo).toBeTruthy();
|
||||
|
||||
@@ -2,17 +2,18 @@ import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
inject,
|
||||
input,
|
||||
} from '@angular/core';
|
||||
import { DisplayOrderItem } from '@isa/oms/data-access';
|
||||
import { ConfirmationListItemActionCardComponent } from './confirmation-list-item-action-card/confirmation-list-item-action-card.component';
|
||||
import {
|
||||
ProductInfoComponent,
|
||||
ProductInfoItem,
|
||||
DestinationInfoComponent,
|
||||
DisplayOrderDestinationInfoComponent,
|
||||
} from '@isa/checkout/shared/product-info';
|
||||
import { OrderConfiramtionStore } from '../../reward-order-confirmation.store';
|
||||
import {
|
||||
DisplayOrderItemDTO,
|
||||
} from '@generated/swagger/oms-api';
|
||||
import { Product } from '@isa/common/data-access';
|
||||
import { type OrderItemGroup } from '@isa/checkout/data-access';
|
||||
|
||||
@Component({
|
||||
selector: 'checkout-order-confirmation-item-list-item',
|
||||
@@ -22,67 +23,25 @@ import { OrderConfiramtionStore } from '../../reward-order-confirmation.store';
|
||||
imports: [
|
||||
ConfirmationListItemActionCardComponent,
|
||||
ProductInfoComponent,
|
||||
DestinationInfoComponent,
|
||||
DisplayOrderDestinationInfoComponent,
|
||||
],
|
||||
})
|
||||
export class OrderConfirmationItemListItemComponent {
|
||||
#orderConfiramtionStore = inject(OrderConfiramtionStore);
|
||||
item = input.required<DisplayOrderItemDTO>();
|
||||
|
||||
item = input.required<DisplayOrderItem>();
|
||||
/**
|
||||
* The order item group containing the delivery type, destination, and other group metadata.
|
||||
* This now receives an OrderItemGroup which contains the necessary order information
|
||||
* (features, targetBranch, shippingAddress) required by the DisplayOrderDestinationInfoComponent.
|
||||
*/
|
||||
order = input.required<Pick<OrderItemGroup, 'features' | 'targetBranch' | 'shippingAddress'>>();
|
||||
|
||||
productItem = computed<ProductInfoItem>(() => {
|
||||
const product = this.item().product;
|
||||
|
||||
return {
|
||||
contributors: product?.contributors ?? '',
|
||||
ean: product?.ean ?? '',
|
||||
name: product?.name ?? '',
|
||||
};
|
||||
productItem = computed<Product | undefined>(() => {
|
||||
return this.item()?.product;
|
||||
});
|
||||
|
||||
points = computed(() => {
|
||||
const shoppingCart = this.#orderConfiramtionStore.shoppingCart();
|
||||
|
||||
if (!shoppingCart) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const item = this.item();
|
||||
|
||||
const shoppingCartItem = shoppingCart.items.find(
|
||||
(scItem) =>
|
||||
scItem?.data?.product?.catalogProductNumber ===
|
||||
item.product?.catalogProductNumber,
|
||||
)?.data;
|
||||
|
||||
return shoppingCartItem?.loyalty?.value ?? 0;
|
||||
});
|
||||
|
||||
shoppingCartItem = computed(() => {
|
||||
const shoppingCart = this.#orderConfiramtionStore.shoppingCart();
|
||||
const item = this.item();
|
||||
if (!shoppingCart) {
|
||||
// Fallback: use DisplayOrderItem features directly
|
||||
return {
|
||||
features: item.features,
|
||||
availability: undefined,
|
||||
destination: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const foundItem = shoppingCart.items.find(
|
||||
(scItem) =>
|
||||
scItem?.data?.product?.catalogProductNumber ===
|
||||
item.product?.catalogProductNumber,
|
||||
)?.data;
|
||||
|
||||
// Fallback: use DisplayOrderItem features if not found in cart
|
||||
return (
|
||||
foundItem ?? {
|
||||
features: item.features,
|
||||
availability: undefined,
|
||||
destination: undefined,
|
||||
}
|
||||
);
|
||||
return item.loyalty?.value ?? 0;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,42 +1,38 @@
|
||||
@for (group of groupedOrders(); track group.orderType) {
|
||||
@for (group of groupedItems(); track trackByGroupedItems(group)) {
|
||||
<!-- Delivery type section -->
|
||||
<div
|
||||
<section
|
||||
class="flex flex-col gap-2 self-stretch"
|
||||
data-what="delivery-type-group"
|
||||
[attr.data-which]="group.orderType"
|
||||
[attr.aria-label]="group.orderType + ' items'"
|
||||
>
|
||||
@for (branchGroup of group.branchGroups; track branchGroup.branchId ?? 0) {
|
||||
<!-- Branch header (only shown when multiple branches exist within same delivery type) -->
|
||||
@if (branchGroup.branchName && group.branchGroups.length > 1) {
|
||||
<div
|
||||
class="flex p-2 items-center gap-[0.625rem] self-stretch rounded-2xl bg-isa-neutral-200 w-full isa-text-body-2-bold"
|
||||
data-what="branch-header"
|
||||
[attr.data-which]="branchGroup.branchName"
|
||||
>
|
||||
<ng-icon [name]="group.icon" size="1.5rem" />
|
||||
<div>{{ group.orderType }} - {{ branchGroup.branchName }}</div>
|
||||
</div>
|
||||
<h3
|
||||
class="flex p-2 items-center gap-[0.625rem] self-stretch rounded-2xl bg-isa-neutral-200 w-full isa-text-body-2-bold"
|
||||
role="heading"
|
||||
aria-level="3"
|
||||
>
|
||||
<ng-icon
|
||||
[name]="group.orderTypeIcon"
|
||||
size="1.5rem"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
@if (group.orderType === 'Abholung' || group.orderType === 'Rücklage') {
|
||||
@let branchName = group?.targetBranch?.name;
|
||||
<span>{{ group.orderType }} - {{ branchName }}</span>
|
||||
} @else {
|
||||
<!-- Order type header (for single branch or Versand types) -->
|
||||
<div
|
||||
class="flex p-2 items-center gap-[0.625rem] self-stretch rounded-2xl bg-isa-neutral-200 w-full isa-text-body-2-bold"
|
||||
data-what="order-type-header"
|
||||
[attr.data-which]="group.orderType"
|
||||
>
|
||||
<ng-icon [name]="group.icon" size="1.5rem" />
|
||||
<div>
|
||||
{{ group.orderType }}
|
||||
</div>
|
||||
</div>
|
||||
<span>{{ group.orderType }}</span>
|
||||
}
|
||||
</h3>
|
||||
|
||||
<!-- Items from all orders in this (orderType + branch) group -->
|
||||
@for (item of branchGroup.items; track item.id; let isLast = $last) {
|
||||
<checkout-order-confirmation-item-list-item [item]="item" />
|
||||
@if (!isLast) {
|
||||
<hr class="mt-3" />
|
||||
}
|
||||
<!-- Items from all orders in this (orderType + branch) group -->
|
||||
@for (item of group.items; track item.id; let isLast = $last) {
|
||||
<checkout-order-confirmation-item-list-item
|
||||
[item]="item"
|
||||
[order]="group"
|
||||
/>
|
||||
@if (!isLast) {
|
||||
<hr class="mt-3" />
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
@@ -15,13 +15,13 @@ describe('OrderConfirmationItemListComponent', () => {
|
||||
let component: OrderConfirmationItemListComponent;
|
||||
let fixture: ComponentFixture<OrderConfirmationItemListComponent>;
|
||||
let mockStore: {
|
||||
shoppingCart: ReturnType<typeof signal>;
|
||||
orders: ReturnType<typeof signal>;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
// Create mock store with signal
|
||||
// Create mock store with orders signal
|
||||
mockStore = {
|
||||
shoppingCart: signal(null),
|
||||
orders: signal([]),
|
||||
};
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
@@ -43,243 +43,136 @@ describe('OrderConfirmationItemListComponent', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
describe('orderType computed signal', () => {
|
||||
it('should return Delivery for delivery order type', () => {
|
||||
// Arrange
|
||||
const order: DisplayOrder = {
|
||||
it('should expose orders signal from store', () => {
|
||||
const testOrders: DisplayOrder[] = [
|
||||
{
|
||||
id: 1,
|
||||
features: { orderType: OrderType.Delivery },
|
||||
items: [],
|
||||
} as DisplayOrder;
|
||||
} as DisplayOrder,
|
||||
];
|
||||
|
||||
// Act
|
||||
fixture.componentRef.setInput('order', order);
|
||||
fixture.detectChanges();
|
||||
mockStore.orders.set(testOrders);
|
||||
|
||||
// Assert
|
||||
expect(component.orderType()).toBe(OrderType.Delivery);
|
||||
});
|
||||
|
||||
it('should return Pickup for pickup order type', () => {
|
||||
// Arrange
|
||||
const order: DisplayOrder = {
|
||||
id: 1,
|
||||
features: { orderType: OrderType.Pickup },
|
||||
items: [],
|
||||
} as DisplayOrder;
|
||||
|
||||
// Act
|
||||
fixture.componentRef.setInput('order', order);
|
||||
fixture.detectChanges();
|
||||
|
||||
// Assert
|
||||
expect(component.orderType()).toBe(OrderType.Pickup);
|
||||
});
|
||||
|
||||
it('should return InStore for in-store order type', () => {
|
||||
// Arrange
|
||||
const order: DisplayOrder = {
|
||||
id: 1,
|
||||
features: { orderType: OrderType.InStore },
|
||||
items: [],
|
||||
} as DisplayOrder;
|
||||
|
||||
// Act
|
||||
fixture.componentRef.setInput('order', order);
|
||||
fixture.detectChanges();
|
||||
|
||||
// Assert
|
||||
expect(component.orderType()).toBe(OrderType.InStore);
|
||||
});
|
||||
expect(component.orders()).toEqual(testOrders);
|
||||
});
|
||||
|
||||
describe('orderTypeIcon computed signal', () => {
|
||||
it('should return isaDeliveryVersand icon for Delivery', () => {
|
||||
// Arrange
|
||||
const order: DisplayOrder = {
|
||||
id: 1,
|
||||
features: { orderType: OrderType.Delivery },
|
||||
items: [],
|
||||
} as DisplayOrder;
|
||||
|
||||
// Act
|
||||
fixture.componentRef.setInput('order', order);
|
||||
|
||||
// Assert
|
||||
expect(component.orderTypeIcon()).toBe('isaDeliveryVersand');
|
||||
});
|
||||
|
||||
it('should return isaDeliveryRuecklage2 icon for Pickup', () => {
|
||||
// Arrange
|
||||
const order: DisplayOrder = {
|
||||
id: 1,
|
||||
features: { orderType: OrderType.Pickup },
|
||||
items: [],
|
||||
} as DisplayOrder;
|
||||
|
||||
// Act
|
||||
fixture.componentRef.setInput('order', order);
|
||||
|
||||
// Assert
|
||||
expect(component.orderTypeIcon()).toBe('isaDeliveryRuecklage2');
|
||||
});
|
||||
|
||||
it('should return isaDeliveryRuecklage1 icon for InStore', () => {
|
||||
// Arrange
|
||||
const order: DisplayOrder = {
|
||||
id: 1,
|
||||
features: { orderType: OrderType.InStore },
|
||||
items: [],
|
||||
} as DisplayOrder;
|
||||
|
||||
// Act
|
||||
fixture.componentRef.setInput('order', order);
|
||||
|
||||
// Assert
|
||||
expect(component.orderTypeIcon()).toBe('isaDeliveryRuecklage1');
|
||||
});
|
||||
|
||||
it('should default to isaDeliveryVersand for unknown order type', () => {
|
||||
// Arrange
|
||||
const order: DisplayOrder = {
|
||||
id: 1,
|
||||
features: { orderType: 'Unknown' as any },
|
||||
items: [],
|
||||
} as DisplayOrder;
|
||||
|
||||
// Act
|
||||
fixture.componentRef.setInput('order', order);
|
||||
|
||||
// Assert
|
||||
expect(component.orderTypeIcon()).toBe('isaDeliveryVersand');
|
||||
});
|
||||
it('should have getOrderTypeIcon function', () => {
|
||||
expect(component.getOrderTypeIcon).toBeDefined();
|
||||
expect(typeof component.getOrderTypeIcon).toBe('function');
|
||||
});
|
||||
|
||||
describe('items computed signal', () => {
|
||||
it('should return items from order', () => {
|
||||
// Arrange
|
||||
const items = [
|
||||
{ id: 1, ean: '1234567890123' },
|
||||
{ id: 2, ean: '9876543210987' },
|
||||
] as any[];
|
||||
|
||||
const order: DisplayOrder = {
|
||||
id: 1,
|
||||
features: { orderType: OrderType.InStore },
|
||||
items,
|
||||
} as DisplayOrder;
|
||||
|
||||
// Act
|
||||
fixture.componentRef.setInput('order', order);
|
||||
|
||||
// Assert
|
||||
expect(component.items()).toEqual(items);
|
||||
});
|
||||
|
||||
it('should return empty array when items is undefined', () => {
|
||||
// Arrange
|
||||
const order: DisplayOrder = {
|
||||
id: 1,
|
||||
features: { orderType: OrderType.InStore },
|
||||
items: undefined,
|
||||
} as DisplayOrder;
|
||||
|
||||
// Act
|
||||
fixture.componentRef.setInput('order', order);
|
||||
|
||||
// Assert
|
||||
expect(component.items()).toEqual([]);
|
||||
});
|
||||
it('should have getOrderTypeFeature function', () => {
|
||||
expect(component.getOrderTypeFeature).toBeDefined();
|
||||
expect(typeof component.getOrderTypeFeature).toBe('function');
|
||||
});
|
||||
|
||||
describe('template rendering', () => {
|
||||
it('should render order type header with icon and text', () => {
|
||||
it('should render order groups for each order', () => {
|
||||
// Arrange
|
||||
const order: DisplayOrder = {
|
||||
id: 1,
|
||||
features: { orderType: OrderType.Delivery },
|
||||
items: [],
|
||||
} as DisplayOrder;
|
||||
const orders: DisplayOrder[] = [
|
||||
{
|
||||
id: 1,
|
||||
features: { orderType: OrderType.Delivery },
|
||||
items: [
|
||||
{
|
||||
id: 1,
|
||||
product: { ean: '123', name: 'Product 1', catalogProductNumber: 'CAT-1' },
|
||||
quantity: 1,
|
||||
} as any,
|
||||
],
|
||||
} as DisplayOrder,
|
||||
{
|
||||
id: 2,
|
||||
features: { orderType: OrderType.Pickup },
|
||||
targetBranch: { name: 'Test Branch' },
|
||||
items: [
|
||||
{
|
||||
id: 2,
|
||||
product: { ean: '456', name: 'Product 2', catalogProductNumber: 'CAT-2' },
|
||||
quantity: 2,
|
||||
} as any,
|
||||
],
|
||||
} as DisplayOrder,
|
||||
];
|
||||
|
||||
mockStore.orders.set(orders);
|
||||
|
||||
// Act
|
||||
fixture.componentRef.setInput('order', order);
|
||||
fixture.detectChanges();
|
||||
|
||||
// Assert
|
||||
const header: DebugElement = fixture.debugElement.query(
|
||||
By.css('.bg-isa-neutral-200')
|
||||
const deliveryGroups = fixture.debugElement.queryAll(
|
||||
By.css('[data-what="delivery-type-group"]')
|
||||
);
|
||||
expect(header).toBeTruthy();
|
||||
expect(header.nativeElement.textContent).toContain(OrderType.Delivery);
|
||||
expect(deliveryGroups.length).toBe(2);
|
||||
});
|
||||
|
||||
it('should render item list components for each item', () => {
|
||||
it('should render order type header with icon', () => {
|
||||
// Arrange
|
||||
const items = [
|
||||
{ id: 1, product: { ean: '1234567890123', catalogProductNumber: 'CAT-123' } },
|
||||
{ id: 2, product: { ean: '9876543210987', catalogProductNumber: 'CAT-456' } },
|
||||
{ id: 3, product: { ean: '1111111111111', catalogProductNumber: 'CAT-789' } },
|
||||
] as any[];
|
||||
const orders: DisplayOrder[] = [
|
||||
{
|
||||
id: 1,
|
||||
features: { orderType: OrderType.Delivery },
|
||||
items: [],
|
||||
} as DisplayOrder,
|
||||
];
|
||||
|
||||
const order: DisplayOrder = {
|
||||
id: 1,
|
||||
features: { orderType: OrderType.InStore },
|
||||
items,
|
||||
} as DisplayOrder;
|
||||
|
||||
// Provide shopping cart data to avoid destination errors
|
||||
mockStore.shoppingCart.set({
|
||||
id: 1,
|
||||
items: [
|
||||
{
|
||||
data: {
|
||||
product: { catalogProductNumber: 'CAT-123' },
|
||||
destination: { type: 'InStore' },
|
||||
},
|
||||
},
|
||||
{
|
||||
data: {
|
||||
product: { catalogProductNumber: 'CAT-456' },
|
||||
destination: { type: 'InStore' },
|
||||
},
|
||||
},
|
||||
{
|
||||
data: {
|
||||
product: { catalogProductNumber: 'CAT-789' },
|
||||
destination: { type: 'InStore' },
|
||||
},
|
||||
},
|
||||
],
|
||||
} as any);
|
||||
mockStore.orders.set(orders);
|
||||
|
||||
// Act
|
||||
fixture.componentRef.setInput('order', order);
|
||||
fixture.detectChanges();
|
||||
|
||||
// Assert
|
||||
const itemComponents = fixture.debugElement.queryAll(
|
||||
By.css('checkout-order-confirmation-item-list-item')
|
||||
);
|
||||
expect(itemComponents.length).toBe(3);
|
||||
const icon = fixture.debugElement.query(By.css('ng-icon'));
|
||||
expect(icon).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should not render any items when items array is empty', () => {
|
||||
it('should render items for each order', () => {
|
||||
// Arrange
|
||||
const order: DisplayOrder = {
|
||||
id: 1,
|
||||
features: { orderType: OrderType.InStore },
|
||||
items: [],
|
||||
} as DisplayOrder;
|
||||
const orders: DisplayOrder[] = [
|
||||
{
|
||||
id: 1,
|
||||
features: { orderType: OrderType.Delivery },
|
||||
items: [
|
||||
{
|
||||
id: 1,
|
||||
product: { ean: '123', name: 'Product 1', catalogProductNumber: 'CAT-1' },
|
||||
quantity: 1,
|
||||
} as any,
|
||||
{
|
||||
id: 2,
|
||||
product: { ean: '456', name: 'Product 2', catalogProductNumber: 'CAT-2' },
|
||||
quantity: 2,
|
||||
} as any,
|
||||
],
|
||||
} as DisplayOrder,
|
||||
];
|
||||
|
||||
mockStore.orders.set(orders);
|
||||
|
||||
// Act
|
||||
fixture.componentRef.setInput('order', order);
|
||||
fixture.detectChanges();
|
||||
|
||||
// Assert
|
||||
const itemComponents = fixture.debugElement.queryAll(
|
||||
const items = fixture.debugElement.queryAll(
|
||||
By.css('checkout-order-confirmation-item-list-item')
|
||||
);
|
||||
expect(itemComponents.length).toBe(0);
|
||||
expect(items.length).toBe(2);
|
||||
});
|
||||
|
||||
it('should render nothing when orders array is empty', () => {
|
||||
// Arrange
|
||||
mockStore.orders.set([]);
|
||||
|
||||
// Act
|
||||
fixture.detectChanges();
|
||||
|
||||
// Assert
|
||||
const deliveryGroups = fixture.debugElement.queryAll(
|
||||
By.css('[data-what="delivery-type-group"]')
|
||||
);
|
||||
expect(deliveryGroups.length).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,9 +8,8 @@ import {
|
||||
import { OrderConfirmationItemListItemComponent } from './order-confirmation-item-list-item/order-confirmation-item-list-item.component';
|
||||
|
||||
import {
|
||||
groupDisplayOrderItemsByDeliveryType,
|
||||
groupDisplayOrderItemsByBranch,
|
||||
getOrderTypeIcon,
|
||||
groupItemsByDeliveryDestination,
|
||||
type OrderItemGroup,
|
||||
} from '@isa/checkout/data-access';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import {
|
||||
@@ -39,33 +38,30 @@ import { OrderConfiramtionStore } from '../reward-order-confirmation.store';
|
||||
export class OrderConfirmationItemListComponent {
|
||||
#store = inject(OrderConfiramtionStore);
|
||||
|
||||
orders = this.#store.orders;
|
||||
|
||||
/**
|
||||
* Groups order items hierarchically by delivery type and branch.
|
||||
* - Primary grouping: By delivery type from item.features.orderType (Abholung, Rücklage, Versand, etc.)
|
||||
* - Secondary grouping: By branch (only for Abholung and Rücklage)
|
||||
*
|
||||
* Note: Groups by item-level orderType, not order-level, since items within
|
||||
* a single order can have different delivery types.
|
||||
* Track function for @for to optimize rendering.
|
||||
* Generates a unique key for each group based on order type and destination.
|
||||
*/
|
||||
groupedOrders = computed(() => {
|
||||
const orders = this.orders();
|
||||
if (!orders || orders.length === 0) {
|
||||
return [];
|
||||
trackByGroupedItems = (group: OrderItemGroup): string => {
|
||||
let key = group.orderType;
|
||||
|
||||
if (group.shippingAddress) {
|
||||
key += `-shippingAddress-${JSON.stringify(group.shippingAddress)}`;
|
||||
}
|
||||
|
||||
const byDeliveryType = groupDisplayOrderItemsByDeliveryType(orders);
|
||||
if (group.targetBranch) {
|
||||
key += `-targetBranch-${group.targetBranch.id}`;
|
||||
}
|
||||
|
||||
const result = Array.from(byDeliveryType.entries()).map(
|
||||
([orderType, items]) => ({
|
||||
orderType,
|
||||
icon: getOrderTypeIcon(orderType),
|
||||
branchGroups: groupDisplayOrderItemsByBranch(orderType, items),
|
||||
}),
|
||||
);
|
||||
return key;
|
||||
};
|
||||
|
||||
console.log('Grouped orders:', result);
|
||||
return result;
|
||||
/**
|
||||
* Groups order items by delivery type and destination.
|
||||
* Uses the helper function for efficient Map-based grouping.
|
||||
*/
|
||||
groupedItems = computed(() => {
|
||||
const orders = this.#store.orders();
|
||||
return groupItemsByDeliveryDestination(orders ?? []);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { By } from '@angular/platform-browser';
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { provideProductImageUrl } from '@isa/shared/product-image';
|
||||
import { provideProductRouterLinkBuilder } from '@isa/shared/product-router-link';
|
||||
import { DisplayOrdersResource } from '@isa/oms/data-access';
|
||||
|
||||
describe('RewardOrderConfirmationComponent', () => {
|
||||
let component: RewardOrderConfirmationComponent;
|
||||
@@ -29,6 +30,14 @@ describe('RewardOrderConfirmationComponent', () => {
|
||||
let mockTabService: {
|
||||
activatedTabId: ReturnType<typeof signal>;
|
||||
};
|
||||
let mockDisplayOrdersResource: {
|
||||
orders: ReturnType<typeof signal>;
|
||||
loading: ReturnType<typeof signal>;
|
||||
error: ReturnType<typeof signal>;
|
||||
loadOrder: ReturnType<typeof vi.fn>;
|
||||
loadOrders: ReturnType<typeof vi.fn>;
|
||||
refresh: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
// Create mock paramMap subject
|
||||
@@ -46,6 +55,16 @@ describe('RewardOrderConfirmationComponent', () => {
|
||||
patch: vi.fn(),
|
||||
};
|
||||
|
||||
// Create mock DisplayOrdersResource
|
||||
mockDisplayOrdersResource = {
|
||||
orders: signal([]),
|
||||
loading: signal(false),
|
||||
error: signal(null),
|
||||
loadOrder: vi.fn(),
|
||||
loadOrders: vi.fn(),
|
||||
refresh: vi.fn(),
|
||||
};
|
||||
|
||||
// Create mock TabService with writable signal
|
||||
mockTabService = {
|
||||
activatedTabId: signal(null),
|
||||
@@ -68,10 +87,13 @@ describe('RewardOrderConfirmationComponent', () => {
|
||||
],
|
||||
});
|
||||
|
||||
// Override component's providers to use our mock store
|
||||
// Override component's providers to use our mock store and resource
|
||||
TestBed.overrideComponent(RewardOrderConfirmationComponent, {
|
||||
set: {
|
||||
providers: [{ provide: OrderConfiramtionStore, useValue: mockStore }],
|
||||
providers: [
|
||||
{ provide: OrderConfiramtionStore, useValue: mockStore },
|
||||
{ provide: DisplayOrdersResource, useValue: mockDisplayOrdersResource },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -109,7 +131,7 @@ describe('RewardOrderConfirmationComponent', () => {
|
||||
|
||||
it('should parse single order ID from route params', () => {
|
||||
// Arrange - recreate subject with correct initial value
|
||||
paramMapSubject = new BehaviorSubject<ParamMap>(convertToParamMap({ orderIds: '123' }));
|
||||
paramMapSubject = new BehaviorSubject<ParamMap>(convertToParamMap({ displayOrderIds: '123' }));
|
||||
TestBed.overrideProvider(ActivatedRoute, {
|
||||
useValue: { paramMap: paramMapSubject.asObservable() },
|
||||
});
|
||||
@@ -125,7 +147,7 @@ describe('RewardOrderConfirmationComponent', () => {
|
||||
|
||||
it('should parse multiple order IDs from route params', () => {
|
||||
// Arrange - recreate subject with correct initial value
|
||||
paramMapSubject = new BehaviorSubject<ParamMap>(convertToParamMap({ orderIds: '123+456+789' }));
|
||||
paramMapSubject = new BehaviorSubject<ParamMap>(convertToParamMap({ displayOrderIds: '123,456,789' }));
|
||||
TestBed.overrideProvider(ActivatedRoute, {
|
||||
useValue: { paramMap: paramMapSubject.asObservable() },
|
||||
});
|
||||
@@ -141,7 +163,7 @@ describe('RewardOrderConfirmationComponent', () => {
|
||||
|
||||
it('should handle single digit order IDs', () => {
|
||||
// Arrange - recreate subject with correct initial value
|
||||
paramMapSubject = new BehaviorSubject<ParamMap>(convertToParamMap({ orderIds: '1+2+3' }));
|
||||
paramMapSubject = new BehaviorSubject<ParamMap>(convertToParamMap({ displayOrderIds: '1,2,3' }));
|
||||
TestBed.overrideProvider(ActivatedRoute, {
|
||||
useValue: { paramMap: paramMapSubject.asObservable() },
|
||||
});
|
||||
@@ -157,7 +179,7 @@ describe('RewardOrderConfirmationComponent', () => {
|
||||
|
||||
it('should return empty array for empty string param', () => {
|
||||
// Arrange - recreate subject with correct initial value
|
||||
paramMapSubject = new BehaviorSubject<ParamMap>(convertToParamMap({ orderIds: '' }));
|
||||
paramMapSubject = new BehaviorSubject<ParamMap>(convertToParamMap({ displayOrderIds: '' }));
|
||||
TestBed.overrideProvider(ActivatedRoute, {
|
||||
useValue: { paramMap: paramMapSubject.asObservable() },
|
||||
});
|
||||
@@ -177,7 +199,7 @@ describe('RewardOrderConfirmationComponent', () => {
|
||||
it('should call store.patch with tabId and orderIds', () => {
|
||||
// Arrange - set up state before creating component
|
||||
mockTabService.activatedTabId.set('test-tab-123');
|
||||
paramMapSubject = new BehaviorSubject<ParamMap>(convertToParamMap({ orderIds: '456' }));
|
||||
paramMapSubject = new BehaviorSubject<ParamMap>(convertToParamMap({ displayOrderIds: '456' }));
|
||||
TestBed.overrideProvider(ActivatedRoute, {
|
||||
useValue: { paramMap: paramMapSubject.asObservable() },
|
||||
});
|
||||
@@ -198,7 +220,7 @@ describe('RewardOrderConfirmationComponent', () => {
|
||||
it('should call store.patch with undefined tabId when no tab is active', () => {
|
||||
// Arrange - set up state before creating component
|
||||
mockTabService.activatedTabId.set(null);
|
||||
paramMapSubject = new BehaviorSubject<ParamMap>(convertToParamMap({ orderIds: '789' }));
|
||||
paramMapSubject = new BehaviorSubject<ParamMap>(convertToParamMap({ displayOrderIds: '789' }));
|
||||
TestBed.overrideProvider(ActivatedRoute, {
|
||||
useValue: { paramMap: paramMapSubject.asObservable() },
|
||||
});
|
||||
@@ -218,7 +240,7 @@ describe('RewardOrderConfirmationComponent', () => {
|
||||
|
||||
it('should update store when route params change', () => {
|
||||
// Arrange - create component with initial params
|
||||
paramMapSubject = new BehaviorSubject<ParamMap>(convertToParamMap({ orderIds: '111' }));
|
||||
paramMapSubject = new BehaviorSubject<ParamMap>(convertToParamMap({ displayOrderIds: '111' }));
|
||||
TestBed.overrideProvider(ActivatedRoute, {
|
||||
useValue: { paramMap: paramMapSubject.asObservable() },
|
||||
});
|
||||
@@ -231,7 +253,7 @@ describe('RewardOrderConfirmationComponent', () => {
|
||||
mockStore.patch.mockClear();
|
||||
|
||||
// Act - change route params
|
||||
paramMapSubject.next(convertToParamMap({ orderIds: '222+333' }));
|
||||
paramMapSubject.next(convertToParamMap({ displayOrderIds: '222,333' }));
|
||||
fixture.detectChanges();
|
||||
TestBed.flushEffects();
|
||||
|
||||
@@ -282,10 +304,6 @@ describe('RewardOrderConfirmationComponent', () => {
|
||||
{ id: 3, items: [], features: { orderType: 'Versand' } },
|
||||
] as any);
|
||||
|
||||
// Need to add shopping cart to avoid child component errors
|
||||
const mockStoreWithCart = mockStore as any;
|
||||
mockStoreWithCart.shoppingCart = signal({ id: 1, items: [] });
|
||||
|
||||
fixture = TestBed.createComponent(RewardOrderConfirmationComponent);
|
||||
component = fixture.componentInstance;
|
||||
|
||||
@@ -293,10 +311,11 @@ describe('RewardOrderConfirmationComponent', () => {
|
||||
fixture.detectChanges();
|
||||
|
||||
// Assert
|
||||
// There's always exactly one item list component that renders all orders internally
|
||||
const itemLists = fixture.debugElement.queryAll(
|
||||
By.css('checkout-order-confirmation-item-list')
|
||||
);
|
||||
expect(itemLists.length).toBe(3);
|
||||
expect(itemLists.length).toBe(1);
|
||||
});
|
||||
|
||||
it('should not render item lists when orders array is empty', () => {
|
||||
@@ -309,10 +328,11 @@ describe('RewardOrderConfirmationComponent', () => {
|
||||
fixture.detectChanges();
|
||||
|
||||
// Assert
|
||||
// The item list component always exists, but it won't render any order groups
|
||||
const itemLists = fixture.debugElement.queryAll(
|
||||
By.css('checkout-order-confirmation-item-list')
|
||||
);
|
||||
expect(itemLists.length).toBe(0);
|
||||
expect(itemLists.length).toBe(1);
|
||||
});
|
||||
|
||||
it('should pass order to item list component', () => {
|
||||
@@ -325,20 +345,6 @@ describe('RewardOrderConfirmationComponent', () => {
|
||||
|
||||
mockStore.orders.set([testOrder]);
|
||||
|
||||
// Need to add shopping cart to avoid child component errors
|
||||
const mockStoreWithCart = mockStore as any;
|
||||
mockStoreWithCart.shoppingCart = signal({
|
||||
id: 1,
|
||||
items: [
|
||||
{
|
||||
data: {
|
||||
product: { catalogProductNumber: 'CAT-123' },
|
||||
destination: { type: 'InStore' },
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
fixture = TestBed.createComponent(RewardOrderConfirmationComponent);
|
||||
component = fixture.componentInstance;
|
||||
|
||||
@@ -350,7 +356,8 @@ describe('RewardOrderConfirmationComponent', () => {
|
||||
By.css('checkout-order-confirmation-item-list')
|
||||
);
|
||||
expect(itemList).toBeTruthy();
|
||||
expect(itemList.componentInstance.order()).toEqual(testOrder);
|
||||
// The item list component gets all orders from the store, not individual orders
|
||||
expect(itemList.componentInstance.orders()).toEqual([testOrder]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import { OrderConfirmationItemListComponent } from './order-confirmation-item-li
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { TabService } from '@isa/core/tabs';
|
||||
import { OrderConfiramtionStore } from './reward-order-confirmation.store';
|
||||
import { DisplayOrdersResource } from '@isa/oms/data-access';
|
||||
|
||||
@Component({
|
||||
selector: 'checkout-reward-order-confirmation',
|
||||
@@ -25,32 +26,47 @@ import { OrderConfiramtionStore } from './reward-order-confirmation.store';
|
||||
OrderConfirmationAddressesComponent,
|
||||
OrderConfirmationItemListComponent,
|
||||
],
|
||||
providers: [OrderConfiramtionStore],
|
||||
providers: [OrderConfiramtionStore, DisplayOrdersResource],
|
||||
})
|
||||
export class RewardOrderConfirmationComponent {
|
||||
#store = inject(OrderConfiramtionStore);
|
||||
#displayOrdersResource = inject(DisplayOrdersResource);
|
||||
#tabId = inject(TabService).activatedTabId;
|
||||
#activatedRoute = inject(ActivatedRoute);
|
||||
|
||||
params = toSignal(this.#activatedRoute.paramMap);
|
||||
|
||||
orderIds = computed(() => {
|
||||
displayOrderIds = computed(() => {
|
||||
const params = this.params();
|
||||
if (!params) {
|
||||
return [];
|
||||
}
|
||||
const param = params.get('orderIds');
|
||||
return param ? param.split('+').map((strId) => parseInt(strId, 10)) : [];
|
||||
const param = params.get('displayOrderIds');
|
||||
return param ? param.split(',').map((strId) => parseInt(strId, 10)) : [];
|
||||
});
|
||||
|
||||
// Expose for testing
|
||||
orderIds = this.displayOrderIds;
|
||||
orders = this.#store.orders;
|
||||
|
||||
constructor() {
|
||||
// Update store state
|
||||
effect(() => {
|
||||
const tabId = this.#tabId() || undefined;
|
||||
const orderIds = this.orderIds();
|
||||
const orderIds = this.displayOrderIds();
|
||||
|
||||
untracked(() => {
|
||||
this.#store.patch({ tabId, orderIds });
|
||||
});
|
||||
});
|
||||
|
||||
// Load display orders when orderIds change
|
||||
effect(() => {
|
||||
const orderIds = this.displayOrderIds();
|
||||
|
||||
untracked(() => {
|
||||
this.#displayOrdersResource.loadOrders(orderIds);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,11 +3,10 @@ import {
|
||||
deduplicateAddressees,
|
||||
deduplicateBranches,
|
||||
} from '@isa/crm/data-access';
|
||||
import { OmsMetadataService } from '@isa/oms/data-access';
|
||||
import { DisplayOrdersResource } from '@isa/oms/data-access';
|
||||
import {
|
||||
CheckoutMetadataService,
|
||||
hasOrderTypeFeature,
|
||||
OrderType,
|
||||
OrderTypeFeature,
|
||||
} from '@isa/checkout/data-access';
|
||||
import {
|
||||
patchState,
|
||||
@@ -31,46 +30,22 @@ const initialState: OrderConfiramtionState = {
|
||||
export const OrderConfiramtionStore = signalStore(
|
||||
withState(initialState),
|
||||
withProps(() => ({
|
||||
_omsMetadataService: inject(OmsMetadataService),
|
||||
_checkoutMetadataService: inject(CheckoutMetadataService),
|
||||
_displayOrdersResource: inject(DisplayOrdersResource),
|
||||
})),
|
||||
withComputed((state) => ({
|
||||
orders: computed(() => {
|
||||
const tabId = state.tabId();
|
||||
const orderIds = state.orderIds();
|
||||
const orders = state._displayOrdersResource.orders();
|
||||
|
||||
if (!tabId) {
|
||||
if (!orders || orders.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!orderIds || !orderIds.length) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const orders = state._omsMetadataService.getDisplayOrders(tabId);
|
||||
|
||||
return orders?.filter(
|
||||
(order) => order.id !== undefined && orderIds.includes(order.id),
|
||||
);
|
||||
return orders;
|
||||
}),
|
||||
loading: computed(() => state._displayOrdersResource.loading()),
|
||||
error: computed(() => state._displayOrdersResource.error()),
|
||||
})),
|
||||
withComputed((state) => ({
|
||||
shoppingCart: computed(() => {
|
||||
const tabId = state.tabId();
|
||||
const orders = state.orders();
|
||||
if (!tabId) {
|
||||
return undefined;
|
||||
}
|
||||
if (!orders || !orders.length) {
|
||||
return undefined;
|
||||
}
|
||||
const completedCarts =
|
||||
state._checkoutMetadataService.getCompletedShoppingCarts(tabId);
|
||||
|
||||
return completedCarts?.find(
|
||||
(cart) => cart.id === orders[0].shoppingCartId,
|
||||
);
|
||||
}),
|
||||
payers: computed(() => {
|
||||
const orders = state.orders();
|
||||
if (!orders) {
|
||||
@@ -102,9 +77,9 @@ export const OrderConfiramtionStore = signalStore(
|
||||
}
|
||||
return orders.some((order) => {
|
||||
return hasOrderTypeFeature(order.features, [
|
||||
OrderType.Delivery,
|
||||
OrderType.DigitalShipping,
|
||||
OrderType.B2BShipping,
|
||||
OrderTypeFeature.Delivery,
|
||||
OrderTypeFeature.DigitalShipping,
|
||||
OrderTypeFeature.B2BShipping,
|
||||
]);
|
||||
});
|
||||
}),
|
||||
@@ -115,8 +90,8 @@ export const OrderConfiramtionStore = signalStore(
|
||||
}
|
||||
return orders.some((order) => {
|
||||
return hasOrderTypeFeature(order.features, [
|
||||
OrderType.InStore,
|
||||
OrderType.Pickup,
|
||||
OrderTypeFeature.InStore,
|
||||
OrderTypeFeature.Pickup,
|
||||
]);
|
||||
});
|
||||
}),
|
||||
|
||||
@@ -4,7 +4,7 @@ import { OMS_ACTION_HANDLERS } from '@isa/oms/data-access';
|
||||
|
||||
export const routes: Routes = [
|
||||
{
|
||||
path: ':orderIds',
|
||||
path: ':displayOrderIds',
|
||||
providers: [
|
||||
CoreCommandModule.forChild(OMS_ACTION_HANDLERS).providers ?? [],
|
||||
],
|
||||
|
||||
@@ -147,7 +147,7 @@ export class CompleteOrderButtonComponent {
|
||||
`/${this.#tabId()}`,
|
||||
'reward',
|
||||
'order-confirmation',
|
||||
orders.map((o) => o.id).join('+'),
|
||||
orders.map((o) => o.id).join(','),
|
||||
]);
|
||||
|
||||
this.isCompleted.set(true);
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
AvailabilityFacade,
|
||||
GetSingleItemAvailabilityInputParams,
|
||||
GetAvailabilityParamsAdapter,
|
||||
OrderType,
|
||||
OrderTypeFeature,
|
||||
} from '@isa/availability/data-access';
|
||||
|
||||
// TODO: [Next Sprint - High Priority] Create comprehensive test file
|
||||
@@ -64,9 +64,9 @@ export class RewardShoppingCartItemQuantityControlComponent {
|
||||
maxQuantity = computed(() => {
|
||||
const orderType = this.orderType();
|
||||
if (
|
||||
orderType === OrderType.Delivery ||
|
||||
orderType === OrderType.DigitalShipping ||
|
||||
orderType === OrderType.B2BShipping
|
||||
orderType === OrderTypeFeature.Delivery ||
|
||||
orderType === OrderTypeFeature.DigitalShipping ||
|
||||
orderType === OrderTypeFeature.B2BShipping
|
||||
) {
|
||||
return 999;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
inject,
|
||||
} from '@angular/core';
|
||||
import { NavigateBackButtonComponent } from '@isa/core/tabs';
|
||||
import { CheckoutCustomerRewardCardComponent } from './customer-reward-card/customer-reward-card.component';
|
||||
import { BillingAndShippingAddressCardComponent } from './billing-and-shipping-address-card/billing-and-shipping-address-card.component';
|
||||
import { RewardShoppingCartItemsComponent } from './reward-shopping-cart-items/reward-shopping-cart-items.component';
|
||||
import { SelectedRewardShoppingCartResource, calculateTotalLoyaltyPoints } from '@isa/checkout/data-access';
|
||||
import {
|
||||
SelectedRewardShoppingCartResource,
|
||||
calculateTotalLoyaltyPoints,
|
||||
} from '@isa/checkout/data-access';
|
||||
import {
|
||||
SelectedCustomerResource,
|
||||
PrimaryCustomerCardResource,
|
||||
@@ -27,18 +35,15 @@ import { isaOtherInfo } from '@isa/icons';
|
||||
RewardSelectionTriggerComponent,
|
||||
NgIconComponent,
|
||||
],
|
||||
providers: [
|
||||
SelectedRewardShoppingCartResource,
|
||||
SelectedCustomerResource,
|
||||
provideIcons({ isaOtherInfo }),
|
||||
],
|
||||
providers: [provideIcons({ isaOtherInfo })],
|
||||
})
|
||||
export class RewardShoppingCartComponent {
|
||||
#shoppingCartResource = inject(SelectedRewardShoppingCartResource).resource;
|
||||
#primaryCustomerCardResource = inject(PrimaryCustomerCardResource);
|
||||
|
||||
primaryBonusCardPoints = computed(
|
||||
() => this.#primaryCustomerCardResource.primaryCustomerCard()?.totalPoints ?? 0,
|
||||
() =>
|
||||
this.#primaryCustomerCardResource.primaryCustomerCard()?.totalPoints ?? 0,
|
||||
);
|
||||
|
||||
totalPointsRequired = computed(() => {
|
||||
@@ -49,4 +54,8 @@ export class RewardShoppingCartComponent {
|
||||
insufficientPoints = computed(() => {
|
||||
return this.primaryBonusCardPoints() < this.totalPointsRequired();
|
||||
});
|
||||
|
||||
constructor() {
|
||||
this.#shoppingCartResource.reload();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,14 +4,11 @@ import {
|
||||
CompleteCrmOrderParams,
|
||||
CheckoutMetadataService,
|
||||
} from '@isa/checkout/data-access';
|
||||
import {
|
||||
OrderCreationFacade,
|
||||
OmsMetadataService,
|
||||
DisplayOrder,
|
||||
} from '@isa/oms/data-access';
|
||||
import { OrderCreationFacade, DisplayOrder } from '@isa/oms/data-access';
|
||||
import { logger } from '@isa/core/logging';
|
||||
import { HttpErrorResponse } from '@angular/common/http';
|
||||
import { isResponseArgs } from '@isa/common/data-access';
|
||||
import { DisplayOrderDTO } from '@generated/swagger/oms-api';
|
||||
|
||||
/**
|
||||
* Orchestrates checkout completion and order creation.
|
||||
@@ -39,7 +36,6 @@ export class CheckoutCompletionOrchestratorService {
|
||||
|
||||
#shoppingCartFacade = inject(ShoppingCartFacade);
|
||||
#orderCreationFacade = inject(OrderCreationFacade);
|
||||
#omsMetadataService = inject(OmsMetadataService);
|
||||
#checkoutMetadataService = inject(CheckoutMetadataService);
|
||||
|
||||
/**
|
||||
@@ -65,7 +61,7 @@ export class CheckoutCompletionOrchestratorService {
|
||||
params: CompleteCrmOrderParams,
|
||||
tabId?: number,
|
||||
abortSignal?: AbortSignal,
|
||||
): Promise<DisplayOrder[]> {
|
||||
): Promise<DisplayOrderDTO[]> {
|
||||
this.#logger.info('Starting checkout completion and order creation');
|
||||
|
||||
try {
|
||||
@@ -76,12 +72,12 @@ export class CheckoutCompletionOrchestratorService {
|
||||
abortSignal,
|
||||
);
|
||||
|
||||
const shoppingCart = await this.#shoppingCartFacade.getShoppingCart(
|
||||
await this.#shoppingCartFacade.getShoppingCart(
|
||||
params.shoppingCartId,
|
||||
abortSignal,
|
||||
);
|
||||
|
||||
let orders: DisplayOrder[] = [];
|
||||
let orders: DisplayOrderDTO[] = [];
|
||||
|
||||
try {
|
||||
orders =
|
||||
@@ -114,7 +110,7 @@ export class CheckoutCompletionOrchestratorService {
|
||||
*/
|
||||
if (
|
||||
error instanceof HttpErrorResponse &&
|
||||
isResponseArgs<DisplayOrder[]>(error.error)
|
||||
isResponseArgs<DisplayOrderDTO[]>(error.error)
|
||||
) {
|
||||
const responseArgs = error.error;
|
||||
orders = responseArgs.result;
|
||||
@@ -142,22 +138,7 @@ export class CheckoutCompletionOrchestratorService {
|
||||
}
|
||||
|
||||
if (tabId) {
|
||||
// Step 2: Update OMS metadata with created orders
|
||||
if (shoppingCart) {
|
||||
this.#checkoutMetadataService.addCompletedShoppingCart(
|
||||
tabId,
|
||||
shoppingCart,
|
||||
);
|
||||
}
|
||||
if (orders.length > 0 && shoppingCart?.id) {
|
||||
this.#omsMetadataService.addDisplayOrders(
|
||||
tabId,
|
||||
orders,
|
||||
shoppingCart.id,
|
||||
);
|
||||
}
|
||||
|
||||
// Step 3: Cleanup the reward shopping cart
|
||||
// Step 2: Cleanup the reward shopping cart
|
||||
this.#checkoutMetadataService.setRewardShoppingCartId(tabId, undefined);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from './lib/destination-info/destination-info.component';
|
||||
export * from './lib/display-order-destination-info/display-order-destination-info.component';
|
||||
export * from './lib/product-info/product-info.component';
|
||||
export * from './lib/product-info/product-info-redemption.component';
|
||||
export * from './lib/stock-info/stock-info.component';
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
:host {
|
||||
@apply flex flex-col items-start gap-2 flex-grow;
|
||||
}
|
||||
|
||||
.address-container {
|
||||
@apply line-clamp-2 break-words text-ellipsis;
|
||||
@apply contents;
|
||||
}
|
||||
|
||||
@@ -1,28 +1,7 @@
|
||||
<div
|
||||
class="flex items-center gap-2 self-stretch"
|
||||
[class.underline]="underline()"
|
||||
>
|
||||
<ng-icon
|
||||
[name]="destinationIcon()"
|
||||
size="1.5rem"
|
||||
class="text-neutral-900"
|
||||
></ng-icon>
|
||||
<span class="isa-text-body-2-bold text-isa-secondary-900">{{
|
||||
orderType()
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="text-isa-neutral-600 isa-text-body-2-regular address-container">
|
||||
@if (displayAddress()) {
|
||||
{{ name() }} |
|
||||
<shared-inline-address [address]="address()"></shared-inline-address>
|
||||
} @else {
|
||||
@if (estimatedDelivery(); as delivery) {
|
||||
@if (delivery.stop) {
|
||||
Zustellung zwischen {{ delivery.start | date: 'E, dd.MM.' }} und
|
||||
{{ delivery.stop | date: 'E, dd.MM.' }}
|
||||
} @else {
|
||||
Zustellung voraussichtlich am {{ delivery.start | date: 'E, dd.MM.' }}
|
||||
}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
<shared-order-destination
|
||||
[underline]="underline()"
|
||||
[branch]="mappedBranch()"
|
||||
[shippingAddress]="mappedShippingAddress()"
|
||||
[orderType]="orderType()"
|
||||
[estimatedDelivery]="estimatedDelivery()">
|
||||
</shared-order-destination>
|
||||
|
||||
@@ -9,36 +9,28 @@ import {
|
||||
import {
|
||||
BranchResource,
|
||||
getOrderTypeFeature,
|
||||
OrderType,
|
||||
ShoppingCartItem,
|
||||
} from '@isa/checkout/data-access';
|
||||
import { SelectedCustomerShippingAddressResource } from '@isa/crm/data-access';
|
||||
import {
|
||||
isaDeliveryVersand,
|
||||
isaDeliveryRuecklage2,
|
||||
isaDeliveryRuecklage1,
|
||||
} from '@isa/icons';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import { InlineAddressComponent } from '@isa/shared/address';
|
||||
import { DatePipe } from '@angular/common';
|
||||
import { coerceBooleanProperty } from '@angular/cdk/coercion';
|
||||
import { OrderDestinationComponent } from '@isa/shared/delivery';
|
||||
import { logger } from '@isa/core/logging';
|
||||
|
||||
export type DestinationInfo = {
|
||||
features: ShoppingCartItem['features'];
|
||||
availability: ShoppingCartItem['availability'];
|
||||
destination: ShoppingCartItem['destination'];
|
||||
};
|
||||
|
||||
@Component({
|
||||
selector: 'checkout-destination-info',
|
||||
templateUrl: './destination-info.component.html',
|
||||
styleUrls: ['./destination-info.component.css'],
|
||||
imports: [NgIcon, InlineAddressComponent, DatePipe],
|
||||
providers: [
|
||||
provideIcons({
|
||||
isaDeliveryVersand,
|
||||
isaDeliveryRuecklage2,
|
||||
isaDeliveryRuecklage1,
|
||||
}),
|
||||
BranchResource,
|
||||
SelectedCustomerShippingAddressResource,
|
||||
],
|
||||
imports: [OrderDestinationComponent],
|
||||
providers: [BranchResource],
|
||||
})
|
||||
export class DestinationInfoComponent {
|
||||
#logger = logger({ component: 'DestinationInfoComponent' });
|
||||
#branchResource = inject(BranchResource);
|
||||
#shippingAddressResource = inject(SelectedCustomerShippingAddressResource);
|
||||
|
||||
@@ -46,40 +38,13 @@ export class DestinationInfoComponent {
|
||||
transform: coerceBooleanProperty,
|
||||
});
|
||||
|
||||
shoppingCartItem =
|
||||
input.required<
|
||||
Pick<ShoppingCartItem, 'availability' | 'destination' | 'features'>
|
||||
>();
|
||||
shoppingCartItem = input.required<DestinationInfo>();
|
||||
|
||||
orderType = computed(() => {
|
||||
return getOrderTypeFeature(this.shoppingCartItem().features);
|
||||
});
|
||||
|
||||
destinationIcon = computed(() => {
|
||||
const orderType = this.orderType();
|
||||
|
||||
if (OrderType.Delivery === orderType) {
|
||||
return 'isaDeliveryVersand';
|
||||
}
|
||||
|
||||
if (OrderType.Pickup === orderType) {
|
||||
return 'isaDeliveryRuecklage2';
|
||||
}
|
||||
|
||||
if (OrderType.InStore === orderType) {
|
||||
return 'isaDeliveryRuecklage1';
|
||||
}
|
||||
|
||||
return 'isaDeliveryVersand';
|
||||
});
|
||||
|
||||
displayAddress = computed(() => {
|
||||
const orderType = this.orderType();
|
||||
return (
|
||||
OrderType.InStore === orderType ||
|
||||
OrderType.Pickup === orderType ||
|
||||
OrderType.B2BShipping === orderType
|
||||
);
|
||||
const features = this.shoppingCartItem().features;
|
||||
const orderType = getOrderTypeFeature(features);
|
||||
this.#logger.debug('Computing order type', () => ({ orderType, features }));
|
||||
return orderType;
|
||||
});
|
||||
|
||||
branchContainer = computed(
|
||||
@@ -99,34 +64,25 @@ export class DestinationInfoComponent {
|
||||
}
|
||||
});
|
||||
|
||||
name = computed(() => {
|
||||
const orderType = this.orderType();
|
||||
if (
|
||||
OrderType.Delivery === orderType ||
|
||||
OrderType.B2BShipping === orderType ||
|
||||
OrderType.DigitalShipping === orderType
|
||||
) {
|
||||
const shippingAddress = this.#shippingAddressResource.resource.value();
|
||||
return `${shippingAddress?.firstName || ''} ${shippingAddress?.lastName || ''}`.trim();
|
||||
}
|
||||
|
||||
return this.branch()?.name || 'Filiale nicht gefunden';
|
||||
mappedBranch = computed(() => {
|
||||
const branch = this.branch();
|
||||
return branch
|
||||
? {
|
||||
name: branch.name,
|
||||
address: branch.address,
|
||||
}
|
||||
: undefined;
|
||||
});
|
||||
|
||||
address = computed(() => {
|
||||
const orderType = this.orderType();
|
||||
|
||||
if (
|
||||
OrderType.Delivery === orderType ||
|
||||
OrderType.B2BShipping === orderType ||
|
||||
OrderType.DigitalShipping === orderType
|
||||
) {
|
||||
const shippingAddress = this.#shippingAddressResource.resource.value();
|
||||
return shippingAddress?.address || undefined;
|
||||
}
|
||||
|
||||
const destination = this.shoppingCartItem().destination;
|
||||
return destination?.data?.targetBranch?.data?.address;
|
||||
mappedShippingAddress = computed(() => {
|
||||
const address = this.#shippingAddressResource.resource.value();
|
||||
return address
|
||||
? {
|
||||
firstName: address.firstName,
|
||||
lastName: address.lastName,
|
||||
address: address.address,
|
||||
}
|
||||
: undefined;
|
||||
});
|
||||
|
||||
estimatedDelivery = computed(() => {
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
:host {
|
||||
@apply contents;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<shared-order-destination
|
||||
[underline]="underline()"
|
||||
[branch]="mappedBranch()"
|
||||
[shippingAddress]="mappedShippingAddress()"
|
||||
[orderType]="orderType()"
|
||||
[estimatedDelivery]="estimatedDelivery()">
|
||||
</shared-order-destination>
|
||||
@@ -0,0 +1,74 @@
|
||||
import { Component, computed, inject, input } from '@angular/core';
|
||||
import { coerceBooleanProperty } from '@angular/cdk/coercion';
|
||||
import { getOrderTypeFeature } from '@isa/checkout/data-access';
|
||||
import { DisplayOrder, DisplayOrderItem } from '@isa/oms/data-access';
|
||||
import {
|
||||
OrderDestinationComponent,
|
||||
ShippingAddress,
|
||||
} from '@isa/shared/delivery';
|
||||
import { logger } from '@isa/core/logging';
|
||||
|
||||
@Component({
|
||||
selector: 'checkout-display-order-destination-info',
|
||||
templateUrl: './display-order-destination-info.component.html',
|
||||
styleUrls: ['./display-order-destination-info.component.css'],
|
||||
imports: [OrderDestinationComponent],
|
||||
})
|
||||
export class DisplayOrderDestinationInfoComponent {
|
||||
#logger = logger({ component: 'DisplayOrderDestinationInfoComponent' });
|
||||
|
||||
underline = input<boolean, unknown>(false, {
|
||||
transform: coerceBooleanProperty,
|
||||
});
|
||||
|
||||
// Accept the parent DisplayOrder (required for branch info)
|
||||
order = input.required<DisplayOrder>();
|
||||
|
||||
// Optionally accept DisplayOrderItem (for potential future item-specific logic)
|
||||
item = input<DisplayOrderItem>();
|
||||
|
||||
orderType = computed(() => {
|
||||
const order = this.order();
|
||||
const features = order.features;
|
||||
return getOrderTypeFeature(features);
|
||||
});
|
||||
|
||||
mappedBranch = computed(() => {
|
||||
const order = this.order();
|
||||
const branch = order.targetBranch;
|
||||
this.#logger.debug('Mapping branch from DisplayOrder', () => ({
|
||||
branchName: branch?.name,
|
||||
branchAddress: branch?.address,
|
||||
}));
|
||||
return branch
|
||||
? {
|
||||
name: branch.name,
|
||||
address: branch.address,
|
||||
}
|
||||
: undefined;
|
||||
});
|
||||
|
||||
mappedShippingAddress = computed<ShippingAddress | undefined>(() => {
|
||||
const order = this.order();
|
||||
const shippingAddress = order.shippingAddress;
|
||||
|
||||
this.#logger.debug('Mapping shipping address from DisplayOrder', () => ({
|
||||
firstName: shippingAddress?.firstName,
|
||||
lastName: shippingAddress?.lastName,
|
||||
hasAddress: !!shippingAddress?.address,
|
||||
}));
|
||||
|
||||
if (!shippingAddress) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
firstName: shippingAddress.firstName,
|
||||
lastName: shippingAddress.lastName,
|
||||
address: shippingAddress.address,
|
||||
};
|
||||
});
|
||||
|
||||
// DisplayOrder doesn't have estimatedDelivery
|
||||
estimatedDelivery = computed(() => null);
|
||||
}
|
||||
@@ -4,14 +4,11 @@ import {
|
||||
computed,
|
||||
input,
|
||||
} from '@angular/core';
|
||||
import { Product } from '@isa/common/data-access';
|
||||
import { ProductImageDirective } from '@isa/shared/product-image';
|
||||
import { ProductRouterLinkDirective } from '@isa/shared/product-router-link';
|
||||
|
||||
export type ProductInfoItem = {
|
||||
ean: string;
|
||||
name: string;
|
||||
contributors: string;
|
||||
};
|
||||
export type ProductInfoItem = Pick<Product, 'ean' | 'name' | 'contributors'>;
|
||||
|
||||
export type ProductNameSize = 'small' | 'medium' | 'large';
|
||||
|
||||
|
||||
@@ -1,209 +1,205 @@
|
||||
# Reward Selection Dialog
|
||||
|
||||
Angular library for managing reward selection in shopping cart context. Allows users to toggle between regular purchase and reward redemption using bonus points.
|
||||
|
||||
## Features
|
||||
|
||||
- 🎯 Pre-built trigger component or direct service integration
|
||||
- 🔄 Automatic resource management (carts, bonus cards)
|
||||
- 📊 Smart grouping by order type and branch
|
||||
- 💾 NgRx Signals state management
|
||||
- ✅ Full TypeScript support
|
||||
|
||||
## Installation
|
||||
|
||||
```typescript
|
||||
import {
|
||||
RewardSelectionService,
|
||||
RewardSelectionPopUpService,
|
||||
RewardSelectionTriggerComponent,
|
||||
} from '@isa/checkout/shared/reward-selection-dialog';
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Using the Trigger Component (Recommended)
|
||||
|
||||
Simplest integration - includes all providers automatically:
|
||||
|
||||
```typescript
|
||||
import { Component } from '@angular/core';
|
||||
import { RewardSelectionTriggerComponent } from '@isa/checkout/shared/reward-selection-dialog';
|
||||
|
||||
@Component({
|
||||
selector: 'app-checkout',
|
||||
template: `<lib-reward-selection-trigger />`,
|
||||
imports: [RewardSelectionTriggerComponent],
|
||||
})
|
||||
export class CheckoutComponent {}
|
||||
```
|
||||
|
||||
### Using the Pop-Up Service
|
||||
|
||||
More control over navigation flow:
|
||||
|
||||
```typescript
|
||||
import { Component, inject } from '@angular/core';
|
||||
import {
|
||||
RewardSelectionPopUpService,
|
||||
NavigateAfterRewardSelection,
|
||||
RewardSelectionService,
|
||||
} from '@isa/checkout/shared/reward-selection-dialog';
|
||||
import {
|
||||
SelectedShoppingCartResource,
|
||||
SelectedRewardShoppingCartResource,
|
||||
} from '@isa/checkout/data-access';
|
||||
|
||||
@Component({
|
||||
selector: 'app-custom-checkout',
|
||||
template: `<button (click)="openRewardSelection()">Select Rewards</button>`,
|
||||
providers: [
|
||||
// Required providers
|
||||
SelectedShoppingCartResource,
|
||||
SelectedRewardShoppingCartResource,
|
||||
RewardSelectionService,
|
||||
RewardSelectionPopUpService,
|
||||
],
|
||||
})
|
||||
export class CustomCheckoutComponent {
|
||||
#popUpService = inject(RewardSelectionPopUpService);
|
||||
|
||||
async openRewardSelection() {
|
||||
const result = await this.#popUpService.popUp();
|
||||
|
||||
// Handle navigation: 'cart' | 'reward' | 'catalog' | undefined
|
||||
if (result === NavigateAfterRewardSelection.CART) {
|
||||
// Navigate to cart
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Using the Service Directly
|
||||
|
||||
For custom UI or advanced use cases:
|
||||
|
||||
```typescript
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { RewardSelectionService } from '@isa/checkout/shared/reward-selection-dialog';
|
||||
import {
|
||||
SelectedShoppingCartResource,
|
||||
SelectedRewardShoppingCartResource,
|
||||
} from '@isa/checkout/data-access';
|
||||
|
||||
@Component({
|
||||
selector: 'app-advanced',
|
||||
template: `
|
||||
@if (canOpen()) {
|
||||
<button (click)="openDialog()" [disabled]="isLoading()">
|
||||
{{ eligibleItemsCount() }} items as rewards ({{ availablePoints() }} points)
|
||||
</button>
|
||||
}
|
||||
`,
|
||||
providers: [
|
||||
SelectedShoppingCartResource,
|
||||
SelectedRewardShoppingCartResource,
|
||||
RewardSelectionService,
|
||||
],
|
||||
})
|
||||
export class AdvancedComponent {
|
||||
#service = inject(RewardSelectionService);
|
||||
|
||||
canOpen = this.#service.canOpen;
|
||||
isLoading = this.#service.isLoading;
|
||||
eligibleItemsCount = computed(() => this.#service.eligibleItems().length);
|
||||
availablePoints = this.#service.primaryBonusCardPoints;
|
||||
|
||||
async openDialog() {
|
||||
const result = await this.#service.open({ closeText: 'Cancel' });
|
||||
if (result) {
|
||||
// Handle result.rewardSelectionItems
|
||||
await this.#service.reloadResources();
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### RewardSelectionService
|
||||
|
||||
**Key Signals:**
|
||||
- `canOpen()`: `boolean` - Can dialog be opened
|
||||
- `isLoading()`: `boolean` - Loading state
|
||||
- `eligibleItems()`: `RewardSelectionItem[]` - Items available as rewards
|
||||
- `primaryBonusCardPoints()`: `number` - Available points
|
||||
|
||||
**Methods:**
|
||||
- `open({ closeText }): Promise<RewardSelectionDialogResult>` - Opens dialog
|
||||
- `reloadResources(): Promise<void>` - Reloads all data
|
||||
|
||||
### RewardSelectionPopUpService
|
||||
|
||||
**Methods:**
|
||||
- `popUp(): Promise<NavigateAfterRewardSelection | undefined>` - Opens dialog with navigation flow
|
||||
|
||||
**Return values:**
|
||||
- `'cart'` - Navigate to shopping cart
|
||||
- `'reward'` - Navigate to reward checkout
|
||||
- `'catalog'` - Navigate to catalog
|
||||
- `undefined` - No navigation needed
|
||||
|
||||
### Types
|
||||
|
||||
```typescript
|
||||
interface RewardSelectionItem {
|
||||
item: ShoppingCartItem;
|
||||
catalogPrice: Price | undefined;
|
||||
availabilityPrice: Price | undefined;
|
||||
catalogRewardPoints: number | undefined;
|
||||
cartQuantity: number;
|
||||
rewardCartQuantity: number;
|
||||
}
|
||||
|
||||
type RewardSelectionDialogResult = {
|
||||
rewardSelectionItems: RewardSelectionItem[];
|
||||
} | undefined;
|
||||
|
||||
type NavigateAfterRewardSelection = 'cart' | 'reward' | 'catalog';
|
||||
```
|
||||
|
||||
## Required Providers
|
||||
|
||||
When using `RewardSelectionService` or `RewardSelectionPopUpService` directly, provide:
|
||||
|
||||
```typescript
|
||||
providers: [
|
||||
SelectedShoppingCartResource, // Regular cart data
|
||||
SelectedRewardShoppingCartResource, // Reward cart data
|
||||
RewardSelectionService, // Core service
|
||||
RewardSelectionPopUpService, // Optional: only if using pop-up
|
||||
]
|
||||
```
|
||||
|
||||
**Note:** `RewardSelectionTriggerComponent` includes all required providers automatically.
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
nx test reward-selection-dialog
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
reward-selection-dialog/
|
||||
├── helper/ # Pure utility functions
|
||||
├── resource/ # Data resources
|
||||
├── service/ # Business logic
|
||||
├── store/ # NgRx Signals state
|
||||
└── trigger/ # Trigger component
|
||||
```
|
||||
|
||||
## Dependencies
|
||||
|
||||
- `@isa/checkout/data-access` - Cart resources
|
||||
- `@isa/crm/data-access` - Customer data
|
||||
- `@isa/catalogue/data-access` - Product catalog
|
||||
- `@isa/ui/dialog` - Dialog infrastructure
|
||||
- `@ngrx/signals` - State management
|
||||
# Reward Selection Dialog
|
||||
|
||||
Angular library for managing reward selection in shopping cart context. Allows users to toggle between regular purchase and reward redemption using bonus points.
|
||||
|
||||
## Features
|
||||
|
||||
- 🎯 Pre-built trigger component or direct service integration
|
||||
- 🔄 Automatic resource management (carts, bonus cards)
|
||||
- 📊 Smart grouping by order type and branch
|
||||
- 💾 NgRx Signals state management
|
||||
- ✅ Full TypeScript support
|
||||
|
||||
## Installation
|
||||
|
||||
```typescript
|
||||
import {
|
||||
RewardSelectionService,
|
||||
RewardSelectionPopUpService,
|
||||
RewardSelectionTriggerComponent,
|
||||
} from '@isa/checkout/shared/reward-selection-dialog';
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Using the Trigger Component (Recommended)
|
||||
|
||||
Simplest integration - includes all providers automatically:
|
||||
|
||||
```typescript
|
||||
import { Component } from '@angular/core';
|
||||
import { RewardSelectionTriggerComponent } from '@isa/checkout/shared/reward-selection-dialog';
|
||||
|
||||
@Component({
|
||||
selector: 'app-checkout',
|
||||
template: `<lib-reward-selection-trigger />`,
|
||||
imports: [RewardSelectionTriggerComponent],
|
||||
})
|
||||
export class CheckoutComponent {}
|
||||
```
|
||||
|
||||
### Using the Pop-Up Service
|
||||
|
||||
More control over navigation flow:
|
||||
|
||||
```typescript
|
||||
import { Component, inject } from '@angular/core';
|
||||
import {
|
||||
RewardSelectionPopUpService,
|
||||
NavigateAfterRewardSelection,
|
||||
RewardSelectionService,
|
||||
} from '@isa/checkout/shared/reward-selection-dialog';
|
||||
import {
|
||||
SelectedShoppingCartResource,
|
||||
SelectedRewardShoppingCartResource,
|
||||
} from '@isa/checkout/data-access';
|
||||
|
||||
@Component({
|
||||
selector: 'app-custom-checkout',
|
||||
template: `<button (click)="openRewardSelection()">Select Rewards</button>`,
|
||||
providers: [
|
||||
// Required providers
|
||||
SelectedShoppingCartResource,
|
||||
RewardSelectionService,
|
||||
RewardSelectionPopUpService,
|
||||
],
|
||||
})
|
||||
export class CustomCheckoutComponent {
|
||||
#popUpService = inject(RewardSelectionPopUpService);
|
||||
|
||||
async openRewardSelection() {
|
||||
const result = await this.#popUpService.popUp();
|
||||
|
||||
// Handle navigation: 'cart' | 'reward' | 'catalog' | undefined
|
||||
if (result === NavigateAfterRewardSelection.CART) {
|
||||
// Navigate to cart
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Using the Service Directly
|
||||
|
||||
For custom UI or advanced use cases:
|
||||
|
||||
```typescript
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { RewardSelectionService } from '@isa/checkout/shared/reward-selection-dialog';
|
||||
import {
|
||||
SelectedShoppingCartResource,
|
||||
} from '@isa/checkout/data-access';
|
||||
|
||||
@Component({
|
||||
selector: 'app-advanced',
|
||||
template: `
|
||||
@if (canOpen()) {
|
||||
<button (click)="openDialog()" [disabled]="isLoading()">
|
||||
{{ eligibleItemsCount() }} items as rewards ({{ availablePoints() }} points)
|
||||
</button>
|
||||
}
|
||||
`,
|
||||
providers: [
|
||||
SelectedShoppingCartResource,
|
||||
RewardSelectionService,
|
||||
],
|
||||
})
|
||||
export class AdvancedComponent {
|
||||
#service = inject(RewardSelectionService);
|
||||
|
||||
canOpen = this.#service.canOpen;
|
||||
isLoading = this.#service.isLoading;
|
||||
eligibleItemsCount = computed(() => this.#service.eligibleItems().length);
|
||||
availablePoints = this.#service.primaryBonusCardPoints;
|
||||
|
||||
async openDialog() {
|
||||
const result = await this.#service.open({ closeText: 'Cancel' });
|
||||
if (result) {
|
||||
// Handle result.rewardSelectionItems
|
||||
await this.#service.reloadResources();
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### RewardSelectionService
|
||||
|
||||
**Key Signals:**
|
||||
- `canOpen()`: `boolean` - Can dialog be opened
|
||||
- `isLoading()`: `boolean` - Loading state
|
||||
- `eligibleItems()`: `RewardSelectionItem[]` - Items available as rewards
|
||||
- `primaryBonusCardPoints()`: `number` - Available points
|
||||
|
||||
**Methods:**
|
||||
- `open({ closeText }): Promise<RewardSelectionDialogResult>` - Opens dialog
|
||||
- `reloadResources(): Promise<void>` - Reloads all data
|
||||
|
||||
### RewardSelectionPopUpService
|
||||
|
||||
**Methods:**
|
||||
- `popUp(): Promise<NavigateAfterRewardSelection | undefined>` - Opens dialog with navigation flow
|
||||
|
||||
**Return values:**
|
||||
- `'cart'` - Navigate to shopping cart
|
||||
- `'reward'` - Navigate to reward checkout
|
||||
- `'catalog'` - Navigate to catalog
|
||||
- `undefined` - No navigation needed
|
||||
|
||||
### Types
|
||||
|
||||
```typescript
|
||||
interface RewardSelectionItem {
|
||||
item: ShoppingCartItem;
|
||||
catalogPrice: Price | undefined;
|
||||
availabilityPrice: Price | undefined;
|
||||
catalogRewardPoints: number | undefined;
|
||||
cartQuantity: number;
|
||||
rewardCartQuantity: number;
|
||||
}
|
||||
|
||||
type RewardSelectionDialogResult = {
|
||||
rewardSelectionItems: RewardSelectionItem[];
|
||||
} | undefined;
|
||||
|
||||
type NavigateAfterRewardSelection = 'cart' | 'reward' | 'catalog';
|
||||
```
|
||||
|
||||
## Required Providers
|
||||
|
||||
When using `RewardSelectionService` or `RewardSelectionPopUpService` directly, provide:
|
||||
|
||||
```typescript
|
||||
providers: [
|
||||
SelectedShoppingCartResource, // Regular cart data
|
||||
RewardSelectionService, // Core service
|
||||
RewardSelectionPopUpService, // Optional: only if using pop-up
|
||||
]
|
||||
```
|
||||
|
||||
**Note:** `RewardSelectionTriggerComponent` includes all required providers automatically.
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
nx test reward-selection-dialog
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
reward-selection-dialog/
|
||||
├── helper/ # Pure utility functions
|
||||
├── resource/ # Data resources
|
||||
├── service/ # Business logic
|
||||
├── store/ # NgRx Signals state
|
||||
└── trigger/ # Trigger component
|
||||
```
|
||||
|
||||
## Dependencies
|
||||
|
||||
- `@isa/checkout/data-access` - Cart resources
|
||||
- `@isa/crm/data-access` - Customer data
|
||||
- `@isa/catalogue/data-access` - Product catalog
|
||||
- `@isa/ui/dialog` - Dialog infrastructure
|
||||
- `@ngrx/signals` - State management
|
||||
|
||||
@@ -1,175 +1,175 @@
|
||||
import { computed, inject, Injectable, resource, signal } from '@angular/core';
|
||||
import { AvailabilityService } from '@isa/availability/data-access';
|
||||
import {
|
||||
CatalougeSearchService,
|
||||
Price as CatalogPrice,
|
||||
} from '@isa/catalogue/data-access';
|
||||
import {
|
||||
OrderType,
|
||||
Price as AvailabilityPrice,
|
||||
} from '@isa/checkout/data-access';
|
||||
import { logger } from '@isa/core/logging';
|
||||
|
||||
/**
|
||||
* Input item for availability check - contains EAN, orderType and optional branchId
|
||||
*/
|
||||
export interface ItemWithOrderType {
|
||||
ean: string;
|
||||
orderType: OrderType;
|
||||
branchId?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result containing price from availability and redemption points from catalog
|
||||
*/
|
||||
export interface PriceAndRedemptionPointsResult {
|
||||
ean: string;
|
||||
availabilityPrice?: AvailabilityPrice;
|
||||
catalogPrice?: CatalogPrice;
|
||||
redemptionPoints?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resource for fetching combined price and redemption points data.
|
||||
*
|
||||
* This resource:
|
||||
* 1. Fetches catalog items by EAN to get redemption points
|
||||
* 2. Groups items by order type
|
||||
* 3. Fetches availability data for each order type group
|
||||
* 4. Combines catalog redemption points with availability prices
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const resource = inject(PriceAndRedemptionPointsResource);
|
||||
*
|
||||
* // Load data for items
|
||||
* resource.loadPriceAndRedemptionPoints([
|
||||
* { ean: '1234567890', orderType: OrderType.Delivery },
|
||||
* { ean: '0987654321', orderType: OrderType.Pickup }
|
||||
* ]);
|
||||
*
|
||||
* // Access results
|
||||
* const results = resource.priceAndRedemptionPoints();
|
||||
* ```
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class PriceAndRedemptionPointsResource {
|
||||
#catalogueSearchService = inject(CatalougeSearchService);
|
||||
#availabilityService = inject(AvailabilityService);
|
||||
#logger = logger(() => ({ resource: 'PriceAndRedemptionPoints' }));
|
||||
|
||||
#items = signal<ItemWithOrderType[] | undefined>(undefined);
|
||||
|
||||
#priceAndRedemptionPointsResource = resource({
|
||||
params: computed(() => ({ items: this.#items() })),
|
||||
loader: async ({
|
||||
params,
|
||||
abortSignal,
|
||||
}): Promise<PriceAndRedemptionPointsResult[]> => {
|
||||
if (!params?.items || params.items.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Extract unique EANs for catalog lookup
|
||||
const eans = [...new Set(params.items.map((item) => item.ean))];
|
||||
|
||||
// Fetch catalog items to get redemption points
|
||||
const catalogItems = await this.#catalogueSearchService.searchByEans(
|
||||
eans,
|
||||
abortSignal,
|
||||
);
|
||||
|
||||
// Create a map for quick catalog lookup by EAN
|
||||
const catalogByEan = new Map(
|
||||
catalogItems.map((item) => [item.product.ean, item]),
|
||||
);
|
||||
|
||||
// Fetch availability for each item individually (in parallel)
|
||||
const availabilityPromises = params.items.map(async (checkItem) => {
|
||||
const catalogItem = catalogByEan.get(checkItem.ean);
|
||||
|
||||
// Skip items without catalog entry
|
||||
if (!catalogItem?.id) {
|
||||
return { ean: checkItem.ean, price: undefined };
|
||||
}
|
||||
|
||||
try {
|
||||
// Call getAvailability for single item
|
||||
// InStore (Rücklage) has different schema: uses itemId instead of item object
|
||||
const params =
|
||||
checkItem.orderType === OrderType.InStore
|
||||
? {
|
||||
orderType: checkItem.orderType,
|
||||
branchId: checkItem.branchId,
|
||||
itemId: catalogItem.id,
|
||||
}
|
||||
: {
|
||||
orderType: checkItem.orderType,
|
||||
branchId: checkItem.branchId,
|
||||
item: {
|
||||
itemId: catalogItem.id,
|
||||
ean: checkItem.ean,
|
||||
quantity: 1,
|
||||
price: catalogItem.catalogAvailability?.price,
|
||||
},
|
||||
};
|
||||
|
||||
const availability = await this.#availabilityService.getAvailability(
|
||||
params as any,
|
||||
abortSignal,
|
||||
);
|
||||
|
||||
return {
|
||||
ean: checkItem.ean,
|
||||
price: availability?.price as AvailabilityPrice | undefined,
|
||||
};
|
||||
} catch (error) {
|
||||
this.#logger.error(
|
||||
'Failed to fetch availability for item',
|
||||
error as Error,
|
||||
() => ({
|
||||
ean: checkItem.ean,
|
||||
orderType: checkItem.orderType,
|
||||
branchId: checkItem.branchId,
|
||||
}),
|
||||
);
|
||||
return { ean: checkItem.ean, price: undefined };
|
||||
}
|
||||
});
|
||||
|
||||
// Wait for all availability requests to complete
|
||||
const availabilityResults = await Promise.all(availabilityPromises);
|
||||
|
||||
// Build price map from results
|
||||
const pricesByEan = new Map(
|
||||
availabilityResults.map((result) => [result.ean, result.price]),
|
||||
);
|
||||
|
||||
// Build final result: combine catalog prices, availability prices and redemption points
|
||||
const results: PriceAndRedemptionPointsResult[] = eans.map((ean) => ({
|
||||
ean,
|
||||
availabilityPrice: pricesByEan.get(ean),
|
||||
catalogPrice: catalogByEan.get(ean)?.catalogAvailability?.price,
|
||||
redemptionPoints: catalogByEan.get(ean)?.redemptionPoints,
|
||||
}));
|
||||
|
||||
return results;
|
||||
},
|
||||
defaultValue: [],
|
||||
});
|
||||
|
||||
readonly priceAndRedemptionPoints =
|
||||
this.#priceAndRedemptionPointsResource.value.asReadonly();
|
||||
readonly loading = this.#priceAndRedemptionPointsResource.isLoading;
|
||||
readonly error = computed(
|
||||
() => this.#priceAndRedemptionPointsResource.error()?.message ?? null,
|
||||
);
|
||||
|
||||
loadPriceAndRedemptionPoints(items: ItemWithOrderType[] | undefined) {
|
||||
this.#items.set(items);
|
||||
}
|
||||
|
||||
refresh() {
|
||||
this.#priceAndRedemptionPointsResource.reload();
|
||||
}
|
||||
}
|
||||
import { computed, inject, Injectable, resource, signal } from '@angular/core';
|
||||
import { AvailabilityService } from '@isa/availability/data-access';
|
||||
import {
|
||||
CatalougeSearchService,
|
||||
Price as CatalogPrice,
|
||||
} from '@isa/catalogue/data-access';
|
||||
import {
|
||||
OrderTypeFeature,
|
||||
Price as AvailabilityPrice,
|
||||
} from '@isa/checkout/data-access';
|
||||
import { logger } from '@isa/core/logging';
|
||||
|
||||
/**
|
||||
* Input item for availability check - contains EAN, orderType and optional branchId
|
||||
*/
|
||||
export interface ItemWithOrderType {
|
||||
ean: string;
|
||||
orderType: OrderTypeFeature;
|
||||
branchId?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result containing price from availability and redemption points from catalog
|
||||
*/
|
||||
export interface PriceAndRedemptionPointsResult {
|
||||
ean: string;
|
||||
availabilityPrice?: AvailabilityPrice;
|
||||
catalogPrice?: CatalogPrice;
|
||||
redemptionPoints?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resource for fetching combined price and redemption points data.
|
||||
*
|
||||
* This resource:
|
||||
* 1. Fetches catalog items by EAN to get redemption points
|
||||
* 2. Groups items by order type
|
||||
* 3. Fetches availability data for each order type group
|
||||
* 4. Combines catalog redemption points with availability prices
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const resource = inject(PriceAndRedemptionPointsResource);
|
||||
*
|
||||
* // Load data for items
|
||||
* resource.loadPriceAndRedemptionPoints([
|
||||
* { ean: '1234567890', orderType: OrderType.Delivery },
|
||||
* { ean: '0987654321', orderType: OrderType.Pickup }
|
||||
* ]);
|
||||
*
|
||||
* // Access results
|
||||
* const results = resource.priceAndRedemptionPoints();
|
||||
* ```
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class PriceAndRedemptionPointsResource {
|
||||
#catalogueSearchService = inject(CatalougeSearchService);
|
||||
#availabilityService = inject(AvailabilityService);
|
||||
#logger = logger(() => ({ resource: 'PriceAndRedemptionPoints' }));
|
||||
|
||||
#items = signal<ItemWithOrderType[] | undefined>(undefined);
|
||||
|
||||
#priceAndRedemptionPointsResource = resource({
|
||||
params: computed(() => ({ items: this.#items() })),
|
||||
loader: async ({
|
||||
params,
|
||||
abortSignal,
|
||||
}): Promise<PriceAndRedemptionPointsResult[]> => {
|
||||
if (!params?.items || params.items.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Extract unique EANs for catalog lookup
|
||||
const eans = [...new Set(params.items.map((item) => item.ean))];
|
||||
|
||||
// Fetch catalog items to get redemption points
|
||||
const catalogItems = await this.#catalogueSearchService.searchByEans(
|
||||
eans,
|
||||
abortSignal,
|
||||
);
|
||||
|
||||
// Create a map for quick catalog lookup by EAN
|
||||
const catalogByEan = new Map(
|
||||
catalogItems.map((item) => [item.product.ean, item]),
|
||||
);
|
||||
|
||||
// Fetch availability for each item individually (in parallel)
|
||||
const availabilityPromises = params.items.map(async (checkItem) => {
|
||||
const catalogItem = catalogByEan.get(checkItem.ean);
|
||||
|
||||
// Skip items without catalog entry
|
||||
if (!catalogItem?.id) {
|
||||
return { ean: checkItem.ean, price: undefined };
|
||||
}
|
||||
|
||||
try {
|
||||
// Call getAvailability for single item
|
||||
// InStore (Rücklage) has different schema: uses itemId instead of item object
|
||||
const params =
|
||||
checkItem.orderType === OrderTypeFeature.InStore
|
||||
? {
|
||||
orderType: checkItem.orderType,
|
||||
branchId: checkItem.branchId,
|
||||
itemId: catalogItem.id,
|
||||
}
|
||||
: {
|
||||
orderType: checkItem.orderType,
|
||||
branchId: checkItem.branchId,
|
||||
item: {
|
||||
itemId: catalogItem.id,
|
||||
ean: checkItem.ean,
|
||||
quantity: 1,
|
||||
price: catalogItem.catalogAvailability?.price,
|
||||
},
|
||||
};
|
||||
|
||||
const availability = await this.#availabilityService.getAvailability(
|
||||
params as any,
|
||||
abortSignal,
|
||||
);
|
||||
|
||||
return {
|
||||
ean: checkItem.ean,
|
||||
price: availability?.price as AvailabilityPrice | undefined,
|
||||
};
|
||||
} catch (error) {
|
||||
this.#logger.error(
|
||||
'Failed to fetch availability for item',
|
||||
error as Error,
|
||||
() => ({
|
||||
ean: checkItem.ean,
|
||||
orderType: checkItem.orderType,
|
||||
branchId: checkItem.branchId,
|
||||
}),
|
||||
);
|
||||
return { ean: checkItem.ean, price: undefined };
|
||||
}
|
||||
});
|
||||
|
||||
// Wait for all availability requests to complete
|
||||
const availabilityResults = await Promise.all(availabilityPromises);
|
||||
|
||||
// Build price map from results
|
||||
const pricesByEan = new Map(
|
||||
availabilityResults.map((result) => [result.ean, result.price]),
|
||||
);
|
||||
|
||||
// Build final result: combine catalog prices, availability prices and redemption points
|
||||
const results: PriceAndRedemptionPointsResult[] = eans.map((ean) => ({
|
||||
ean,
|
||||
availabilityPrice: pricesByEan.get(ean),
|
||||
catalogPrice: catalogByEan.get(ean)?.catalogAvailability?.price,
|
||||
redemptionPoints: catalogByEan.get(ean)?.redemptionPoints,
|
||||
}));
|
||||
|
||||
return results;
|
||||
},
|
||||
defaultValue: [],
|
||||
});
|
||||
|
||||
readonly priceAndRedemptionPoints =
|
||||
this.#priceAndRedemptionPointsResource.value.asReadonly();
|
||||
readonly loading = this.#priceAndRedemptionPointsResource.isLoading;
|
||||
readonly error = computed(
|
||||
() => this.#priceAndRedemptionPointsResource.error()?.message ?? null,
|
||||
);
|
||||
|
||||
loadPriceAndRedemptionPoints(items: ItemWithOrderType[] | undefined) {
|
||||
this.#items.set(items);
|
||||
}
|
||||
|
||||
refresh() {
|
||||
this.#priceAndRedemptionPointsResource.reload();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { RewardSelectionDialogComponent } from './reward-selection-dialog.component';
|
||||
import { RewardSelectionStore } from './store/reward-selection-dialog.store';
|
||||
import { RewardSelectionFacade } from '@isa/checkout/data-access';
|
||||
import { DialogComponent } from '@isa/ui/dialog';
|
||||
import { DialogRef, DIALOG_DATA } from '@angular/cdk/dialog';
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { signal } from '@angular/core';
|
||||
|
||||
describe('RewardSelectionDialogComponent', () => {
|
||||
let component: RewardSelectionDialogComponent;
|
||||
let fixture: ComponentFixture<RewardSelectionDialogComponent>;
|
||||
|
||||
const mockDialogData = {
|
||||
rewardSelectionItems: [
|
||||
{
|
||||
item: {
|
||||
id: 1,
|
||||
data: {
|
||||
product: { catalogProductNumber: 'CAT-123', name: 'Test Product' },
|
||||
quantity: 1,
|
||||
loyalty: { value: 100 },
|
||||
},
|
||||
},
|
||||
catalogPrice: undefined,
|
||||
availabilityPrice: undefined,
|
||||
catalogRewardPoints: 100,
|
||||
cartQuantity: 1,
|
||||
rewardCartQuantity: 0,
|
||||
} as any,
|
||||
],
|
||||
customerRewardPoints: 500,
|
||||
closeText: 'Close',
|
||||
};
|
||||
|
||||
const dialogComponentMock = {
|
||||
close: vi.fn(),
|
||||
data: mockDialogData,
|
||||
};
|
||||
|
||||
const dialogRefMock = {
|
||||
close: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [RewardSelectionDialogComponent],
|
||||
providers: [
|
||||
{ provide: DialogComponent, useValue: dialogComponentMock },
|
||||
{ provide: DialogRef, useValue: dialogRefMock },
|
||||
{ provide: DIALOG_DATA, useValue: mockDialogData },
|
||||
provideHttpClient(),
|
||||
],
|
||||
});
|
||||
|
||||
fixture = TestBed.createComponent(RewardSelectionDialogComponent);
|
||||
component = fixture.componentInstance;
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should initialize store with dialog data on construction', () => {
|
||||
// The component provides its own store, so we verify it exists and has the init method
|
||||
expect(component.store).toBeDefined();
|
||||
expect(component.store.initState).toBeDefined();
|
||||
});
|
||||
|
||||
it('should inject store', () => {
|
||||
// The component provides its own store instance
|
||||
expect(component.store).toBeDefined();
|
||||
expect(typeof component.store.initState).toBe('function');
|
||||
});
|
||||
|
||||
it('should extend DialogContentDirective', () => {
|
||||
expect(component.data).toBe(mockDialogData);
|
||||
});
|
||||
});
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
import { RewardSelectionItemComponent } from '../reward-selection-item.component';
|
||||
import { RewardSelectionStore } from '../../../store/reward-selection-dialog.store';
|
||||
import {
|
||||
OrderType,
|
||||
OrderTypeFeature,
|
||||
calculatePriceValue,
|
||||
calculateLoyaltyPointsValue,
|
||||
hasOrderTypeFeature,
|
||||
@@ -42,8 +42,8 @@ export class RewardSelectionInputsComponent {
|
||||
hasCorrectOrderType = computed(() => {
|
||||
const item = this.rewardSelectionItem().item;
|
||||
return hasOrderTypeFeature(item.features, [
|
||||
OrderType.InStore,
|
||||
OrderType.Pickup,
|
||||
OrderTypeFeature.InStore,
|
||||
OrderTypeFeature.Pickup,
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,163 +1,163 @@
|
||||
import { inject, Injectable } from '@angular/core';
|
||||
import { injectConfirmationDialog, injectFeedbackDialog } from '@isa/ui/dialog';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { RewardSelectionService } from './reward-selection.service';
|
||||
import { CheckoutMetadataService } from '@isa/checkout/data-access';
|
||||
import { injectTabId } from '@isa/core/tabs';
|
||||
|
||||
export const NavigateAfterRewardSelection = {
|
||||
CART: 'cart',
|
||||
REWARD: 'reward',
|
||||
CATALOG: 'catalog',
|
||||
} as const;
|
||||
export type NavigateAfterRewardSelection =
|
||||
(typeof NavigateAfterRewardSelection)[keyof typeof NavigateAfterRewardSelection];
|
||||
|
||||
@Injectable()
|
||||
export class RewardSelectionPopUpService {
|
||||
#tabId = injectTabId();
|
||||
#feedbackDialog = injectFeedbackDialog();
|
||||
#confirmationDialog = injectConfirmationDialog();
|
||||
#rewardSelectionService = inject(RewardSelectionService);
|
||||
#checkoutMetadataService = inject(CheckoutMetadataService);
|
||||
|
||||
/**
|
||||
* Displays the reward selection popup dialog if conditions are met.
|
||||
*
|
||||
* This method manages the complete flow of the reward selection popup:
|
||||
* 1. Checks if the popup has already been shown in the current tab (prevents duplicate displays)
|
||||
* 2. Reloads necessary resources for the reward selection dialog
|
||||
* 3. Opens the reward selection dialog if conditions allow
|
||||
* 4. Marks the popup as opened for the current tab using {@link #setPopUpOpenedState}
|
||||
* 5. Processes user selections and determines navigation flow
|
||||
*
|
||||
* @returns A promise that resolves to:
|
||||
* - `NavigateAfterRewardSelection.CART` - Navigate to the shopping cart
|
||||
* - `NavigateAfterRewardSelection.REWARD` - Navigate to the reward cart
|
||||
* - `NavigateAfterRewardSelection.CATALOG` - Navigate back to the catalog
|
||||
* - `undefined` - Stay on the current page (e.g., when all quantities are set to 0)
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const result = await rewardSelectionPopUpService.popUp();
|
||||
* if (result === NavigateAfterRewardSelection.CART) {
|
||||
* this.router.navigate(['/cart']);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
async popUp(): Promise<NavigateAfterRewardSelection | undefined> {
|
||||
if (this.#popUpAlreadyOpened(this.#tabId())) {
|
||||
return NavigateAfterRewardSelection.CART;
|
||||
}
|
||||
|
||||
await this.#rewardSelectionService.reloadResources();
|
||||
|
||||
if (this.#rewardSelectionService.canOpen()) {
|
||||
const dialogResult = await this.#rewardSelectionService.open({
|
||||
closeText: 'Weiter einkaufen',
|
||||
});
|
||||
|
||||
this.#setPopUpOpenedState(this.#tabId(), true);
|
||||
|
||||
if (dialogResult) {
|
||||
// Wenn der User alles 0 setzt, bleibt man einfach auf der aktuellen Seite
|
||||
if (dialogResult?.rewardSelectionItems?.length === 0) {
|
||||
await this.#feedback();
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (dialogResult.rewardSelectionItems?.length > 0) {
|
||||
const hasRegularCartItems = dialogResult.rewardSelectionItems?.some(
|
||||
(item) => item?.cartQuantity > 0,
|
||||
);
|
||||
const hasRewardCartItems = dialogResult.rewardSelectionItems?.some(
|
||||
(item) => item?.rewardCartQuantity > 0,
|
||||
);
|
||||
|
||||
return await this.#confirmDialog(
|
||||
hasRegularCartItems,
|
||||
hasRewardCartItems,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return NavigateAfterRewardSelection.CART;
|
||||
}
|
||||
|
||||
async #feedback() {
|
||||
this.#feedbackDialog({
|
||||
data: { message: 'Auswahl gespeichert' },
|
||||
});
|
||||
}
|
||||
|
||||
async #confirmDialog(
|
||||
hasRegularCartItems: boolean,
|
||||
hasRewardCartItems: boolean,
|
||||
): Promise<NavigateAfterRewardSelection | undefined> {
|
||||
const title = hasRewardCartItems
|
||||
? 'Artikel wurde der Prämienausgabe hinzugefügt'
|
||||
: 'Artikel wurde zum Warenkorb hinzugefügt';
|
||||
|
||||
const message = hasRegularCartItems
|
||||
? 'Bitte schließen sie erst den Warenkorb ab und dann die Prämienausgabe'
|
||||
: hasRegularCartItems && !hasRewardCartItems
|
||||
? 'Bitte schließen sie den Warenkorb ab'
|
||||
: '';
|
||||
|
||||
const dialogRef = this.#confirmationDialog({
|
||||
title,
|
||||
data: {
|
||||
message,
|
||||
closeText: 'Weiter einkaufen',
|
||||
confirmText: hasRegularCartItems
|
||||
? 'Zum Warenkorb'
|
||||
: 'Zur Prämienausgabe',
|
||||
},
|
||||
width: '30rem',
|
||||
});
|
||||
|
||||
const dialogResult = await firstValueFrom(dialogRef.closed);
|
||||
|
||||
if (dialogResult) {
|
||||
if (dialogResult.confirmed && hasRegularCartItems) {
|
||||
return NavigateAfterRewardSelection.CART;
|
||||
} else if (dialogResult.confirmed) {
|
||||
return NavigateAfterRewardSelection.REWARD;
|
||||
} else {
|
||||
return NavigateAfterRewardSelection.CATALOG;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
#popUpAlreadyOpened(tabId: number | null): boolean | undefined {
|
||||
if (tabId == null) return;
|
||||
return this.#checkoutMetadataService.getRewardSelectionPopupOpenedState(
|
||||
tabId,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the opened state of the reward selection popup for a specific tab.
|
||||
*
|
||||
* This method persists the popup state to prevent the popup from being displayed
|
||||
* multiple times within the same tab session. It's called after successfully
|
||||
* opening the reward selection dialog.
|
||||
*
|
||||
* @param tabId - The unique identifier of the tab. If null, the method returns early without setting state.
|
||||
* @param opened - The opened state to set. `true` indicates the popup has been shown, `false` or `undefined` resets the state.
|
||||
*
|
||||
* @remarks
|
||||
* This state is typically set to `true` after the user has seen the popup to ensure
|
||||
* it doesn't appear again during the same browsing session in that tab.
|
||||
*/
|
||||
#setPopUpOpenedState(tabId: number | null, opened: boolean | undefined) {
|
||||
if (tabId == null) return;
|
||||
this.#checkoutMetadataService.setRewardSelectionPopupOpenedState(
|
||||
tabId,
|
||||
opened,
|
||||
);
|
||||
}
|
||||
}
|
||||
import { inject, Injectable } from '@angular/core';
|
||||
import { injectConfirmationDialog, injectFeedbackDialog } from '@isa/ui/dialog';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { RewardSelectionService } from './reward-selection.service';
|
||||
import { CheckoutMetadataService } from '@isa/checkout/data-access';
|
||||
import { injectTabId } from '@isa/core/tabs';
|
||||
|
||||
export const NavigateAfterRewardSelection = {
|
||||
CART: 'cart',
|
||||
REWARD: 'reward',
|
||||
CATALOG: 'catalog',
|
||||
} as const;
|
||||
export type NavigateAfterRewardSelection =
|
||||
(typeof NavigateAfterRewardSelection)[keyof typeof NavigateAfterRewardSelection];
|
||||
|
||||
@Injectable()
|
||||
export class RewardSelectionPopUpService {
|
||||
#tabId = injectTabId();
|
||||
#feedbackDialog = injectFeedbackDialog();
|
||||
#confirmationDialog = injectConfirmationDialog();
|
||||
#rewardSelectionService = inject(RewardSelectionService);
|
||||
#checkoutMetadataService = inject(CheckoutMetadataService);
|
||||
|
||||
/**
|
||||
* Displays the reward selection popup dialog if conditions are met.
|
||||
*
|
||||
* This method manages the complete flow of the reward selection popup:
|
||||
* 1. Checks if the popup has already been shown in the current tab (prevents duplicate displays)
|
||||
* 2. Reloads necessary resources for the reward selection dialog
|
||||
* 3. Opens the reward selection dialog if conditions allow
|
||||
* 4. Marks the popup as opened for the current tab using {@link #setPopUpOpenedState}
|
||||
* 5. Processes user selections and determines navigation flow
|
||||
*
|
||||
* @returns A promise that resolves to:
|
||||
* - `NavigateAfterRewardSelection.CART` - Navigate to the shopping cart
|
||||
* - `NavigateAfterRewardSelection.REWARD` - Navigate to the reward cart
|
||||
* - `NavigateAfterRewardSelection.CATALOG` - Navigate back to the catalog
|
||||
* - `undefined` - Stay on the current page (e.g., when all quantities are set to 0)
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const result = await rewardSelectionPopUpService.popUp();
|
||||
* if (result === NavigateAfterRewardSelection.CART) {
|
||||
* this.router.navigate(['/cart']);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
async popUp(): Promise<NavigateAfterRewardSelection | undefined> {
|
||||
if (this.#popUpAlreadyOpened(this.#tabId())) {
|
||||
return NavigateAfterRewardSelection.CART;
|
||||
}
|
||||
|
||||
await this.#rewardSelectionService.reloadResources();
|
||||
|
||||
if (this.#rewardSelectionService.canOpen()) {
|
||||
const dialogResult = await this.#rewardSelectionService.open({
|
||||
closeText: 'Weiter einkaufen',
|
||||
});
|
||||
|
||||
this.#setPopUpOpenedState(this.#tabId(), true);
|
||||
|
||||
if (dialogResult) {
|
||||
// Wenn der User alles 0 setzt, bleibt man einfach auf der aktuellen Seite
|
||||
if (dialogResult?.rewardSelectionItems?.length === 0) {
|
||||
await this.#feedback();
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (dialogResult.rewardSelectionItems?.length > 0) {
|
||||
const hasRegularCartItems = dialogResult.rewardSelectionItems?.some(
|
||||
(item) => item?.cartQuantity > 0,
|
||||
);
|
||||
const hasRewardCartItems = dialogResult.rewardSelectionItems?.some(
|
||||
(item) => item?.rewardCartQuantity > 0,
|
||||
);
|
||||
|
||||
return await this.#confirmDialog(
|
||||
hasRegularCartItems,
|
||||
hasRewardCartItems,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return NavigateAfterRewardSelection.CART;
|
||||
}
|
||||
|
||||
async #feedback() {
|
||||
this.#feedbackDialog({
|
||||
data: { message: 'Auswahl gespeichert' },
|
||||
});
|
||||
}
|
||||
|
||||
async #confirmDialog(
|
||||
hasRegularCartItems: boolean,
|
||||
hasRewardCartItems: boolean,
|
||||
): Promise<NavigateAfterRewardSelection | undefined> {
|
||||
const title = hasRewardCartItems
|
||||
? 'Artikel wurde der Prämienausgabe hinzugefügt'
|
||||
: 'Artikel wurde zum Warenkorb hinzugefügt';
|
||||
|
||||
const message = hasRegularCartItems
|
||||
? 'Bitte schließen sie erst den Warenkorb ab und dann die Prämienausgabe'
|
||||
: hasRegularCartItems && !hasRewardCartItems
|
||||
? 'Bitte schließen sie den Warenkorb ab'
|
||||
: '';
|
||||
|
||||
const dialogRef = this.#confirmationDialog({
|
||||
title,
|
||||
data: {
|
||||
message,
|
||||
closeText: 'Weiter einkaufen',
|
||||
confirmText: hasRegularCartItems
|
||||
? 'Zum Warenkorb'
|
||||
: 'Zur Prämienausgabe',
|
||||
},
|
||||
width: '30rem',
|
||||
});
|
||||
|
||||
const dialogResult = await firstValueFrom(dialogRef.closed);
|
||||
|
||||
if (dialogResult) {
|
||||
if (dialogResult.confirmed && hasRegularCartItems) {
|
||||
return NavigateAfterRewardSelection.CART;
|
||||
} else if (dialogResult.confirmed) {
|
||||
return NavigateAfterRewardSelection.REWARD;
|
||||
} else {
|
||||
return NavigateAfterRewardSelection.CATALOG;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
#popUpAlreadyOpened(tabId: number | null): boolean | undefined {
|
||||
if (tabId == null) return;
|
||||
return this.#checkoutMetadataService.getRewardSelectionPopupOpenedState(
|
||||
tabId,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the opened state of the reward selection popup for a specific tab.
|
||||
*
|
||||
* This method persists the popup state to prevent the popup from being displayed
|
||||
* multiple times within the same tab session. It's called after successfully
|
||||
* opening the reward selection dialog.
|
||||
*
|
||||
* @param tabId - The unique identifier of the tab. If null, the method returns early without setting state.
|
||||
* @param opened - The opened state to set. `true` indicates the popup has been shown, `false` or `undefined` resets the state.
|
||||
*
|
||||
* @remarks
|
||||
* This state is typically set to `true` after the user has seen the popup to ensure
|
||||
* it doesn't appear again during the same browsing session in that tab.
|
||||
*/
|
||||
#setPopUpOpenedState(tabId: number | null, opened: boolean | undefined) {
|
||||
if (tabId == null) return;
|
||||
this.#checkoutMetadataService.setRewardSelectionPopupOpenedState(
|
||||
tabId,
|
||||
opened,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,195 +1,191 @@
|
||||
import { computed, effect, inject, Injectable, untracked } from '@angular/core';
|
||||
import {
|
||||
SelectedRewardShoppingCartResource,
|
||||
SelectedShoppingCartResource,
|
||||
ShoppingCartItem,
|
||||
getOrderTypeFeature,
|
||||
RewardSelectionItem,
|
||||
itemSelectionChanged,
|
||||
mergeRewardSelectionItems,
|
||||
} from '@isa/checkout/data-access';
|
||||
import { injectDialog } from '@isa/ui/dialog';
|
||||
import {
|
||||
RewardSelectionDialogComponent,
|
||||
RewardSelectionDialogResult,
|
||||
} from '../reward-selection-dialog.component';
|
||||
import { PrimaryCustomerCardResource } from '@isa/crm/data-access';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { toObservable } from '@angular/core/rxjs-interop';
|
||||
import { filter, first } from 'rxjs/operators';
|
||||
import {
|
||||
PriceAndRedemptionPointsResource,
|
||||
ItemWithOrderType,
|
||||
} from '../resource/price-and-redemption-points.resource';
|
||||
|
||||
@Injectable()
|
||||
export class RewardSelectionService {
|
||||
rewardSelectionDialog = injectDialog(RewardSelectionDialogComponent);
|
||||
|
||||
#primaryCustomerCardResource = inject(PrimaryCustomerCardResource);
|
||||
#priceAndRedemptionPointsResource = inject(PriceAndRedemptionPointsResource);
|
||||
#shoppingCartResource = inject(SelectedShoppingCartResource).resource;
|
||||
#rewardShoppingCartResource = inject(SelectedRewardShoppingCartResource)
|
||||
.resource;
|
||||
|
||||
readonly shoppingCartResponseValue =
|
||||
this.#shoppingCartResource.value.asReadonly();
|
||||
|
||||
readonly rewardShoppingCartResponseValue =
|
||||
this.#rewardShoppingCartResource.value.asReadonly();
|
||||
|
||||
readonly primaryCustomerCardValue =
|
||||
this.#primaryCustomerCardResource.primaryCustomerCard;
|
||||
|
||||
readonly priceAndRedemptionPoints =
|
||||
this.#priceAndRedemptionPointsResource.priceAndRedemptionPoints;
|
||||
|
||||
shoppingCartItems = computed(() => {
|
||||
return (
|
||||
this.shoppingCartResponseValue()
|
||||
?.items?.map((item) => item?.data as ShoppingCartItem)
|
||||
.filter((item): item is ShoppingCartItem => item != null) ?? []
|
||||
);
|
||||
});
|
||||
|
||||
rewardShoppingCartItems = computed(() => {
|
||||
return (
|
||||
this.rewardShoppingCartResponseValue()
|
||||
?.items?.map((item) => item?.data as ShoppingCartItem)
|
||||
.filter((item): item is ShoppingCartItem => item != null) ?? []
|
||||
);
|
||||
});
|
||||
|
||||
mergedRewardSelectionItems = computed<RewardSelectionItem[]>(() => {
|
||||
return mergeRewardSelectionItems(
|
||||
this.shoppingCartItems(),
|
||||
this.rewardShoppingCartItems(),
|
||||
);
|
||||
});
|
||||
|
||||
selectionItemsWithOrderType = computed<ItemWithOrderType[]>(() => {
|
||||
return this.mergedRewardSelectionItems()
|
||||
.map((item): ItemWithOrderType | null => {
|
||||
const ean = item.item.product.ean;
|
||||
const orderType = getOrderTypeFeature(item.item.features);
|
||||
const branchId = item.item.destination?.data?.targetBranch?.data?.id;
|
||||
|
||||
if (!ean || !orderType) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { ean, orderType, branchId };
|
||||
})
|
||||
.filter((item): item is ItemWithOrderType => item !== null);
|
||||
});
|
||||
|
||||
updatedRewardSelectionItems = computed<RewardSelectionItem[]>(() => {
|
||||
const rewardSelectionItems = this.mergedRewardSelectionItems();
|
||||
const priceAndRedemptionResults = this.priceAndRedemptionPoints();
|
||||
|
||||
return rewardSelectionItems.map((selectionItem) => {
|
||||
const ean = selectionItem.item.product.ean;
|
||||
const result = priceAndRedemptionResults?.find((r) => r.ean === ean);
|
||||
|
||||
return {
|
||||
...selectionItem,
|
||||
catalogPrice: result?.catalogPrice,
|
||||
catalogRewardPoints: result?.redemptionPoints,
|
||||
availabilityPrice: result?.availabilityPrice,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
primaryBonusCardPoints = computed(
|
||||
() => this.primaryCustomerCardValue()?.totalPoints ?? 0,
|
||||
);
|
||||
|
||||
isLoading = computed(
|
||||
() =>
|
||||
this.#shoppingCartResource.isLoading() ||
|
||||
this.#rewardShoppingCartResource.isLoading() ||
|
||||
this.#primaryCustomerCardResource.loading() ||
|
||||
this.#priceAndRedemptionPointsResource.loading(),
|
||||
);
|
||||
#isLoading$ = toObservable(this.isLoading);
|
||||
|
||||
eligibleItems = computed(() =>
|
||||
this.updatedRewardSelectionItems().filter(
|
||||
(selectionItem) =>
|
||||
(selectionItem.item.loyalty?.value != null &&
|
||||
selectionItem.item.loyalty.value !== 0) ||
|
||||
(selectionItem?.catalogRewardPoints != null &&
|
||||
selectionItem.catalogRewardPoints !== 0),
|
||||
),
|
||||
);
|
||||
|
||||
canOpen = computed(
|
||||
() => this.eligibleItems().length > 0 && !!this.primaryBonusCardPoints(),
|
||||
);
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const items = this.selectionItemsWithOrderType();
|
||||
|
||||
untracked(() => {
|
||||
const resourceLoading =
|
||||
this.#priceAndRedemptionPointsResource.loading();
|
||||
|
||||
if (!resourceLoading && items.length > 0) {
|
||||
this.#priceAndRedemptionPointsResource.loadPriceAndRedemptionPoints(
|
||||
items,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async open({
|
||||
closeText,
|
||||
}: {
|
||||
closeText: string;
|
||||
}): Promise<RewardSelectionDialogResult> {
|
||||
const rewardSelectionItems = this.eligibleItems();
|
||||
const dialogRef = this.rewardSelectionDialog({
|
||||
title: 'Ein oder mehrere Artikel sind als Prämie verfügbar',
|
||||
data: {
|
||||
rewardSelectionItems,
|
||||
customerRewardPoints: this.primaryBonusCardPoints(),
|
||||
closeText,
|
||||
},
|
||||
displayClose: true,
|
||||
disableClose: false,
|
||||
width: '44.5rem',
|
||||
});
|
||||
const dialogResult = await firstValueFrom(dialogRef.closed);
|
||||
|
||||
if (
|
||||
itemSelectionChanged(
|
||||
rewardSelectionItems,
|
||||
dialogResult?.rewardSelectionItems,
|
||||
)
|
||||
) {
|
||||
return dialogResult;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async reloadResources(): Promise<void> {
|
||||
// Start reloading all resources
|
||||
// Note: PrimaryCustomerCard, Price and redemption points will be loaded automatically by the effect
|
||||
// when selectionItemsWithOrderType changes after cart resources are reloaded
|
||||
await Promise.all([
|
||||
this.#shoppingCartResource.reload(),
|
||||
this.#rewardShoppingCartResource.reload(),
|
||||
]);
|
||||
|
||||
// Wait until all resources are fully loaded (isLoading becomes false)
|
||||
await firstValueFrom(
|
||||
this.#isLoading$.pipe(
|
||||
filter((isLoading) => !isLoading),
|
||||
first(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
import { computed, effect, inject, Injectable, untracked } from '@angular/core';
|
||||
import {
|
||||
SelectedRewardShoppingCartResource,
|
||||
SelectedShoppingCartResource,
|
||||
ShoppingCartItem,
|
||||
getOrderTypeFeature,
|
||||
RewardSelectionItem,
|
||||
itemSelectionChanged,
|
||||
mergeRewardSelectionItems,
|
||||
} from '@isa/checkout/data-access';
|
||||
import { injectDialog } from '@isa/ui/dialog';
|
||||
import {
|
||||
RewardSelectionDialogComponent,
|
||||
RewardSelectionDialogResult,
|
||||
} from '../reward-selection-dialog.component';
|
||||
import { PrimaryCustomerCardResource } from '@isa/crm/data-access';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { toObservable } from '@angular/core/rxjs-interop';
|
||||
import { filter, first } from 'rxjs/operators';
|
||||
import {
|
||||
PriceAndRedemptionPointsResource,
|
||||
ItemWithOrderType,
|
||||
} from '../resource/price-and-redemption-points.resource';
|
||||
|
||||
@Injectable()
|
||||
export class RewardSelectionService {
|
||||
rewardSelectionDialog = injectDialog(RewardSelectionDialogComponent);
|
||||
|
||||
#primaryCustomerCardResource = inject(PrimaryCustomerCardResource);
|
||||
#priceAndRedemptionPointsResource = inject(PriceAndRedemptionPointsResource);
|
||||
#shoppingCartResource = inject(SelectedShoppingCartResource).resource;
|
||||
#rewardShoppingCartResource = inject(SelectedRewardShoppingCartResource)
|
||||
.resource;
|
||||
|
||||
readonly shoppingCartResponseValue =
|
||||
this.#shoppingCartResource.value.asReadonly();
|
||||
|
||||
readonly rewardShoppingCartResponseValue =
|
||||
this.#rewardShoppingCartResource.value.asReadonly();
|
||||
|
||||
readonly primaryCustomerCardValue =
|
||||
this.#primaryCustomerCardResource.primaryCustomerCard;
|
||||
|
||||
readonly priceAndRedemptionPoints =
|
||||
this.#priceAndRedemptionPointsResource.priceAndRedemptionPoints;
|
||||
|
||||
shoppingCartItems = computed(() => {
|
||||
return (
|
||||
this.shoppingCartResponseValue()
|
||||
?.items?.map((item) => item?.data as ShoppingCartItem)
|
||||
.filter((item): item is ShoppingCartItem => item != null) ?? []
|
||||
);
|
||||
});
|
||||
|
||||
rewardShoppingCartItems = computed(() => {
|
||||
return (
|
||||
this.rewardShoppingCartResponseValue()
|
||||
?.items?.map((item) => item?.data as ShoppingCartItem)
|
||||
.filter((item): item is ShoppingCartItem => item != null) ?? []
|
||||
);
|
||||
});
|
||||
|
||||
mergedRewardSelectionItems = computed<RewardSelectionItem[]>(() => {
|
||||
return mergeRewardSelectionItems(
|
||||
this.shoppingCartItems(),
|
||||
this.rewardShoppingCartItems(),
|
||||
);
|
||||
});
|
||||
|
||||
selectionItemsWithOrderType = computed<ItemWithOrderType[]>(() => {
|
||||
return this.mergedRewardSelectionItems()
|
||||
.map((item): ItemWithOrderType | null => {
|
||||
const ean = item.item.product.ean;
|
||||
const orderType = getOrderTypeFeature(item.item.features);
|
||||
const branchId = item.item.destination?.data?.targetBranch?.data?.id;
|
||||
|
||||
if (!ean || !orderType) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { ean, orderType, branchId };
|
||||
})
|
||||
.filter((item): item is ItemWithOrderType => item !== null);
|
||||
});
|
||||
|
||||
updatedRewardSelectionItems = computed<RewardSelectionItem[]>(() => {
|
||||
const rewardSelectionItems = this.mergedRewardSelectionItems();
|
||||
const priceAndRedemptionResults = this.priceAndRedemptionPoints();
|
||||
|
||||
return rewardSelectionItems.map((selectionItem) => {
|
||||
const ean = selectionItem.item.product.ean;
|
||||
const result = priceAndRedemptionResults?.find((r) => r.ean === ean);
|
||||
|
||||
return {
|
||||
...selectionItem,
|
||||
catalogPrice: result?.catalogPrice,
|
||||
catalogRewardPoints: result?.redemptionPoints,
|
||||
availabilityPrice: result?.availabilityPrice,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
primaryBonusCardPoints = computed(
|
||||
() => this.primaryCustomerCardValue()?.totalPoints ?? 0,
|
||||
);
|
||||
|
||||
isLoading = computed(
|
||||
() =>
|
||||
this.#shoppingCartResource.isLoading() ||
|
||||
this.#rewardShoppingCartResource.isLoading() ||
|
||||
this.#primaryCustomerCardResource.loading() ||
|
||||
this.#priceAndRedemptionPointsResource.loading(),
|
||||
);
|
||||
#isLoading$ = toObservable(this.isLoading);
|
||||
|
||||
eligibleItems = computed(() =>
|
||||
this.updatedRewardSelectionItems().filter(
|
||||
(selectionItem) =>
|
||||
(selectionItem.item.loyalty?.value != null &&
|
||||
selectionItem.item.loyalty.value !== 0) ||
|
||||
(selectionItem?.catalogRewardPoints != null &&
|
||||
selectionItem.catalogRewardPoints !== 0),
|
||||
),
|
||||
);
|
||||
|
||||
canOpen = computed(
|
||||
() => this.eligibleItems().length > 0 && !!this.primaryBonusCardPoints(),
|
||||
);
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const items = this.selectionItemsWithOrderType();
|
||||
|
||||
untracked(() => {
|
||||
const resourceLoading =
|
||||
this.#priceAndRedemptionPointsResource.loading();
|
||||
|
||||
if (!resourceLoading && items.length > 0) {
|
||||
this.#priceAndRedemptionPointsResource.loadPriceAndRedemptionPoints(
|
||||
items,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async open({
|
||||
closeText,
|
||||
}: {
|
||||
closeText: string;
|
||||
}): Promise<RewardSelectionDialogResult> {
|
||||
const rewardSelectionItems = this.eligibleItems();
|
||||
const dialogRef = this.rewardSelectionDialog({
|
||||
title: 'Ein oder mehrere Artikel sind als Prämie verfügbar',
|
||||
data: {
|
||||
rewardSelectionItems,
|
||||
customerRewardPoints: this.primaryBonusCardPoints(),
|
||||
closeText,
|
||||
},
|
||||
displayClose: true,
|
||||
disableClose: false,
|
||||
width: '44.5rem',
|
||||
});
|
||||
const dialogResult = await firstValueFrom(dialogRef.closed);
|
||||
|
||||
if (
|
||||
itemSelectionChanged(
|
||||
rewardSelectionItems,
|
||||
dialogResult?.rewardSelectionItems,
|
||||
)
|
||||
) {
|
||||
await this.reloadResources();
|
||||
return dialogResult;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async reloadResources(): Promise<void> {
|
||||
// Start reloading all resources
|
||||
// Note: PrimaryCustomerCard, Price and redemption points will be loaded automatically by the effect
|
||||
// when selectionItemsWithOrderType changes after cart resources are reloaded
|
||||
this.#shoppingCartResource.reload();
|
||||
this.#rewardShoppingCartResource.reload();
|
||||
|
||||
// Wait until all resources are fully loaded (isLoading becomes false)
|
||||
await firstValueFrom(
|
||||
this.#isLoading$.pipe(filter((isLoading) => !isLoading)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,95 +1,87 @@
|
||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
|
||||
import { TextButtonComponent } from '@isa/ui/buttons';
|
||||
import { SkeletonLoaderDirective } from '@isa/ui/skeleton-loader';
|
||||
import { injectTabId } from '@isa/core/tabs';
|
||||
import { DomainCheckoutService } from '@domain/checkout';
|
||||
import { injectFeedbackDialog } from '@isa/ui/dialog';
|
||||
import { RewardSelectionService } from '../service/reward-selection.service';
|
||||
import { Router } from '@angular/router';
|
||||
import {
|
||||
SelectedRewardShoppingCartResource,
|
||||
SelectedShoppingCartResource,
|
||||
} from '@isa/checkout/data-access';
|
||||
import { CheckoutNavigationService } from '@shared/services/navigation';
|
||||
|
||||
@Component({
|
||||
selector: 'lib-reward-selection-trigger',
|
||||
templateUrl: './reward-selection-trigger.component.html',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [TextButtonComponent, SkeletonLoaderDirective],
|
||||
providers: [
|
||||
SelectedShoppingCartResource,
|
||||
SelectedRewardShoppingCartResource,
|
||||
RewardSelectionService,
|
||||
],
|
||||
})
|
||||
export class RewardSelectionTriggerComponent {
|
||||
#router = inject(Router);
|
||||
#tabId = injectTabId();
|
||||
#feedbackDialog = injectFeedbackDialog();
|
||||
#rewardSelectionService = inject(RewardSelectionService);
|
||||
#domainCheckoutService = inject(DomainCheckoutService);
|
||||
#checkoutNavigationService = inject(CheckoutNavigationService);
|
||||
|
||||
canOpen = this.#rewardSelectionService.canOpen;
|
||||
isLoading = this.#rewardSelectionService.isLoading;
|
||||
|
||||
async openRewardSelectionDialog() {
|
||||
const tabId = this.#tabId();
|
||||
const dialogResult = await this.#rewardSelectionService.open({
|
||||
closeText: 'Abbrechen',
|
||||
});
|
||||
|
||||
if (dialogResult && tabId) {
|
||||
await this.#reloadShoppingCart(tabId);
|
||||
|
||||
// Wenn der User alles 0 setzt, bleibt man einfach auf der aktuellen Seite
|
||||
if (dialogResult.rewardSelectionItems?.length === 0) {
|
||||
await this.#feedback();
|
||||
}
|
||||
|
||||
if (dialogResult.rewardSelectionItems?.length > 0) {
|
||||
const hasRegularCartItems = dialogResult.rewardSelectionItems?.some(
|
||||
(item) => item?.cartQuantity > 0,
|
||||
);
|
||||
const hasRewardCartItems = dialogResult.rewardSelectionItems?.some(
|
||||
(item) => item?.rewardCartQuantity > 0,
|
||||
);
|
||||
|
||||
await this.#feedback();
|
||||
|
||||
// Wenn Nutzer im Warenkorb ist und alle Items als Prämie setzt -> Navigation zum Prämien Checkout
|
||||
if (!hasRegularCartItems && hasRewardCartItems) {
|
||||
await this.#navigateToRewardCheckout(tabId);
|
||||
}
|
||||
|
||||
// Wenn Nutzer im Prämien Checkout ist und alle Items in den Warenkorb setzt -> Navigation zu Warenkorb
|
||||
if (hasRegularCartItems && !hasRewardCartItems) {
|
||||
await this.#navigateToCheckout(tabId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async #reloadShoppingCart(tabId: number) {
|
||||
await this.#domainCheckoutService.reloadShoppingCart({
|
||||
processId: tabId,
|
||||
});
|
||||
}
|
||||
|
||||
async #feedback() {
|
||||
this.#feedbackDialog({
|
||||
data: { message: 'Auswahl gespeichert' },
|
||||
});
|
||||
}
|
||||
|
||||
async #navigateToRewardCheckout(tabId: number) {
|
||||
await this.#router.navigate([`/${tabId}`, 'reward', 'cart']);
|
||||
}
|
||||
|
||||
async #navigateToCheckout(tabId: number) {
|
||||
await this.#checkoutNavigationService
|
||||
.getCheckoutReviewPath(tabId)
|
||||
.navigate();
|
||||
}
|
||||
}
|
||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
|
||||
import { TextButtonComponent } from '@isa/ui/buttons';
|
||||
import { SkeletonLoaderDirective } from '@isa/ui/skeleton-loader';
|
||||
import { injectTabId } from '@isa/core/tabs';
|
||||
import { DomainCheckoutService } from '@domain/checkout';
|
||||
import { injectFeedbackDialog } from '@isa/ui/dialog';
|
||||
import { RewardSelectionService } from '../service/reward-selection.service';
|
||||
import { Router } from '@angular/router';
|
||||
import { CheckoutNavigationService } from '@shared/services/navigation';
|
||||
|
||||
@Component({
|
||||
selector: 'lib-reward-selection-trigger',
|
||||
templateUrl: './reward-selection-trigger.component.html',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [TextButtonComponent, SkeletonLoaderDirective],
|
||||
providers: [RewardSelectionService],
|
||||
})
|
||||
export class RewardSelectionTriggerComponent {
|
||||
#router = inject(Router);
|
||||
#tabId = injectTabId();
|
||||
#feedbackDialog = injectFeedbackDialog();
|
||||
#rewardSelectionService = inject(RewardSelectionService);
|
||||
#domainCheckoutService = inject(DomainCheckoutService);
|
||||
#checkoutNavigationService = inject(CheckoutNavigationService);
|
||||
|
||||
canOpen = this.#rewardSelectionService.canOpen;
|
||||
isLoading = this.#rewardSelectionService.isLoading;
|
||||
|
||||
async openRewardSelectionDialog() {
|
||||
const tabId = this.#tabId();
|
||||
const dialogResult = await this.#rewardSelectionService.open({
|
||||
closeText: 'Abbrechen',
|
||||
});
|
||||
|
||||
if (dialogResult && tabId) {
|
||||
await this.#reloadShoppingCart(tabId);
|
||||
|
||||
// Wenn der User alles 0 setzt, bleibt man einfach auf der aktuellen Seite
|
||||
if (dialogResult.rewardSelectionItems?.length === 0) {
|
||||
await this.#feedback();
|
||||
}
|
||||
|
||||
if (dialogResult.rewardSelectionItems?.length > 0) {
|
||||
const hasRegularCartItems = dialogResult.rewardSelectionItems?.some(
|
||||
(item) => item?.cartQuantity > 0,
|
||||
);
|
||||
const hasRewardCartItems = dialogResult.rewardSelectionItems?.some(
|
||||
(item) => item?.rewardCartQuantity > 0,
|
||||
);
|
||||
|
||||
await this.#feedback();
|
||||
|
||||
// Wenn Nutzer im Warenkorb ist und alle Items als Prämie setzt -> Navigation zum Prämien Checkout
|
||||
if (!hasRegularCartItems && hasRewardCartItems) {
|
||||
await this.#navigateToRewardCheckout(tabId);
|
||||
}
|
||||
|
||||
// Wenn Nutzer im Prämien Checkout ist und alle Items in den Warenkorb setzt -> Navigation zu Warenkorb
|
||||
if (hasRegularCartItems && !hasRewardCartItems) {
|
||||
await this.#navigateToCheckout(tabId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async #reloadShoppingCart(tabId: number) {
|
||||
await this.#domainCheckoutService.reloadShoppingCart({
|
||||
processId: tabId,
|
||||
});
|
||||
}
|
||||
|
||||
async #feedback() {
|
||||
this.#feedbackDialog({
|
||||
data: { message: 'Auswahl gespeichert' },
|
||||
});
|
||||
}
|
||||
|
||||
async #navigateToRewardCheckout(tabId: number) {
|
||||
await this.#router.navigate([`/${tabId}`, 'reward', 'cart']);
|
||||
}
|
||||
|
||||
async #navigateToCheckout(tabId: number) {
|
||||
await this.#checkoutNavigationService
|
||||
.getCheckoutReviewPath(tabId)
|
||||
.navigate();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,10 +5,11 @@ export * from './entity-cotnainer';
|
||||
export * from './entity-status';
|
||||
export * from './gender';
|
||||
export * from './list-response-args';
|
||||
export * from './order-type';
|
||||
export * from './order-type-feature';
|
||||
export * from './payer-type';
|
||||
export * from './price-value';
|
||||
export * from './price';
|
||||
export * from './product';
|
||||
export * from './response-args';
|
||||
export * from './return-value';
|
||||
export * from './vat-type';
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user