Compare commits

...

28 Commits

Author SHA1 Message Date
Nino Righi
5711a75188 Merged PR 2068: fix(shared-filter): Adjusted Styling parameters for Height and Scrolling
fix(shared-filter): Adjusted Styling parameters for Height and Scrolling

Ref: #5476, #5477
2025-12-05 10:10:32 +00:00
Nino Righi
3696fb5b2d Merged PR 2069: feature(oms-data-access, oms-return-task-list): Return can now handle Rewards
feature(oms-data-access, oms-return-task-list): Return can now handle Rewards

#5373
2025-12-05 10:10:18 +00:00
Nino
7e7721b222 Merge branch 'release/4.5' into develop 2025-12-03 16:00:41 +01:00
Nino
f10338a48b Merge branch 'release/4.5' into develop 2025-12-02 17:33:50 +01:00
Nino
6cb9aea7d1 Merge branch 'release/4.5' into develop 2025-12-02 17:18:11 +01:00
Lorenz Hilpert
fdfb54a3a0 Merged PR 2065: ♻️ refactor(core-navigation): remove library and use TabService directly
♻️ refactor(core-navigation): remove library and use TabService directly

Remove @isa/core/navigation library entirely as it was just a thin
wrapper around TabService.patchTabMetadata(). Consumers now use
TabService directly for scoped metadata operations.

Changes:
- Delete libs/core/navigation/ (~12 files, ~2900 LOC removed)
- Update 6 consumer components to use TabService directly
- Remove @isa/core/navigation path alias from tsconfig.base.json
- All operations now synchronous (removed async/await)

Migration pattern:
- preserveContext() → patchTabMetadata(tabId, { [scope]: data })
- restoreContext() → activatedTab()?.metadata?.[scope]
- restoreAndClearContext() → get + patchTabMetadata(tabId, { [scope]: null })

Refs #5502
2025-12-02 15:41:18 +00:00
Nino Righi
a3c865e39c Merged PR 2064: feature(oms-return-details): Display Loyalty Points if order is Reward Order
feature(oms-return-details): Display Loyalty Points if order is Reward Order

Ref: #5374
2025-12-02 12:40:44 +00:00
Lorenz Hilpert
68f50b911d Merged PR 1991: feat(navigation): implement title management and enhance tab system
 feat(navigation): implement title management and enhance tab system

This commit introduces a comprehensive title management system and extends
the tab functionality with subtitle support, improving navigation clarity
and user experience across the application.

Key changes:

Title Management System:
- Add @isa/common/title-management library with dual approach:
  - IsaTitleStrategy for route-based static titles
  - usePageTitle() for component-based dynamic titles
- Implement TitleRegistryService for nested component hierarchies
- Automatic ISA prefix addition and TabService integration
- Comprehensive test coverage (1,158 lines of tests)

Tab System Enhancement:
- Add subtitle field to tab schema for additional context
- Update TabService API (addTab, patchTab) to support subtitles
- Extend Zod schemas with subtitle validation
- Update documentation with usage examples

Routing Modernization:
- Consolidate route guards using ActivateProcessIdWithConfigKeyGuard
- Replace 4+ specific guards with generic config-key-based approach
- Add title attributes to 100+ routes across all modules
- Remove deprecated ProcessIdGuard in favor of ActivateProcessIdGuard

Code Cleanup:
- Remove deprecated preview component and related routes
- Clean up unused imports and exports
- Update TypeScript path aliases

Dependencies:
- Update package.json and package-lock.json
- Add @isa/common/title-management to tsconfig path mappings

Refs: #5351, #5418, #5419, #5420
2025-12-02 12:38:28 +00:00
Nino Righi
0670dbfdb1 Merged PR 2063: fix(domain-checkout): After refreshing cart availabilities always keep previo...
fix(domain-checkout): After refreshing cart availabilities always keep previous selected price from purchasing options modal

Ref: #5488
2025-12-02 11:59:35 +00:00
Lorenz Hilpert
db4f30af86 🔧 chore: improve skills cross-references and CLAUDE.md guidance
- Add --amend option with safety rules to commit command
- Add logging skill cross-references to angular-template and html-template
- Fix logging skill name from logging-helper to logging
- Add extended thinking triggers, context monitoring, and code investigation
  rules to CLAUDE.md
2025-12-02 12:57:27 +01:00
Lorenz Hilpert
39b945ae88 📝 docs: add reference documentation for specialist skills
Add migration-patterns.md for test-migration-specialist (Jest to Vitest)
and zod-patterns.md for type-safety-engineer (Zod validation patterns).
2025-12-02 12:57:11 +01:00
Lorenz Hilpert
a2b29c0525 ♻️ refactor: convert architecture-documentation from command to skill
Move architecture documentation generation from a slash command to a
skill with comprehensive reference materials (C4 model, Arc42, ADR templates).
2025-12-02 12:56:56 +01:00
Lorenz Hilpert
2c385210db 🔧 chore: add frontmatter metadata to docs commands
Add allowed-tools, argument-hint, and description frontmatter to
docs-library.md and docs-refresh-reference.md for better discoverability.
2025-12-02 12:56:40 +01:00
Lorenz Hilpert
46999cc04c 🔧 chore: consolidate quality commands into single quality.md
Merge quality-bundle-analyze.md and quality-coverage.md into a unified
quality.md command with subcommand support (bundle, coverage).
2025-12-02 12:56:20 +01:00
Lorenz Hilpert
5aded6ff8e 🩹 fix(ui-input-controls): remove top padding from dropdown options when filter present 2025-11-28 18:18:57 +01:00
Nino Righi
3228abef44 Merged PR 2061: feature(crm-data-access): Added check in customer resource if customerId has...
feature(crm-data-access): Added check in customer resource if customerId has changed to prevent flickering in the view after Filter changes etc.

Ref: #5478
2025-11-28 12:40:04 +00:00
Nino Righi
c0cc0e1bbc Merged PR 2060: feature(checkout-reward, core-tabs): Added back button and configured it so i...
feature(checkout-reward, core-tabs): Added back button and configured it so it can accept optional route to navigate. Added orderNumber and orderDate to reward order confirmation

Ref: #5456
2025-11-28 12:38:08 +00:00
Nino Righi
41630d5d7c Merged PR 2055: feature(ui-label, ahf, warenausgabe, customer-orders): Added and Updated Labe...
feature(ui-label, ahf, warenausgabe, customer-orders): Added and Updated Label Library and Label to the Views, Updated Positioning

Ref: #5479
2025-11-28 12:37:11 +00:00
Lorenz Hilpert
a5bb8b2895 Merged PR 2058: feat(crm): customer card copy-to-clipboard and carousel improvements
Customer Card Copy-to-Clipboard (#5508)

  - Click on card number copies it to clipboard using Angular CDK Clipboard
  - Shows success tooltip confirmation positioned on the right
  - Tooltip auto-dismisses after 3 seconds

  Card Stack Carousel Improvements (#5509)

  - Fix card centering by using afterNextRender instead of AfterViewInit
  - Add ResizeObserver to handle dynamic size changes
  - Disable transforms until natural position is measured (prevents initial jump)
  - Center single card in carousel view

  Tooltip Enhancements

  - Add success variant with green styling (isa-accent-green)
  - Add position input (left | right | top | bottom)
  - Add fade in/out CSS keyframes animations (150ms)
  - Respect prefers-reduced-motion for accessibility

  Related Tasks

  - Closes #5508
  - Refs #5509
2025-11-27 16:28:06 +00:00
Lorenz Hilpert
7950994d66 Merged PR 2057: feat(checkout): add branch selection to reward catalog
feat(checkout): add branch selection to reward catalog

- Add new select-branch-dropdown library with BranchDropdownComponent
  and SelectedBranchDropdownComponent for branch selection
- Extend DropdownButtonComponent with filter and option subcomponents
- Integrate branch selection into reward catalog page
- Add BranchesResource for fetching available branches
- Update CheckoutMetadataService with branch selection persistence
- Add comprehensive tests for dropdown components

Related work items: #5464
2025-11-27 10:38:52 +00:00
Nino Righi
4589146e31 Merged PR 2056: fix(ui-tooltip): Integrated CloseOnScrollDirective to close tooltip on scrolling
fix(ui-tooltip): Integrated CloseOnScrollDirective to close tooltip on scrolling

Ref: #5510
2025-11-26 20:08:11 +00:00
Nino
98fb863fc7 Merge branch 'release/4.5' into develop 2025-11-26 16:46:47 +01:00
Nino Righi
6f13d48604 Merged PR 2054: fix(checkout-reward-shopping-cart): Layout Issue Fix
fix(checkout-reward-shopping-cart): Layout Issue Fix

Ref: #5458
2025-11-26 14:04:06 +00:00
Nino Righi
d4bba4075b Merged PR 2053: feature(checkout-reward-cart): Added Empty State and View Adjustments, Disabl...
feature(checkout-reward-cart): Added Empty State and View Adjustments, Disable CTA if no items are in cart

Ref: #5435
2025-11-26 14:03:24 +00:00
Lorenz Hilpert
1fae7df73e 📝 docs: add library-reference.md usage guidance to CLAUDE.md 2025-11-25 14:14:19 +01:00
Lorenz Hilpert
bc1f6a42e6 📝 docs: update README documentation for 13 libraries 2025-11-25 14:13:44 +01:00
Nino Righi
b93e39068c Merged PR 2050: feature(checkout-reward): Disable Print Order Confirmation for HSC Users
feature(checkout-reward): Disable Print Order Confirmation for HSC Users

Ref: #5471
2025-11-24 16:01:56 +00:00
Lorenz Hilpert
dc26c4de04 Merged PR 2049: feat(oms): add auto-refresh for open reward tasks
feat(oms): add auto-refresh for open reward tasks

Implements 5-minute polling to automatically update open reward tasks
without requiring manual page refresh. Uses reference counting to safely
handle multiple consumers starting/stopping the refresh.

Changes:
- Add startAutoRefresh/stopAutoRefresh methods with ref counting
- Add lifecycle hooks to carousel component for proper cleanup
- Add logging for debugging auto-refresh behavior
- Add refresh interval constant (5 minutes)

Closes #5463

Related work items: #5463
2025-11-24 15:46:17 +00:00
231 changed files with 14867 additions and 10037 deletions

View File

@@ -29,12 +29,33 @@ Create well-formatted commit: $ARGUMENTS
6. If multiple distinct changes are detected, suggests breaking the commit into multiple smaller commits
7. For each commit (or the single commit if not split), creates a commit message using emoji conventional commit format
## Determining the Scope
The scope in commit messages MUST be the `name` field from the affected library's `project.json`:
1. **Check the file path**: `libs/ui/label/src/...` → Look at `libs/ui/label/project.json`
2. **Read the project name**: The `"name"` field (e.g., `"name": "ui-label"`)
3. **Use that as scope**: `feat(ui-label): ...`
**Examples:**
- File: `libs/remission/feature/remission-list/src/...` → Scope: `remission-feature-remission-list`
- File: `libs/ui/notice/src/...` → Scope: `ui-notice`
- File: `apps/isa-app/src/...` → Scope: `isa-app`
**Multi-project changes:**
- If changes span 2-3 related projects, use the primary one or list them: `feat(ui-label, ui-notice): ...`
- If changes span many unrelated projects, split into separate commits
## Best Practices for Commits
- **Verify before committing**: Ensure code is linted, builds correctly, and documentation is updated
- **Atomic commits**: Each commit should contain related changes that serve a single purpose
- **Split large changes**: If changes touch multiple concerns, split them into separate commits
- **Conventional commit format**: Use the format `<type>: <description>` where type is one of:
- **Conventional commit format**: Use the format `<type>(<scope>): <description>` where:
- **scope**: The project name from `project.json` of the affected library (e.g., `ui-label`, `crm-feature-checkout`)
- Determine the scope by checking which library/project the changes belong to
- If changes span multiple projects, use the primary affected project or split into multiple commits
- type is one of:
- `feat`: A new feature
- `fix`: A bug fix
- `docs`: Documentation changes
@@ -122,37 +143,73 @@ When analyzing the diff, consider splitting commits based on these criteria:
## Examples
Good commit messages:
- ✨ feat: add user authentication system
- 🐛 fix: resolve memory leak in rendering process
- 📝 docs: update API documentation with new endpoints
- ♻️ refactor: simplify error handling logic in parser
- 🚨 fix: resolve linter warnings in component files
- 🧑‍💻 chore: improve developer tooling setup process
- 👔 feat: implement business logic for transaction validation
- 🩹 fix: address minor styling inconsistency in header
- 🚑️ fix: patch critical security vulnerability in auth flow
- 🎨 style: reorganize component structure for better readability
- 🔥 fix: remove deprecated legacy code
- 🦺 feat: add input validation for user registration form
- 💚 fix: resolve failing CI pipeline tests
- 📈 feat: implement analytics tracking for user engagement
- 🔒️ fix: strengthen authentication password requirements
- ♿️ feat: improve form accessibility for screen readers
Good commit messages (scope = project name from project.json):
- ✨ feat(auth-feature-login): add user authentication system
- 🐛 fix(ui-renderer): resolve memory leak in rendering process
- 📝 docs(crm-api): update API documentation with new endpoints
- ♻️ refactor(shared-utils): simplify error handling logic in parser
- 🚨 fix(ui-label): resolve linter warnings in component files
- 🧑‍💻 chore(dev-tools): improve developer tooling setup process
- 👔 feat(checkout-feature): implement business logic for transaction validation
- 🩹 fix(ui-header): address minor styling inconsistency in header
- 🚑️ fix(auth-core): patch critical security vulnerability in auth flow
- 🎨 style(ui-components): reorganize component structure for better readability
- 🔥 fix(legacy-module): remove deprecated legacy code
- 🦺 feat(user-registration): add input validation for user registration form
- 💚 fix(ci-config): resolve failing CI pipeline tests
- 📈 feat(analytics-feature): implement analytics tracking for user engagement
- 🔒️ fix(auth-password): strengthen authentication password requirements
- ♿️ feat(ui-forms): improve form accessibility for screen readers
Example of splitting commits:
- First commit: ✨ feat: add new solc version type definitions
- Second commit: 📝 docs: update documentation for new solc versions
- Third commit: 🔧 chore: update package.json dependencies
- Fourth commit: 🏷️ feat: add type definitions for new API endpoints
- Fifth commit: 🧵 feat: improve concurrency handling in worker threads
- Sixth commit: 🚨 fix: resolve linting issues in new code
- Seventh commit: ✅ test: add unit tests for new solc version features
- Eighth commit: 🔒️ fix: update dependencies with security vulnerabilities
- First commit: ✨ feat(ui-label): add prio-label component with variant styles
- Second commit: 📝 docs(ui-label): update README with usage examples
- Third commit: 🔧 chore(ui-notice): scaffold new notice library
- Fourth commit: 🏷️ feat(shared-types): add type definitions for new API endpoints
- Fifth commit: ♻️ refactor(pickup-shelf): update components to use new label
- Sixth commit: 🚨 fix(remission-list): resolve linting issues in new code
- Seventh commit: ✅ test(ui-label): add unit tests for prio-label component
- Eighth commit: 🔒️ fix(deps): update dependencies with security vulnerabilities
## Command Options
- `--no-verify`: Skip running the pre-commit checks (lint, build, generate:docs)
- `--amend`: Amend the previous commit (RESTRICTED - see rules below)
## Amend Rules (CRITICAL)
**ONLY use `--amend` in these specific cases:**
1. **Adding pre-commit hook fixes**: If a pre-commit hook modified files (formatting, linting auto-fixes), you may amend to include those changes.
2. **Before amending, ALWAYS verify:**
```bash
# Check authorship - NEVER amend another developer's commit
git log -1 --format='%an %ae'
# Check not pushed - NEVER amend pushed commits
git status # Should show "Your branch is ahead of..."
```
3. **If either check fails:**
- Create a NEW commit instead
- Never amend commits authored by others
- Never amend commits already pushed to remote
**Example workflow for pre-commit hook changes:**
```bash
# 1. Initial commit triggers pre-commit hook
git commit -m "feat(scope): add feature"
# Hook modifies files...
# 2. Verify safe to amend
git log -1 --format='%an %ae' # Your name/email
git status # "Your branch is ahead..."
# 3. Stage hook changes and amend
git add .
git commit --amend --no-edit
```
## Important Notes

View File

@@ -1,94 +0,0 @@
---
allowed-tools: Read, Write, Edit, Bash
argument-hint: [framework] | --c4-model | --arc42 | --adr | --plantuml | --full-suite
description: Generate comprehensive architecture documentation with diagrams, ADRs, and interactive visualization
---
# Architecture Documentation Generator
Generate comprehensive architecture documentation: $ARGUMENTS
## Current Architecture Context
- Project structure: !`find . -type f -name "*.json" -o -name "*.yaml" -o -name "*.toml" | head -5`
- Documentation exists: @docs/ or @README.md (if exists)
- Architecture files: !`find . -name "*architecture*" -o -name "*design*" -o -name "*.puml" | head -3`
- Services/containers: @docker-compose.yml or @k8s/ (if exists)
- API definitions: !`find . -name "*api*" -o -name "*openapi*" -o -name "*swagger*" | head -3`
## Task
Generate comprehensive architecture documentation with modern tooling and best practices:
1. **Architecture Analysis and Discovery**
- Analyze current system architecture and component relationships
- Identify key architectural patterns and design decisions
- Document system boundaries, interfaces, and dependencies
- Assess data flow and communication patterns
- Identify architectural debt and improvement opportunities
2. **Architecture Documentation Framework**
- Choose appropriate documentation framework and tools:
- **C4 Model**: Context, Containers, Components, Code diagrams
- **Arc42**: Comprehensive architecture documentation template
- **Architecture Decision Records (ADRs)**: Decision documentation
- **PlantUML/Mermaid**: Diagram-as-code documentation
- **Structurizr**: C4 model tooling and visualization
- **Draw.io/Lucidchart**: Visual diagramming tools
3. **System Context Documentation**
- Create high-level system context diagrams
- Document external systems and integrations
- Define system boundaries and responsibilities
- Document user personas and stakeholders
- Create system landscape and ecosystem overview
4. **Container and Service Architecture**
- Document container/service architecture and deployment view
- Create service dependency maps and communication patterns
- Document deployment architecture and infrastructure
- Define service boundaries and API contracts
- Document data persistence and storage architecture
5. **Component and Module Documentation**
- Create detailed component architecture diagrams
- Document internal module structure and relationships
- Define component responsibilities and interfaces
- Document design patterns and architectural styles
- Create code organization and package structure documentation
6. **Data Architecture Documentation**
- Document data models and database schemas
- Create data flow diagrams and processing pipelines
- Document data storage strategies and technologies
- Define data governance and lifecycle management
- Create data integration and synchronization documentation
7. **Security and Compliance Architecture**
- Document security architecture and threat model
- Create authentication and authorization flow diagrams
- Document compliance requirements and controls
- Define security boundaries and trust zones
- Create incident response and security monitoring documentation
8. **Quality Attributes and Cross-Cutting Concerns**
- Document performance characteristics and scalability patterns
- Create reliability and availability architecture documentation
- Document monitoring and observability architecture
- Define maintainability and evolution strategies
- Create disaster recovery and business continuity documentation
9. **Architecture Decision Records (ADRs)**
- Create comprehensive ADR template and process
- Document historical architectural decisions and rationale
- Create decision tracking and review process
- Document trade-offs and alternatives considered
- Set up ADR maintenance and evolution procedures
10. **Documentation Automation and Maintenance**
- Set up automated diagram generation from code annotations
- Configure documentation pipeline and publishing automation
- Set up documentation validation and consistency checking
- Create documentation review and approval process
- Train team on architecture documentation practices and tools
- Set up documentation versioning and change management

View File

@@ -1,3 +1,9 @@
---
allowed-tools: Read, Write, Edit, Bash, Grep, Glob, Task
argument-hint: [library-name]
description: Generate or update README.md for a specific library with comprehensive documentation
---
# /docs:library - Generate/Update Library README
Generate or update README.md for a specific library with comprehensive documentation.

View File

@@ -1,3 +1,9 @@
---
allowed-tools: Read, Write, Edit, Bash, Grep, Glob
argument-hint: --dry-run | --force
description: Regenerate library reference documentation (docs/library-reference.md) by scanning all monorepo libraries
---
# /docs:refresh-reference - Regenerate Library Reference
Regenerate the library reference documentation (`docs/library-reference.md`) by scanning all libraries in the monorepo.

View File

@@ -1,129 +0,0 @@
# /quality:bundle-analyze - Analyze Bundle Sizes
Analyze production bundle sizes and provide optimization recommendations. Project thresholds: 2MB warning, 5MB error.
## Tasks
### 1. Run Production Build
```bash
# Clean previous build
rm -rf dist/
# Build for production
npm run build-prod
```
### 2. Analyze Bundle Output
```bash
# List bundle files with sizes
ls -lh dist/apps/isa-app/browser/*.js | awk '{print $9, $5}'
# Get total bundle size
du -sh dist/apps/isa-app/browser/
```
### 3. Identify Large Files
Parse build output and identify:
- Main bundle size
- Lazy-loaded chunk sizes
- Vendor chunks
- Files exceeding thresholds:
- **Warning**: > 2MB
- **Error**: > 5MB
### 4. Analyze Dependencies
```bash
# Check for duplicate dependencies
npm ls --depth=0 | grep -E "UNMET|deduped"
# Show largest node_modules packages
du -sh node_modules/* | sort -rh | head -20
```
### 5. Source Map Analysis
Use source maps to identify large contributors:
```bash
# Install source-map-explorer if needed
npm install -g source-map-explorer
# Analyze main bundle
source-map-explorer dist/apps/isa-app/browser/main.*.js
```
### 6. Generate Recommendations
Based on analysis, provide actionable recommendations:
**If bundle > 2MB:**
- Identify heavy dependencies to replace or remove
- Suggest lazy loading opportunities
- Check for unused imports
**Code Splitting Opportunities:**
- Large feature modules that could be lazy-loaded
- Heavy libraries that could be dynamically imported
- Vendor code that could be split into separate chunks
**Dependency Optimization:**
- Replace large libraries with smaller alternatives
- Remove unused dependencies
- Use tree-shakeable imports
**Build Configuration:**
- Enable/optimize compression
- Check for source maps in production (should be disabled)
- Verify optimization flags
### 7. Comparative Analysis
If previous build data exists:
```bash
# Compare with previous build
# (Requires manual tracking or CI/CD integration)
echo "Current build: $(du -sh dist/apps/isa-app/browser/ | awk '{print $1}')"
```
### 8. Generate Report
Create formatted report with:
- Total bundle size with threshold status (✅ < 2MB, ⚠️ 2-5MB, ❌ > 5MB)
- Main bundle and largest chunks
- Top 10 largest dependencies
- Optimization recommendations prioritized by impact
- Lazy loading opportunities
## Output Format
```
Bundle Analysis Report
======================
Total Size: X.XX MB [STATUS]
Main Bundle: X.XX MB
Largest Chunks:
- chunk-name.js: X.XX MB
- ...
Largest Dependencies:
1. dependency-name: X.XX MB
...
Recommendations:
🔴 Critical (> 5MB):
- [Action items]
⚠️ Warning (2-5MB):
- [Action items]
✅ Optimization Opportunities:
- [Action items]
Lazy Loading Candidates:
- [Feature modules]
```
## Error Handling
- Build failures: Show error and suggest fixes
- Missing tools: Offer to install (source-map-explorer)
- No dist folder: Run build first
## References
- CLAUDE.md Build Configuration section
- Angular build optimization: https://angular.dev/tools/cli/build
- package.json (build-prod script)

View File

@@ -1,201 +0,0 @@
# /quality:coverage - Generate Test Coverage Report
Generate comprehensive test coverage report with recommendations for improving coverage.
## Parameters
- `library-name` (optional): Specific library to analyze. If omitted, analyzes all libraries.
## Tasks
### 1. Run Coverage Analysis
```bash
# Single library
npx nx test [library-name] --skip-nx-cache --coverage
# All libraries (if no library specified)
npm run ci # Runs all tests with coverage
```
### 2. Parse Coverage Report
Coverage output typically in:
- `coverage/libs/[domain]/[layer]/[name]/`
- Look for `coverage-summary.json` or text output
Extract metrics:
- **Line coverage**: % of executable lines tested
- **Branch coverage**: % of conditional branches tested
- **Function coverage**: % of functions called in tests
- **Statement coverage**: % of statements executed
### 3. Identify Uncovered Code
Parse coverage report to find:
- **Uncovered files**: Files with 0% coverage
- **Partially covered files**: < 80% coverage
- **Uncovered lines**: Specific line numbers not tested
- **Uncovered branches**: Conditional paths not tested
```bash
# List files with coverage below 80%
# (Parse from coverage JSON output)
```
### 4. Categorize Coverage Gaps
**Critical (High Risk):**
- Service methods handling business logic
- Data transformation functions
- Error handling code paths
- Security-related functions
- State management store actions
**Important (Medium Risk):**
- Component public methods
- Utility functions
- Validators
- Pipes and filters
- Guard functions
**Low Priority:**
- Getters/setters
- Simple property assignments
- Console logging
- Type definitions
### 5. Generate Recommendations
For each coverage gap, provide:
- **File and line numbers**
- **Risk level** (Critical/Important/Low)
- **Suggested test type** (unit/integration)
- **Test approach** (example test scenario)
Example:
```
📍 libs/oms/data-access/src/lib/services/order.service.ts:45-52
🔴 Critical - Business Logic
❌ 0% coverage - Error handling path
Recommended test:
it('should handle API error when fetching order', async () => {
// Mock API to return error
// Call method
// Verify error handling
});
```
### 6. Calculate Coverage Trends
If historical data available:
- Compare with previous coverage percentage
- Show improvement/regression
- Identify files with declining coverage
### 7. Generate HTML Report
```bash
# Open coverage report in browser (if available)
open coverage/libs/[domain]/[layer]/[name]/index.html
```
### 8. Create Coverage Summary Report
**Overall Metrics:**
```
Coverage Summary for [library-name]
====================================
Line Coverage: XX.X% (XXX/XXX lines)
Branch Coverage: XX.X% (XXX/XXX branches)
Function Coverage: XX.X% (XXX/XXX functions)
Statement Coverage: XX.X% (XXX/XXX statements)
Target: 80% (Recommended minimum)
Status: ✅ Met / ⚠️ Below Target / 🔴 Critical
```
**Files Needing Attention:**
```
🔴 Critical (< 50% coverage):
1. service-name.service.ts - 35% (business logic)
2. data-processor.ts - 42% (transformations)
⚠️ Below Target (50-79% coverage):
3. component-name.component.ts - 68%
4. validator.ts - 72%
✅ Well Covered (≥ 80% coverage):
- Other files maintaining good coverage
```
**Top Priority Tests to Add:**
1. [File:Line] - [Description] - [Risk Level]
2. ...
### 9. Framework-Specific Notes
**Vitest:**
- Coverage provider: v8 or istanbul
- Config in `vitest.config.ts`
- Coverage thresholds configurable
**Jest:**
- Coverage collected via `--coverage` flag
- Config in `jest.config.ts`
- Coverage directory: `coverage/`
### 10. Set Coverage Thresholds (Optional)
Suggest adding to test config:
```typescript
// vitest.config.ts
coverage: {
thresholds: {
lines: 80,
functions: 80,
branches: 80,
statements: 80
}
}
```
## Output Format
```
Test Coverage Report
====================
Library: [name]
Test Framework: [Vitest/Jest]
Generated: [timestamp]
📊 Coverage Metrics
-------------------
Lines: XX.X% ████████░░ (XXX/XXX)
Branches: XX.X% ███████░░░ (XXX/XXX)
Functions: XX.X% █████████░ (XXX/XXX)
Statements: XX.X% ████████░░ (XXX/XXX)
🎯 Target: 80% | Status: [✅/⚠️/🔴]
🔍 Coverage Gaps
----------------
[Categorized list with priorities]
💡 Recommendations
------------------
[Prioritized list of tests to add]
📈 Next Steps
-------------
1. Focus on critical coverage gaps first
2. Add tests for business logic in [files]
3. Consider setting coverage thresholds
4. Re-run: npx nx test [library-name] --skip-nx-cache --coverage
```
## Error Handling
- No coverage data: Ensure `--coverage` flag used
- Missing library: Verify library name is correct
- Coverage tool not configured: Check test config for coverage setup
## References
- docs/guidelines/testing.md
- CLAUDE.md Testing Framework section
- Vitest coverage: https://vitest.dev/guide/coverage
- Jest coverage: https://jestjs.io/docs/configuration#collectcoverage-boolean

188
.claude/commands/quality.md Normal file
View File

@@ -0,0 +1,188 @@
---
allowed-tools: Read, Write, Edit, Bash, Grep, Glob
argument-hint: bundle | coverage [library-name] | --all
description: Analyze code quality: bundle sizes, test coverage, with optimization recommendations
---
# /quality - Code Quality Analysis
Comprehensive quality analysis including bundle sizes and test coverage.
## Subcommands
- `bundle` - Analyze production bundle sizes
- `coverage [library-name]` - Test coverage analysis
- No argument - Run both analyses
- `[library-name]` - Coverage for specific library
## Bundle Analysis
### 1. Run Production Build
```bash
# Clean previous build
rm -rf dist/
# Build for production
npm run build-prod
```
### 2. Analyze Bundle Output
```bash
# List bundle files with sizes
ls -lh dist/apps/isa-app/browser/*.js | awk '{print $9, $5}'
# Get total bundle size
du -sh dist/apps/isa-app/browser/
```
### 3. Identify Large Files
Parse build output and identify:
- Main bundle size
- Lazy-loaded chunk sizes
- Vendor chunks
- Files exceeding thresholds:
- **Warning**: > 2MB
- **Error**: > 5MB
### 4. Analyze Dependencies
```bash
# Check for duplicate dependencies
npm ls --depth=0 | grep -E "UNMET|deduped"
# Show largest node_modules packages
du -sh node_modules/* | sort -rh | head -20
```
### 5. Source Map Analysis (Optional)
```bash
# Install source-map-explorer if needed
npm install -g source-map-explorer
# Analyze main bundle
source-map-explorer dist/apps/isa-app/browser/main.*.js
```
### 6. Bundle Recommendations
**If bundle > 2MB:**
- Identify heavy dependencies to replace or remove
- Suggest lazy loading opportunities
- Check for unused imports
**Code Splitting Opportunities:**
- Large feature modules that could be lazy-loaded
- Heavy libraries that could be dynamically imported
**Dependency Optimization:**
- Replace large libraries with smaller alternatives
- Remove unused dependencies
- Use tree-shakeable imports
---
## Coverage Analysis
### 1. Run Coverage Analysis
```bash
# Single library
npx nx test [library-name] --skip-nx-cache --coverage
# All libraries (if no library specified)
npm run ci # Runs all tests with coverage
```
### 2. Parse Coverage Report
Coverage output in: `coverage/libs/[domain]/[layer]/[name]/`
Extract metrics:
- **Line coverage**: % of executable lines tested
- **Branch coverage**: % of conditional branches tested
- **Function coverage**: % of functions called in tests
- **Statement coverage**: % of statements executed
### 3. Identify Uncovered Code
Parse coverage report to find:
- **Uncovered files**: Files with 0% coverage
- **Partially covered files**: < 80% coverage
- **Uncovered lines**: Specific line numbers not tested
- **Uncovered branches**: Conditional paths not tested
### 4. Categorize Coverage Gaps
**Critical (High Risk):**
- Service methods handling business logic
- Data transformation functions
- Error handling code paths
- Security-related functions
- State management store actions
**Important (Medium Risk):**
- Component public methods
- Utility functions
- Validators and guards
**Low Priority:**
- Getters/setters
- Simple property assignments
### 5. Generate Recommendations
For each coverage gap provide:
- **File and line numbers**
- **Risk level** (Critical/Important/Low)
- **Suggested test type** (unit/integration)
- **Test approach** (example test scenario)
---
## Output Formats
### Bundle Report
```
Bundle Analysis Report
======================
Total Size: X.XX MB [STATUS]
Main Bundle: X.XX MB
Largest Chunks:
- chunk-name.js: X.XX MB
Largest Dependencies:
1. dependency-name: X.XX MB
Recommendations:
- [Prioritized action items]
```
### Coverage Report
```
Coverage Summary for [library-name]
====================================
Line Coverage: XX.X% (XXX/XXX lines)
Branch Coverage: XX.X% (XXX/XXX branches)
Function Coverage: XX.X% (XXX/XXX functions)
Target: 80% (Recommended minimum)
Status: [Met/Below Target/Critical]
Files Needing Attention:
[Categorized list with priorities]
Top Priority Tests to Add:
[Prioritized recommendations]
```
## Error Handling
- **Build failures**: Show error and suggest fixes
- **Missing tools**: Offer to install (source-map-explorer)
- **No coverage data**: Ensure `--coverage` flag used
- **Missing library**: Verify library name is correct
## References
- CLAUDE.md Build Configuration section
- docs/guidelines/testing.md
- Angular build optimization: https://angular.dev/tools/cli/build
- Vitest coverage: https://vitest.dev/guide/coverage

View File

@@ -18,6 +18,7 @@ Guide for modern Angular 20+ template patterns: control flow, lazy loading, proj
**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)
- **[logging](../logging/SKILL.md)** - MANDATORY logging in all Angular files using `@isa/core/logging`
## Control Flow (Angular 17+)

View File

@@ -0,0 +1,171 @@
---
name: architecture-documentation
description: Generate architecture documentation (C4, Arc42, ADRs, PlantUML). Auto-invoke when user mentions "architecture docs", "C4 model", "ADR", "document architecture", "system design", or "create architecture diagram".
---
# Architecture Documentation Skill
Generate comprehensive architecture documentation using modern frameworks and best practices.
## When to Use
- Creating or updating architecture documentation
- Generating C4 model diagrams (Context, Container, Component, Code)
- Writing Architecture Decision Records (ADRs)
- Documenting system design and component relationships
- Creating PlantUML or Mermaid diagrams
## Available Frameworks
### C4 Model
Best for: Visualizing software architecture at different abstraction levels
Levels:
1. **Context** - System landscape and external actors
2. **Container** - High-level technology choices (apps, databases, etc.)
3. **Component** - Internal structure of containers
4. **Code** - Class/module level detail (optional)
See: `@references/c4-model.md` for patterns and examples
### Arc42 Template
Best for: Comprehensive architecture documentation
Sections:
1. Introduction and Goals
2. Constraints
3. Context and Scope
4. Solution Strategy
5. Building Block View
6. Runtime View
7. Deployment View
8. Cross-cutting Concepts
9. Architecture Decisions
10. Quality Requirements
11. Risks and Technical Debt
12. Glossary
See: `@references/arc42.md` for template structure
### Architecture Decision Records (ADRs)
Best for: Documenting individual architectural decisions
See: `@references/adr-template.md` for format and examples
## Workflow
### 1. Discovery Phase
```bash
# Find existing architecture files
find . -name "*architecture*" -o -name "*.puml" -o -name "*.mmd"
# Identify service boundaries
cat nx.json docker-compose.yml
# Check for existing ADRs
ls -la docs/adr/ docs/decisions/
```
### 2. Analysis Phase
- Analyze codebase structure (`libs/`, `apps/`)
- Identify dependencies from `tsconfig.base.json` paths
- Review service boundaries from `project.json` tags
- Map data flow from API definitions
### 3. Documentation Phase
Based on the request, create appropriate documentation:
**For C4 diagrams:**
```
docs/architecture/
├── c4-context.puml
├── c4-container.puml
└── c4-component-[name].puml
```
**For ADRs:**
```
docs/adr/
├── 0001-record-architecture-decisions.md
├── 0002-[decision-title].md
└── template.md
```
**For Arc42:**
```
docs/architecture/
└── arc42/
├── 01-introduction.md
├── 02-constraints.md
└── ...
```
## ISA-Frontend Specific Context
### Monorepo Structure
- **apps/**: Angular applications
- **libs/**: Shared libraries organized by domain
- `libs/[domain]/feature/` - Feature modules
- `libs/[domain]/data-access/` - State management
- `libs/[domain]/ui/` - Presentational components
- `libs/[domain]/util/` - Utilities
### Key Architectural Patterns
- **Nx Monorepo** with strict module boundaries
- **NgRx Signal Store** for state management
- **Standalone Components** (Angular 20+)
- **Domain-Driven Design** library organization
### Documentation Locations
- ADRs: `docs/adr/`
- Architecture diagrams: `docs/architecture/`
- API documentation: Generated from Swagger/OpenAPI
## Output Standards
### PlantUML Format
```plantuml
@startuml C4_Context
!include https://raw.githubusercontent.com/plantuml-stdlib/C4-PlantUML/master/C4_Context.puml
Person(user, "User", "System user")
System(system, "ISA System", "Main application")
System_Ext(external, "External API", "Third-party service")
Rel(user, system, "Uses")
Rel(system, external, "Calls")
@enduml
```
### Mermaid Format
```mermaid
graph TD
A[User] --> B[ISA App]
B --> C[API Gateway]
C --> D[Backend Services]
```
### ADR Format
```markdown
# ADR-XXXX: [Title]
## Status
[Proposed | Accepted | Deprecated | Superseded]
## Context
[What is the issue?]
## Decision
[What was decided?]
## Consequences
[What are the results?]
```
## Best Practices
1. **Start with Context** - Always begin with C4 Level 1 (System Context)
2. **Use Consistent Notation** - Stick to one diagramming tool/format
3. **Keep ADRs Atomic** - One decision per ADR
4. **Version Control** - Commit documentation with code changes
5. **Review Regularly** - Architecture docs decay; schedule reviews

View File

@@ -0,0 +1,213 @@
# Architecture Decision Record (ADR) Template
## Overview
Architecture Decision Records document significant architectural decisions along with their context and consequences.
## ADR Format
### Standard Template
```markdown
# ADR-XXXX: [Short Title]
## Status
[Proposed | Accepted | Deprecated | Superseded by ADR-YYYY]
## Date
YYYY-MM-DD
## Context
What is the issue that we're seeing that is motivating this decision or change?
## Decision
What is the change that we're proposing and/or doing?
## Consequences
### Positive
- Benefit 1
- Benefit 2
### Negative
- Drawback 1
- Drawback 2
### Neutral
- Side effect 1
## Alternatives Considered
### Option 1: [Name]
- Pros: ...
- Cons: ...
- Why rejected: ...
### Option 2: [Name]
- Pros: ...
- Cons: ...
- Why rejected: ...
## References
- [Link to related documentation]
- [Link to discussion thread]
```
## Example ADRs
### ADR-0001: Use Nx Monorepo
```markdown
# ADR-0001: Use Nx Monorepo for Project Organization
## Status
Accepted
## Date
2024-01-15
## Context
The ISA Frontend consists of multiple applications and shared libraries. We need a way to:
- Share code between applications
- Maintain consistent tooling and dependencies
- Enable efficient CI/CD with affected-based testing
- Enforce architectural boundaries
## Decision
We will use Nx as our monorepo tool with the following structure:
- `apps/` - Deployable applications
- `libs/` - Shared libraries organized by domain and type
## Consequences
### Positive
- Single version of dependencies across all projects
- Affected-based testing reduces CI time
- Consistent tooling (ESLint, Prettier, TypeScript)
- Built-in dependency graph visualization
### Negative
- Learning curve for team members new to Nx
- More complex initial setup
- All code in single repository increases clone time
### Neutral
- Requires discipline in library boundaries
- Need to maintain `project.json` and tags
## Alternatives Considered
### Polyrepo
- Pros: Simpler individual repos, independent deployments
- Cons: Dependency management nightmare, code duplication
- Why rejected: Too much overhead for code sharing
```
### ADR-0002: Adopt NgRx Signal Store
```markdown
# ADR-0002: Adopt NgRx Signal Store for State Management
## Status
Accepted
## Date
2024-03-01
## Context
We need a state management solution that:
- Integrates well with Angular signals
- Provides predictable state updates
- Supports devtools for debugging
- Has good TypeScript support
## Decision
We will use NgRx Signal Store for new features and gradually migrate existing stores.
## Consequences
### Positive
- Native signal integration
- Simpler boilerplate than classic NgRx
- Better performance with fine-grained reactivity
- Excellent TypeScript inference
### Negative
- Migration effort for existing NgRx stores
- Different patterns from classic NgRx
### Neutral
- Team needs to learn new API
## Alternatives Considered
### Classic NgRx (Store + Effects)
- Pros: Mature, well-documented
- Cons: Verbose boilerplate, doesn't leverage signals
- Why rejected: Signal Store is the future direction
### Akita
- Pros: Less boilerplate
- Cons: Not Angular-native, less community support
- Why rejected: NgRx has better Angular integration
```
## ADR Naming Convention
```
docs/adr/
├── 0000-adr-template.md # Template file
├── 0001-use-nx-monorepo.md
├── 0002-adopt-ngrx-signal-store.md
├── 0003-standalone-components.md
└── README.md # Index of all ADRs
```
## ADR Index Template
```markdown
# Architecture Decision Records
This directory contains Architecture Decision Records (ADRs) for the ISA Frontend.
## Index
| ADR | Title | Status | Date |
|-----|-------|--------|------|
| [0001](0001-use-nx-monorepo.md) | Use Nx Monorepo | Accepted | 2024-01-15 |
| [0002](0002-adopt-ngrx-signal-store.md) | Adopt NgRx Signal Store | Accepted | 2024-03-01 |
| [0003](0003-standalone-components.md) | Migrate to Standalone Components | Accepted | 2024-04-01 |
## Process
1. Copy `0000-adr-template.md` to `XXXX-short-title.md`
2. Fill in the template
3. Submit PR for review
4. Update status once decided
5. Update this index
```
## Best Practices
1. **One decision per ADR** - Keep ADRs focused
2. **Number sequentially** - Never reuse numbers
3. **Record context** - Why was this needed?
4. **Document alternatives** - Show what was considered
5. **Keep concise** - 1-2 pages max
6. **Update status** - Mark deprecated decisions
7. **Link related ADRs** - Reference superseding decisions
8. **Review regularly** - Quarterly ADR review meetings
## When to Write an ADR
Write an ADR when:
- Choosing a framework or library
- Defining code organization patterns
- Setting up infrastructure
- Establishing conventions
- Making trade-offs that affect multiple teams
Don't write an ADR for:
- Small implementation details
- Obvious choices with no alternatives
- Temporary solutions

View File

@@ -0,0 +1,268 @@
# Arc42 Architecture Documentation Template
## Overview
Arc42 is a template for architecture documentation with 12 sections covering all aspects of software architecture.
## Template Structure
### 1. Introduction and Goals
```markdown
# 1. Introduction and Goals
## 1.1 Requirements Overview
- Core business requirements driving the architecture
- Key functional requirements
- Quality goals and priorities
## 1.2 Quality Goals
| Priority | Quality Goal | Description |
|----------|-------------|-------------|
| 1 | Performance | < 200ms response time |
| 2 | Usability | Intuitive for store employees |
| 3 | Reliability | 99.9% uptime during store hours |
## 1.3 Stakeholders
| Role | Expectations |
|------|-------------|
| Store Employee | Fast, reliable daily operations |
| IT Operations | Easy deployment and monitoring |
| Development Team | Maintainable, testable code |
```
### 2. Architecture Constraints
```markdown
# 2. Architecture Constraints
## 2.1 Technical Constraints
| Constraint | Background |
|------------|------------|
| Angular 20+ | Company standard frontend framework |
| TypeScript strict | Type safety requirement |
| Browser support | Chrome 90+, Edge 90+ |
## 2.2 Organizational Constraints
| Constraint | Background |
|------------|------------|
| Monorepo | Nx-based shared codebase |
| CI/CD | Azure DevOps pipelines |
| Code review | All changes require PR approval |
## 2.3 Conventions
- Conventional commits
- ESLint/Prettier formatting
- Component-driven development
```
### 3. System Scope and Context
```markdown
# 3. System Scope and Context
## 3.1 Business Context
[C4 Level 1 - System Context Diagram]
| Neighbor | Description |
|----------|-------------|
| Store Employee | Primary user performing daily operations |
| Backend APIs | Provides business logic and data |
| Printer Service | Label and receipt printing |
## 3.2 Technical Context
[Deployment/Network Diagram]
| Interface | Protocol | Description |
|-----------|----------|-------------|
| REST API | HTTPS/JSON | Backend communication |
| WebSocket | WSS | Real-time updates |
| OAuth2 | HTTPS | Authentication |
```
### 4. Solution Strategy
```markdown
# 4. Solution Strategy
## Key Architectural Decisions
| Decision | Rationale |
|----------|-----------|
| Angular SPA | Rich interactive UI, offline capability |
| NgRx Signal Store | Predictable state management |
| Nx Monorepo | Code sharing, consistent tooling |
| Standalone Components | Better tree-shaking, simpler imports |
## Quality Achievement Strategies
| Quality Goal | Approach |
|--------------|----------|
| Performance | Lazy loading, caching, code splitting |
| Maintainability | Domain-driven library structure |
| Testability | Component isolation, dependency injection |
```
### 5. Building Block View
```markdown
# 5. Building Block View
## Level 1: Application Overview
[C4 Container Diagram]
## Level 2: Domain Decomposition
| Domain | Purpose | Libraries |
|--------|---------|-----------|
| CRM | Customer management | crm-feature-*, crm-data-access-* |
| OMS | Order management | oms-feature-*, oms-data-access-* |
| Checkout | Transactions | checkout-feature-*, checkout-data-access-* |
## Level 3: Feature Details
[C4 Component Diagrams per domain]
```
### 6. Runtime View
```markdown
# 6. Runtime View
## Scenario 1: User Login
```mermaid
sequenceDiagram
User->>App: Open application
App->>Auth: Redirect to login
Auth->>App: Return token
App->>API: Fetch user profile
API->>App: User data
App->>User: Display dashboard
```
## Scenario 2: Order Processing
[Sequence diagram for order flow]
```
### 7. Deployment View
```markdown
# 7. Deployment View
## Infrastructure
```mermaid
graph TD
CDN[CDN] --> Browser[User Browser]
Browser --> LB[Load Balancer]
LB --> API[API Gateway]
API --> Services[Backend Services]
```
## Environments
| Environment | URL | Purpose |
|-------------|-----|---------|
| Development | dev.isa.local | Local development |
| Staging | staging.isa.com | Integration testing |
| Production | isa.com | Live system |
```
### 8. Cross-cutting Concepts
```markdown
# 8. Cross-cutting Concepts
## 8.1 Domain Model
[Domain entity relationships]
## 8.2 Security
- Authentication: OAuth2/OIDC
- Authorization: Role-based access control
- Data protection: HTTPS, encrypted storage
## 8.3 Error Handling
- Global error interceptor
- User-friendly error messages
- Error logging to backend
## 8.4 Logging
- @isa/core/logging library
- Structured log format
- Log levels: trace, debug, info, warn, error
```
### 9. Architecture Decisions
```markdown
# 9. Architecture Decisions
See [ADR folder](../adr/) for detailed decision records.
## Key Decisions
- ADR-0001: Use Nx Monorepo
- ADR-0002: Adopt NgRx Signal Store
- ADR-0003: Migrate to Standalone Components
```
### 10. Quality Requirements
```markdown
# 10. Quality Requirements
## Quality Tree
```
Quality
├── Performance
│ ├── Response Time < 200ms
│ └── Time to Interactive < 3s
├── Reliability
│ ├── Uptime 99.9%
│ └── Graceful degradation
└── Maintainability
├── Test coverage > 80%
└── Clear module boundaries
```
## Quality Scenarios
| Scenario | Measure | Target |
|----------|---------|--------|
| Page load | Time to interactive | < 3s |
| API call | Response time | < 200ms |
| Build | CI pipeline duration | < 10min |
```
### 11. Risks and Technical Debt
```markdown
# 11. Risks and Technical Debt
## Identified Risks
| Risk | Probability | Impact | Mitigation |
|------|-------------|--------|------------|
| Backend unavailable | Medium | High | Offline mode |
| Performance degradation | Low | Medium | Monitoring |
## Technical Debt
| Item | Priority | Effort |
|------|----------|--------|
| Legacy Jest tests | Medium | High |
| Any types in codebase | High | Medium |
```
### 12. Glossary
```markdown
# 12. Glossary
| Term | Definition |
|------|------------|
| ADR | Architecture Decision Record |
| C4 | Context, Container, Component, Code model |
| ISA | In-Store Application |
| SPA | Single Page Application |
```
## Best Practices
1. **Start with sections 1-4** - Goals, constraints, context, strategy
2. **Add diagrams to section 5** - Building block views
3. **Document decisions in section 9** - Link to ADRs
4. **Keep updated** - Review quarterly
5. **Use templates** - Consistent formatting

View File

@@ -0,0 +1,163 @@
# C4 Model Reference
## Overview
The C4 model provides a way to visualize software architecture at four levels of abstraction:
1. **Context** - System landscape
2. **Container** - Applications and data stores
3. **Component** - Internal structure
4. **Code** - Class/module detail (optional)
## Level 1: System Context Diagram
Shows the system under design and its relationships with users and external systems.
### PlantUML Template
```plantuml
@startuml C4_Context
!include https://raw.githubusercontent.com/plantuml-stdlib/C4-PlantUML/master/C4_Context.puml
LAYOUT_WITH_LEGEND()
title System Context Diagram - ISA Frontend
Person(user, "Store Employee", "Uses the ISA application")
Person(admin, "Administrator", "Manages system configuration")
System(isa, "ISA Frontend", "Angular application for in-store operations")
System_Ext(backend, "ISA Backend", "REST API services")
System_Ext(auth, "Auth Provider", "Authentication service")
System_Ext(printer, "Printer Service", "Receipt/label printing")
Rel(user, isa, "Uses", "Browser")
Rel(admin, isa, "Configures", "Browser")
Rel(isa, backend, "API calls", "HTTPS/JSON")
Rel(isa, auth, "Authenticates", "OAuth2")
Rel(isa, printer, "Prints", "WebSocket")
@enduml
```
### Key Elements
- **Person**: Human users of the system
- **System**: The system being documented (highlighted)
- **System_Ext**: External systems the system depends on
- **Rel**: Relationships between elements
## Level 2: Container Diagram
Shows the high-level technology choices and how containers communicate.
### PlantUML Template
```plantuml
@startuml C4_Container
!include https://raw.githubusercontent.com/plantuml-stdlib/C4-PlantUML/master/C4_Container.puml
LAYOUT_WITH_LEGEND()
title Container Diagram - ISA Frontend
Person(user, "Store Employee")
System_Boundary(isa, "ISA Frontend") {
Container(spa, "SPA", "Angular 20", "Single-page application")
Container(pwa, "Service Worker", "Workbox", "Offline capability")
ContainerDb(storage, "Local Storage", "IndexedDB", "Offline data cache")
}
System_Ext(api, "ISA API Gateway")
System_Ext(cdn, "CDN", "Static assets")
Rel(user, spa, "Uses", "Browser")
Rel(spa, pwa, "Registers")
Rel(pwa, storage, "Caches data")
Rel(spa, api, "API calls", "REST/JSON")
Rel(spa, cdn, "Loads assets", "HTTPS")
@enduml
```
### Container Types
- **Container**: Application or service
- **ContainerDb**: Database or data store
- **ContainerQueue**: Message queue
## Level 3: Component Diagram
Shows the internal structure of a container.
### PlantUML Template
```plantuml
@startuml C4_Component
!include https://raw.githubusercontent.com/plantuml-stdlib/C4-PlantUML/master/C4_Component.puml
LAYOUT_WITH_LEGEND()
title Component Diagram - OMS Feature Module
Container_Boundary(oms, "OMS Feature") {
Component(list, "Order List", "Angular Component", "Displays orders")
Component(detail, "Order Detail", "Angular Component", "Order management")
Component(store, "Order Store", "NgRx Signal Store", "State management")
Component(api, "Order API Service", "Angular Service", "API communication")
}
ContainerDb_Ext(backend, "OMS Backend API")
Rel(list, store, "Reads state")
Rel(detail, store, "Reads/writes state")
Rel(store, api, "Fetches data")
Rel(api, backend, "HTTP requests")
@enduml
```
## ISA-Frontend Domain Components
### Suggested Component Structure
```
libs/[domain]/
├── feature/ → Component diagrams
│ └── [feature]/
├── data-access/ → Store/API components
│ └── [store]/
├── ui/ → Presentational components
│ └── [component]/
└── util/ → Utility components
└── [util]/
```
### Domain Boundaries
- **CRM**: Customer management, loyalty
- **OMS**: Order management, returns
- **Checkout**: Payment, transactions
- **Remission**: Product returns processing
- **Catalogue**: Product information
## Mermaid Alternative
```mermaid
C4Context
title System Context - ISA Frontend
Person(user, "Store Employee", "Daily operations")
System(isa, "ISA Frontend", "Angular SPA")
System_Ext(backend, "Backend Services")
System_Ext(auth, "Auth Service")
Rel(user, isa, "Uses")
Rel(isa, backend, "API calls")
Rel(isa, auth, "Authenticates")
```
## Best Practices
1. **One diagram per level** - Don't mix abstraction levels
2. **Consistent naming** - Use same names across diagrams
3. **Show key relationships** - Not every possible connection
4. **Include legends** - Explain notation
5. **Keep it simple** - 5-20 elements per diagram max

View File

@@ -19,6 +19,7 @@ Use this skill when:
**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
- **[logging](../logging/SKILL.md)** - MANDATORY logging in all Angular components using `@isa/core/logging`
## Overview

View File

@@ -1,5 +1,5 @@
---
name: logging-helper
name: logging
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.
---

View File

@@ -0,0 +1,346 @@
# Jest to Vitest Migration Patterns
## Overview
This reference provides syntax mappings and patterns for migrating tests from Jest (with Spectator) to Vitest (with Angular Testing Library).
## Configuration Migration
### Jest Config → Vitest Config
**Before (jest.config.ts):**
```typescript
export default {
displayName: 'my-lib',
preset: '../../jest.preset.js',
setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'],
coverageDirectory: '../../coverage/libs/my-lib',
transform: {
'^.+\\.(ts|mjs|js|html)$': 'jest-preset-angular',
},
transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'],
snapshotSerializers: [
'jest-preset-angular/build/serializers/no-ng-attributes',
'jest-preset-angular/build/serializers/ng-snapshot',
],
};
```
**After (vitest.config.ts):**
```typescript
import { defineConfig } from 'vitest/config';
import angular from '@analogjs/vite-plugin-angular';
export default defineConfig({
plugins: [angular()],
test: {
globals: true,
environment: 'jsdom',
setupFiles: ['src/test-setup.ts'],
include: ['**/*.spec.ts'],
reporters: ['default'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
},
},
});
```
### Test Setup Migration
**Before (test-setup.ts - Jest):**
```typescript
import 'jest-preset-angular/setup-jest';
```
**After (test-setup.ts - Vitest):**
```typescript
import '@analogjs/vitest-angular/setup-zone';
```
## Import Changes
### Test Function Imports
**Before (Jest):**
```typescript
import { describe, it, expect, beforeEach, afterEach } from '@jest/globals';
```
**After (Vitest):**
```typescript
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
```
### Mock Imports
**Before (Jest):**
```typescript
jest.fn()
jest.spyOn()
jest.mock()
jest.useFakeTimers()
```
**After (Vitest):**
```typescript
vi.fn()
vi.spyOn()
vi.mock()
vi.useFakeTimers()
```
## Mock Migration Patterns
### Function Mocks
**Before (Jest):**
```typescript
const mockFn = jest.fn();
const mockFnWithReturn = jest.fn().mockReturnValue('value');
const mockFnWithAsync = jest.fn().mockResolvedValue('async value');
```
**After (Vitest):**
```typescript
const mockFn = vi.fn();
const mockFnWithReturn = vi.fn().mockReturnValue('value');
const mockFnWithAsync = vi.fn().mockResolvedValue('async value');
```
### Spy Migration
**Before (Jest):**
```typescript
const spy = jest.spyOn(service, 'method');
spy.mockImplementation(() => 'mocked');
```
**After (Vitest):**
```typescript
const spy = vi.spyOn(service, 'method');
spy.mockImplementation(() => 'mocked');
```
### Module Mocks
**Before (Jest):**
```typescript
jest.mock('@isa/core/logging', () => ({
logger: jest.fn(() => ({
info: jest.fn(),
error: jest.fn(),
})),
}));
```
**After (Vitest):**
```typescript
vi.mock('@isa/core/logging', () => ({
logger: vi.fn(() => ({
info: vi.fn(),
error: vi.fn(),
})),
}));
```
## Spectator → Angular Testing Library
### Component Testing
**Before (Spectator):**
```typescript
import { createComponentFactory, Spectator } from '@ngneat/spectator/jest';
describe('MyComponent', () => {
let spectator: Spectator<MyComponent>;
const createComponent = createComponentFactory({
component: MyComponent,
imports: [CommonModule],
providers: [
{ provide: MyService, useValue: mockService },
],
});
beforeEach(() => {
spectator = createComponent();
});
it('should render title', () => {
expect(spectator.query('.title')).toHaveText('Hello');
});
it('should handle click', () => {
spectator.click('.button');
expect(mockService.doSomething).toHaveBeenCalled();
});
});
```
**After (Angular Testing Library):**
```typescript
import { render, screen, fireEvent } from '@testing-library/angular';
describe('MyComponent', () => {
it('should render title', async () => {
await render(MyComponent, {
imports: [CommonModule],
providers: [
{ provide: MyService, useValue: mockService },
],
});
expect(screen.getByText('Hello')).toBeInTheDocument();
});
it('should handle click', async () => {
await render(MyComponent, {
providers: [{ provide: MyService, useValue: mockService }],
});
fireEvent.click(screen.getByRole('button'));
expect(mockService.doSomething).toHaveBeenCalled();
});
});
```
### Query Selectors
| Spectator | Angular Testing Library |
|-----------|------------------------|
| `spectator.query('.class')` | `screen.getByTestId()` or `screen.getByRole()` |
| `spectator.queryAll('.class')` | `screen.getAllByRole()` |
| `spectator.query('button')` | `screen.getByRole('button')` |
| `spectator.query('[data-testid]')` | `screen.getByTestId()` |
### Events
| Spectator | Angular Testing Library |
|-----------|------------------------|
| `spectator.click(element)` | `fireEvent.click(element)` or `await userEvent.click(element)` |
| `spectator.typeInElement(value, element)` | `await userEvent.type(element, value)` |
| `spectator.blur(element)` | `fireEvent.blur(element)` |
| `spectator.focus(element)` | `fireEvent.focus(element)` |
### Service Testing
**Before (Spectator):**
```typescript
import { createServiceFactory, SpectatorService } from '@ngneat/spectator/jest';
describe('MyService', () => {
let spectator: SpectatorService<MyService>;
const createService = createServiceFactory({
service: MyService,
providers: [
{ provide: HttpClient, useValue: mockHttp },
],
});
beforeEach(() => {
spectator = createService();
});
it('should fetch data', () => {
spectator.service.getData().subscribe(data => {
expect(data).toEqual(expectedData);
});
});
});
```
**After (TestBed):**
```typescript
import { TestBed } from '@angular/core/testing';
describe('MyService', () => {
let service: MyService;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
MyService,
{ provide: HttpClient, useValue: mockHttp },
],
});
service = TestBed.inject(MyService);
});
it('should fetch data', () => {
service.getData().subscribe(data => {
expect(data).toEqual(expectedData);
});
});
});
```
## Async Testing
### Observable Testing
**Before (Jest):**
```typescript
it('should emit values', (done) => {
service.data$.subscribe({
next: (value) => {
expect(value).toBe(expected);
done();
},
});
});
```
**After (Vitest):**
```typescript
import { firstValueFrom } from 'rxjs';
it('should emit values', async () => {
const value = await firstValueFrom(service.data$);
expect(value).toBe(expected);
});
```
### Timer Mocks
**Before (Jest):**
```typescript
jest.useFakeTimers();
service.startTimer();
jest.advanceTimersByTime(1000);
expect(callback).toHaveBeenCalled();
jest.useRealTimers();
```
**After (Vitest):**
```typescript
vi.useFakeTimers();
service.startTimer();
vi.advanceTimersByTime(1000);
expect(callback).toHaveBeenCalled();
vi.useRealTimers();
```
## Common Matchers
| Jest | Vitest |
|------|--------|
| `expect(x).toBe(y)` | Same |
| `expect(x).toEqual(y)` | Same |
| `expect(x).toHaveBeenCalled()` | Same |
| `expect(x).toHaveBeenCalledWith(y)` | Same |
| `expect(x).toMatchSnapshot()` | `expect(x).toMatchSnapshot()` |
| `expect(x).toHaveText('text')` | `expect(x).toHaveTextContent('text')` (with jest-dom) |
## Migration Checklist
1. [ ] Update `vitest.config.ts`
2. [ ] Update `test-setup.ts`
3. [ ] Replace `jest.fn()` with `vi.fn()`
4. [ ] Replace `jest.spyOn()` with `vi.spyOn()`
5. [ ] Replace `jest.mock()` with `vi.mock()`
6. [ ] Replace Spectator with Angular Testing Library
7. [ ] Update queries to use accessible selectors
8. [ ] Update async patterns
9. [ ] Run tests and fix any remaining issues
10. [ ] Remove Jest dependencies from `package.json`

View File

@@ -0,0 +1,293 @@
# Zod Patterns Reference
## Overview
Zod is a TypeScript-first schema validation library. Use it for runtime validation at system boundaries (API responses, form inputs, external data).
## Basic Schema Patterns
### Primitive Types
```typescript
import { z } from 'zod';
// Basic types
const stringSchema = z.string();
const numberSchema = z.number();
const booleanSchema = z.boolean();
const dateSchema = z.date();
// With constraints
const emailSchema = z.string().email();
const positiveNumber = z.number().positive();
const nonEmptyString = z.string().min(1);
const optionalString = z.string().optional();
const nullableString = z.string().nullable();
```
### Object Schemas
```typescript
// Basic object
const userSchema = z.object({
id: z.string().uuid(),
email: z.string().email(),
name: z.string().min(1),
age: z.number().int().positive().optional(),
role: z.enum(['admin', 'user', 'guest']),
createdAt: z.string().datetime(),
});
// Infer TypeScript type from schema
type User = z.infer<typeof userSchema>;
// Partial and Pick utilities
const partialUser = userSchema.partial(); // All fields optional
const requiredUser = userSchema.required(); // All fields required
const pickedUser = userSchema.pick({ email: true, name: true });
const omittedUser = userSchema.omit({ createdAt: true });
```
### Array Schemas
```typescript
// Basic array
const stringArray = z.array(z.string());
// With constraints
const nonEmptyArray = z.array(z.string()).nonempty();
const limitedArray = z.array(z.number()).min(1).max(10);
// Tuple (fixed length, different types)
const coordinate = z.tuple([z.number(), z.number()]);
```
## API Response Validation
### Pattern: Validate API Responses
```typescript
// Define response schema
const apiResponseSchema = z.object({
data: z.object({
items: z.array(userSchema),
total: z.number(),
page: z.number(),
pageSize: z.number(),
}),
meta: z.object({
timestamp: z.string().datetime(),
requestId: z.string().uuid(),
}),
});
// In Angular service
@Injectable({ providedIn: 'root' })
export class UserService {
#http = inject(HttpClient);
#logger = logger({ component: 'UserService' });
getUsers(): Observable<User[]> {
return this.#http.get('/api/users').pipe(
map((response) => {
const result = apiResponseSchema.safeParse(response);
if (!result.success) {
this.#logger.error('Invalid API response', {
errors: result.error.errors
});
throw new Error('Invalid API response');
}
return result.data.data.items;
})
);
}
}
```
### Pattern: Coerce Types
```typescript
// API returns string IDs, coerce to number
const productSchema = z.object({
id: z.coerce.number(), // "123" -> 123
price: z.coerce.number(), // "99.99" -> 99.99
inStock: z.coerce.boolean(), // "true" -> true
createdAt: z.coerce.date(), // "2024-01-01" -> Date
});
```
## Form Validation
### Pattern: Form Schema
```typescript
// Define form schema with custom error messages
const loginFormSchema = z.object({
email: z.string()
.email({ message: 'Invalid email address' }),
password: z.string()
.min(8, { message: 'Password must be at least 8 characters' })
.regex(/[A-Z]/, { message: 'Must contain uppercase letter' })
.regex(/[0-9]/, { message: 'Must contain number' }),
rememberMe: z.boolean().default(false),
});
// Use with Angular forms
type LoginForm = z.infer<typeof loginFormSchema>;
```
### Pattern: Cross-field Validation
```typescript
const passwordFormSchema = z.object({
password: z.string().min(8),
confirmPassword: z.string(),
}).refine(
(data) => data.password === data.confirmPassword,
{
message: "Passwords don't match",
path: ['confirmPassword'], // Error path
}
);
```
## Type Guards
### Pattern: Create Type Guard from Schema
```typescript
// Schema
const customerSchema = z.object({
type: z.literal('customer'),
customerId: z.string(),
loyaltyPoints: z.number(),
});
// Type guard function
function isCustomer(value: unknown): value is z.infer<typeof customerSchema> {
return customerSchema.safeParse(value).success;
}
// Usage
if (isCustomer(data)) {
console.log(data.loyaltyPoints); // Type-safe access
}
```
### Pattern: Discriminated Unions
```typescript
const customerSchema = z.object({
type: z.literal('customer'),
customerId: z.string(),
});
const guestSchema = z.object({
type: z.literal('guest'),
sessionId: z.string(),
});
const userSchema = z.discriminatedUnion('type', [
customerSchema,
guestSchema,
]);
type User = z.infer<typeof userSchema>;
// User = { type: 'customer'; customerId: string } | { type: 'guest'; sessionId: string }
```
## Replacing `any` Types
### Before (unsafe)
```typescript
function processOrder(order: any) {
// No type safety
console.log(order.items.length);
console.log(order.customer.name);
}
```
### After (with Zod)
```typescript
const orderSchema = z.object({
id: z.string().uuid(),
items: z.array(z.object({
productId: z.string(),
quantity: z.number().positive(),
price: z.number().nonnegative(),
})),
customer: z.object({
name: z.string(),
email: z.string().email(),
}),
status: z.enum(['pending', 'confirmed', 'shipped', 'delivered']),
});
type Order = z.infer<typeof orderSchema>;
function processOrder(input: unknown): Order {
const order = orderSchema.parse(input); // Throws if invalid
console.log(order.items.length); // Type-safe
console.log(order.customer.name); // Type-safe
return order;
}
```
## Error Handling
### Pattern: Structured Error Handling
```typescript
const result = schema.safeParse(data);
if (!result.success) {
// Access formatted errors
const formatted = result.error.format();
// Access flat error list
const flat = result.error.flatten();
// Custom error mapping
const errors = result.error.errors.map(err => ({
field: err.path.join('.'),
message: err.message,
code: err.code,
}));
}
```
## Transform Patterns
### Pattern: Transform on Parse
```typescript
const userInputSchema = z.object({
email: z.string().email().transform(s => s.toLowerCase()),
name: z.string().transform(s => s.trim()),
tags: z.string().transform(s => s.split(',')),
});
// Input: { email: "USER@EXAMPLE.COM", name: " John ", tags: "a,b,c" }
// Output: { email: "user@example.com", name: "John", tags: ["a", "b", "c"] }
```
### Pattern: Default Values
```typescript
const configSchema = z.object({
theme: z.enum(['light', 'dark']).default('light'),
pageSize: z.number().default(20),
features: z.array(z.string()).default([]),
});
```
## Best Practices
1. **Define schemas at module boundaries** - API services, form handlers
2. **Use `safeParse` for error handling** - Don't let validation throw unexpectedly
3. **Infer types from schemas** - Single source of truth
4. **Add meaningful error messages** - Help debugging and user feedback
5. **Use transforms for normalization** - Clean data on parse
6. **Keep schemas close to usage** - Colocate with services/components

1015
CLAUDE.md
View File

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,4 @@
import { isDevMode, NgModule } from '@angular/core';
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import {
CanActivateCartGuard,
@@ -7,23 +7,17 @@ import {
CanActivateCustomerOrdersGuard,
CanActivateCustomerOrdersWithProcessIdGuard,
CanActivateCustomerWithProcessIdGuard,
CanActivateGoodsInGuard,
CanActivateProductGuard,
CanActivateProductWithProcessIdGuard,
CanActivateTaskCalendarGuard,
IsAuthenticatedGuard,
} from './guards';
import { CanActivateAssortmentGuard } from './guards/can-activate-assortment.guard';
import { CanActivatePackageInspectionGuard } from './guards/can-activate-package-inspection.guard';
import { MainComponent } from './main.component';
import { PreviewComponent } from './preview';
import {
BranchSectionResolver,
CustomerSectionResolver,
ProcessIdResolver,
} from './resolvers';
import { TokenLoginComponent, TokenLoginModule } from './token-login';
import { ProcessIdGuard } from './guards/process-id.guard';
import {
ActivateProcessIdGuard,
ActivateProcessIdWithConfigKeyGuard,
@@ -74,8 +68,12 @@ const routes: Routes = [
loadChildren: () =>
import('@page/catalog').then((m) => m.PageCatalogModule),
canActivate: [CanActivateProductWithProcessIdGuard],
resolve: { processId: ProcessIdResolver },
resolve: {
processId: ProcessIdResolver,
},
},
// TODO: Check if order and :processId/order is still being used
// If not, remove these routes and the related guards and resolvers
{
path: 'order',
loadChildren: () =>
@@ -87,7 +85,9 @@ const routes: Routes = [
loadChildren: () =>
import('@page/customer-order').then((m) => m.CustomerOrderModule),
canActivate: [CanActivateCustomerOrdersWithProcessIdGuard],
resolve: { processId: ProcessIdResolver },
resolve: {
processId: ProcessIdResolver,
},
},
{
path: 'customer',
@@ -100,7 +100,9 @@ const routes: Routes = [
loadChildren: () =>
import('@page/customer').then((m) => m.CustomerModule),
canActivate: [CanActivateCustomerWithProcessIdGuard],
resolve: { processId: ProcessIdResolver },
resolve: {
processId: ProcessIdResolver,
},
},
{
path: 'cart',
@@ -113,10 +115,13 @@ const routes: Routes = [
loadChildren: () =>
import('@page/checkout').then((m) => m.PageCheckoutModule),
canActivate: [CanActivateCartWithProcessIdGuard],
resolve: {
processId: ProcessIdResolver,
},
},
{
path: 'pickup-shelf',
canActivate: [ProcessIdGuard],
canActivate: [ActivateProcessIdGuard],
// NOTE: This is a workaround for the canActivate guard not being called
loadChildren: () =>
import('@page/pickup-shelf').then((m) => m.PickupShelfOutModule),
@@ -126,6 +131,9 @@ const routes: Routes = [
canActivate: [ActivateProcessIdGuard],
loadChildren: () =>
import('@page/pickup-shelf').then((m) => m.PickupShelfOutModule),
resolve: {
processId: ProcessIdResolver,
},
},
{ path: '**', redirectTo: 'dashboard', pathMatch: 'full' },
],
@@ -141,7 +149,7 @@ const routes: Routes = [
import('@page/task-calendar').then(
(m) => m.PageTaskCalendarModule,
),
canActivate: [CanActivateTaskCalendarGuard],
canActivate: [ActivateProcessIdWithConfigKeyGuard('taskCalendar')],
},
{
path: 'pickup-shelf',
@@ -154,27 +162,23 @@ const routes: Routes = [
path: 'goods/in',
loadChildren: () =>
import('@page/goods-in').then((m) => m.GoodsInModule),
canActivate: [CanActivateGoodsInGuard],
canActivate: [ActivateProcessIdWithConfigKeyGuard('goodsIn')],
},
// {
// path: 'remission',
// loadChildren: () =>
// import('@page/remission').then((m) => m.PageRemissionModule),
// canActivate: [CanActivateRemissionGuard],
// },
{
path: 'package-inspection',
loadChildren: () =>
import('@page/package-inspection').then(
(m) => m.PackageInspectionModule,
),
canActivate: [CanActivatePackageInspectionGuard],
canActivate: [
ActivateProcessIdWithConfigKeyGuard('packageInspection'),
],
},
{
path: 'assortment',
loadChildren: () =>
import('@page/assortment').then((m) => m.AssortmentModule),
canActivate: [CanActivateAssortmentGuard],
canActivate: [ActivateProcessIdWithConfigKeyGuard('assortment')],
},
{ path: '**', redirectTo: 'task-calendar', pathMatch: 'full' },
],
@@ -243,13 +247,6 @@ const routes: Routes = [
},
];
if (isDevMode()) {
routes.unshift({
path: 'preview',
component: PreviewComponent,
});
}
@NgModule({
imports: [
RouterModule.forRoot(routes, {

View File

@@ -1,4 +1,5 @@
import { version } from '../../../../package.json';
import { IsaTitleStrategy } from '@isa/common/title-management';
import {
HTTP_INTERCEPTORS,
provideHttpClient,
@@ -56,7 +57,6 @@ import {
ScanditScanAdapterModule,
} from '@adapter/scan';
import * as Commands from './commands';
import { PreviewComponent } from './preview';
import { NativeContainerService } from '@external/native-container';
import { ShellModule } from '@shared/shell';
import { MainComponent } from './main.component';
@@ -87,6 +87,7 @@ import {
import { Store } from '@ngrx/store';
import { OAuthService } from 'angular-oauth2-oidc';
import z from 'zod';
import { TitleStrategy } from '@angular/router';
import { TabNavigationService } from '@isa/core/tabs';
registerLocaleData(localeDe, localeDeExtra);
@@ -270,7 +271,6 @@ const USER_SUB_FACTORY = () => {
CoreCommandModule.forRoot(Object.values(Commands)),
CoreLoggerModule.forRoot(),
AppStoreModule,
PreviewComponent,
AuthModule.forRoot(),
CoreApplicationModule.forRoot(),
UiModalModule.forRoot(),
@@ -330,6 +330,7 @@ const USER_SUB_FACTORY = () => {
useValue: 'EUR',
},
provideUserSubFactory(USER_SUB_FACTORY),
{ provide: TitleStrategy, useClass: IsaTitleStrategy },
],
})
export class AppModule {}

View File

@@ -1,47 +1,82 @@
import { inject } from '@angular/core';
import { ActivatedRouteSnapshot, CanActivateFn, RouterStateSnapshot } from '@angular/router';
import { ApplicationService } from '@core/application';
import { Config } from '@core/config';
import { take } from 'rxjs/operators';
export const ActivateProcessIdGuard: CanActivateFn = async (
route: ActivatedRouteSnapshot,
state: RouterStateSnapshot,
) => {
const application = inject(ApplicationService);
const processIdStr = route.params.processId;
if (!processIdStr) {
return false;
}
const processId = Number(processIdStr);
// Check if Process already exists
const process = await application.getProcessById$(processId).pipe(take(1)).toPromise();
if (!process) {
application.createCustomerProcess(processId);
}
application.activateProcess(processId);
return true;
};
export const ActivateProcessIdWithConfigKeyGuard: (key: string) => CanActivateFn =
(key) => async (route: ActivatedRouteSnapshot, state: RouterStateSnapshot) => {
const application = inject(ApplicationService);
const config = inject(Config);
const processId = config.get(`process.ids.${key}`);
if (isNaN(processId)) {
return false;
}
application.activateProcess(processId);
return true;
};
import { inject } from '@angular/core';
import {
ActivatedRouteSnapshot,
CanActivateFn,
RouterStateSnapshot,
} from '@angular/router';
import { ApplicationService } from '@core/application';
import { Config } from '@core/config';
import { take } from 'rxjs/operators';
import z from 'zod';
export const ActivateProcessIdGuard: CanActivateFn = async (
route: ActivatedRouteSnapshot,
state: RouterStateSnapshot,
) => {
const application = inject(ApplicationService);
const processIdStr = route.params.processId;
if (!processIdStr) {
return false;
}
const processId = Number(processIdStr);
// Check if Process already exists
const process = await application
.getProcessById$(processId)
.pipe(take(1))
.toPromise();
if (!process) {
application.createProcess({
id: processId,
type: 'cart',
section: 'customer',
name: `Vorgang ${processId}`,
});
}
application.activateProcess(processId);
return true;
};
export const ActivateProcessIdWithConfigKeyGuard: (
key: string,
) => CanActivateFn =
(key) =>
async (route: ActivatedRouteSnapshot, state: RouterStateSnapshot) => {
const application = inject(ApplicationService);
const config = inject(Config);
const processId = config.get(`process.ids.${key}`, z.coerce.number());
if (typeof processId !== 'number' || isNaN(processId)) {
return false;
}
const processTitle = config.get(
`process.titles.${key}`,
z.string().default(key),
);
const process = await application
.getProcessById$(processId)
.pipe(take(1))
.toPromise();
if (!process) {
await application.createProcess({
id: processId,
type: key,
section: 'customer', // Not important anymore
name: processTitle,
});
}
application.activateProcess(processId);
return true;
};

View File

@@ -1,34 +1,34 @@
import { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router';
import { ApplicationService } from '@core/application';
import { Config } from '@core/config';
import { first } from 'rxjs/operators';
import { z } from 'zod';
@Injectable({ providedIn: 'root' })
export class CanActivateGoodsInGuard {
constructor(
private readonly _applicationService: ApplicationService,
private readonly _config: Config,
) {}
async canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot) {
const pid = this._config.get('process.ids.goodsIn', z.number());
const process = await this._applicationService
.getProcessById$(pid)
.pipe(first())
.toPromise();
if (!process) {
await this._applicationService.createProcess({
id: this._config.get('process.ids.goodsIn'),
type: 'goods-in',
section: 'branch',
name: '',
});
}
this._applicationService.activateProcess(
this._config.get('process.ids.goodsIn'),
);
return true;
}
}
import { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router';
import { ApplicationService } from '@core/application';
import { Config } from '@core/config';
import { first } from 'rxjs/operators';
import { z } from 'zod';
@Injectable({ providedIn: 'root' })
export class CanActivateGoodsInGuard {
constructor(
private readonly _applicationService: ApplicationService,
private readonly _config: Config,
) {}
async canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot) {
const pid = this._config.get('process.ids.goodsIn', z.number());
const process = await this._applicationService
.getProcessById$(pid)
.pipe(first())
.toPromise();
if (!process) {
await this._applicationService.createProcess({
id: this._config.get('process.ids.goodsIn'),
type: 'goods-in',
section: 'branch',
name: 'Abholfach',
});
}
this._applicationService.activateProcess(
this._config.get('process.ids.goodsIn'),
);
return true;
}
}

View File

@@ -1,3 +0,0 @@
// start:ng42.barrel
export * from './preview.component';
// end:ng42.barrel

View File

@@ -1,3 +0,0 @@
:host {
@apply grid min-h-screen content-center justify-center;
}

View File

@@ -1,10 +0,0 @@
<h1>Platform: {{ platform | json }}</h1>
<br />
<h1>{{ appVersion }}</h1>
<br />
<h1>{{ userAgent }}</h1>
<br />
<h1>Navigator: {{ navigator | json }}</h1>
<br />
<br />
<h1>Device: {{ device }}</h1>

View File

@@ -1,56 +0,0 @@
import { Platform, PlatformModule } from '@angular/cdk/platform';
import { CommonModule } from '@angular/common';
import { Component } from '@angular/core';
import { BranchDTO } from '@generated/swagger/checkout-api';
import { BehaviorSubject } from 'rxjs';
@Component({
selector: 'app-preview',
templateUrl: 'preview.component.html',
styleUrls: ['preview.component.css'],
imports: [CommonModule, PlatformModule],
})
export class PreviewComponent {
selectedBranch$ = new BehaviorSubject<BranchDTO>({});
get appVersion() {
return 'App Version: ' + (window.navigator as any).appVersion;
}
get userAgent() {
return 'User Agent: ' + (window.navigator as any).userAgent;
}
get navigator() {
const nav = {};
for (const i in window.navigator) nav[i] = navigator[i];
return nav;
}
get platform() {
return this._platform;
}
get device() {
const isIpadNative = this._platform.IOS && !this._platform.SAFARI;
const isIpadMini6Native = window?.navigator?.userAgent?.includes('Macintosh') && !this._platform.SAFARI;
const isNative = isIpadNative || isIpadMini6Native;
const isPWA = this._platform.IOS && this._platform.SAFARI;
const isDesktop = !isNative && !isPWA;
if (isNative) {
if (isIpadMini6Native) {
return 'IPAD mini 6 Native App';
} else if (isIpadNative) {
return 'IPAD mini 2 Native App or IPAD mini 5 Native App';
}
} else if (isPWA) {
return 'IPAD Safari PWA';
} else if (isDesktop) return 'Desktop or Macintosh';
}
constructor(private readonly _platform: Platform) {}
setNewBranch(branch: BranchDTO) {
this.selectedBranch$.next(branch);
}
}

View File

@@ -1,4 +1,2 @@
// start:ng42.barrel
export * from './process-id.resolver';
export * from './section.resolver';
// end:ng42.barrel
export * from './process-id.resolver';
export * from './section.resolver';

View File

@@ -1,87 +1,86 @@
{
"title": "ISA - Local",
"silentRefresh": {
"interval": 300000
},
"debug": true,
"dev-scanner": true,
"@cdn/product-image": {
"url": "https://produktbilder.paragon-data.net"
},
"@core/auth": {
"issuer": "https://sso-test.paragon-data.de",
"clientId": "isa-client",
"responseType": "code",
"scope": "openid profile cmf_user isa-isa-webapi isa-checkout-webapi isa-cat-webapi isa-ava-webapi isa-crm-webapi isa-review-webapi isa-kpi-webapi isa-oms-webapi isa-nbo-webapi isa-print-webapi eis-service isa-inv-webapi isa-wws-webapi",
"showDebugInformation": true
},
"@core/logger": {
"logLevel": "debug"
},
"@swagger/isa": {
"rootUrl": "https://isa-test.paragon-data.net/isa/v1"
},
"@swagger/cat": {
"rootUrl": "https://isa-test.paragon-data.net/catsearch/v6"
},
"@swagger/av": {
"rootUrl": "https://isa-test.paragon-data.net/ava/v6"
},
"@swagger/checkout": {
"rootUrl": "https://isa-test.paragon-data.net/checkout/v6"
},
"@swagger/crm": {
"rootUrl": "https://isa-test.paragon-data.net/crm/v6"
},
"@swagger/oms": {
"rootUrl": "https://isa-test.paragon-data.net/oms/v6"
},
"@swagger/print": {
"rootUrl": "https://isa-test.paragon-data.net/print/v1"
},
"@swagger/eis": {
"rootUrl": "https://filialinformationsystem-test.paragon-systems.de/eiswebapi/v1"
},
"@swagger/remi": {
"rootUrl": "https://isa-test.paragon-data.net/inv/v6"
},
"@swagger/wws": {
"rootUrl": "https://isa-test.paragon-data.net/wws/v1"
},
"@domain/checkout": {
"olaExpiration": "5m"
},
"hubs": {
"notifications": {
"url": "https://isa-test.paragon-data.net/isa/v1/rt",
"enableAutomaticReconnect": false,
"httpOptions": {
"transport": 1,
"logMessageContent": true,
"skipNegotiation": true
}
}
},
"process": {
"ids": {
"goodsOut": 1000,
"goodsIn": 2000,
"taskCalendar": 3000,
"remission": 4000,
"packageInspection": 5000,
"assortment": 6000,
"pickupShelf": 7000
}
},
"checkForUpdates": 3600000,
"licence": {
"scandit": "Ae8F2Wx2RMq5Lvn7UUAlWzVFZTt2+ubMAF8XtDpmPlNkBeG/LWs1M7AbgDW0LQqYLnszClEENaEHS56/6Ts2vrJ1Ux03CXUjK3jUvZpF5OchXR1CpnmpepJ6WxPCd7LMVHUGG1BbwPLDTFjP3y8uT0caTSmmGrYQWAs4CZcEF+ZBabP0z7vfm+hCZF/ebj9qqCJZcW8nH/n19hohshllzYBjFXjh87P2lIh1s6yZS3OaQWWXo/o0AKdxx7T6CVyR0/G5zq6uYJWf6rs3euUBEhpzOZHbHZK86Lvy2AVBEyVkkcttlDW1J2fA4l1W1JV/Xibz8AQV6kG482EpGF42KEoK48paZgX3e1AQsqUtmqzw294dcP4zMVstnw5/WrwKKi/5E/nOOJT2txYP1ZufIjPrwNFsqTlv7xCQlHjMzFGYwT816yD5qLRLbwOtjrkUPXNZLZ06T4upvWwJDmm8XgdeoDqMjHdcO4lwji1bl9EiIYJ/2qnsk9yZ2FqSaHzn4cbiL0f5u2HFlNAP0GUujGRlthGhHi6o4dFU+WAxKsFMKVt+SfoQUazNKHFVQgiAklTIZxIc/HUVzRvOLMxf+wFDerraBtcqGJg+g/5mrWYqeDBGhCBHtKiYf6244IJ4afzNTiH1/30SJcRzXwbEa3A7q1fJTx9/nLTOfVPrJKBQs7f/OQs2dA7LDCel8mzXdbjvsNQaeU5+iCIAq6zbTNKy1xT8wwj+VZrQmtNJs+qeznD+u29nCM24h8xCmRpvNPo4/Mww/lrTNrrNwLBSn1pMIwsH7yS9hH0v0oNAM3A6bVtk1D9qEkbyw+xZa+MZGpMP0D0CdcsqHalPcm5r/Ik="
},
"gender": {
"0": "Keine Anrede",
"1": "Enby",
"2": "Herr",
"4": "Frau"
},
"@shared/icon": "/assets/icons.json"
}
{
"title": "ISA - Local",
"silentRefresh": {
"interval": 300000
},
"debug": true,
"dev-scanner": true,
"@cdn/product-image": {
"url": "https://produktbilder.paragon-data.net"
},
"@core/auth": {
"issuer": "https://sso-test.paragon-data.de",
"clientId": "isa-client",
"responseType": "code",
"scope": "openid profile cmf_user isa-isa-webapi isa-checkout-webapi isa-cat-webapi isa-ava-webapi isa-crm-webapi isa-review-webapi isa-kpi-webapi isa-oms-webapi isa-nbo-webapi isa-print-webapi eis-service isa-inv-webapi isa-wws-webapi",
"showDebugInformation": true
},
"@core/logger": {
"logLevel": "debug"
},
"@swagger/isa": {
"rootUrl": "https://isa-test.paragon-data.net/isa/v1"
},
"@swagger/cat": {
"rootUrl": "https://isa-test.paragon-data.net/catsearch/v6"
},
"@swagger/av": {
"rootUrl": "https://isa-test.paragon-data.net/ava/v6"
},
"@swagger/checkout": {
"rootUrl": "https://isa-test.paragon-data.net/checkout/v6"
},
"@swagger/crm": {
"rootUrl": "https://isa-test.paragon-data.net/crm/v6"
},
"@swagger/oms": {
"rootUrl": "https://isa-test.paragon-data.net/oms/v6"
},
"@swagger/print": {
"rootUrl": "https://isa-test.paragon-data.net/print/v1"
},
"@swagger/eis": {
"rootUrl": "https://filialinformationsystem-test.paragon-systems.de/eiswebapi/v1"
},
"@swagger/remi": {
"rootUrl": "https://isa-test.paragon-data.net/inv/v6"
},
"@swagger/wws": {
"rootUrl": "https://isa-test.paragon-data.net/wws/v1"
},
"@domain/checkout": {
"olaExpiration": "5m"
},
"hubs": {
"notifications": {
"url": "https://isa-test.paragon-data.net/isa/v1/rt",
"enableAutomaticReconnect": false,
"httpOptions": {
"transport": 1,
"logMessageContent": true,
"skipNegotiation": true
}
}
},
"process": {
"ids": {
"goodsOut": 1000,
"goodsIn": 2000,
"taskCalendar": 3000,
"packageInspection": 5000,
"assortment": 6000,
"pickupShelf": 2000
}
},
"checkForUpdates": 3600000,
"licence": {
"scandit": "Ae8F2Wx2RMq5Lvn7UUAlWzVFZTt2+ubMAF8XtDpmPlNkBeG/LWs1M7AbgDW0LQqYLnszClEENaEHS56/6Ts2vrJ1Ux03CXUjK3jUvZpF5OchXR1CpnmpepJ6WxPCd7LMVHUGG1BbwPLDTFjP3y8uT0caTSmmGrYQWAs4CZcEF+ZBabP0z7vfm+hCZF/ebj9qqCJZcW8nH/n19hohshllzYBjFXjh87P2lIh1s6yZS3OaQWWXo/o0AKdxx7T6CVyR0/G5zq6uYJWf6rs3euUBEhpzOZHbHZK86Lvy2AVBEyVkkcttlDW1J2fA4l1W1JV/Xibz8AQV6kG482EpGF42KEoK48paZgX3e1AQsqUtmqzw294dcP4zMVstnw5/WrwKKi/5E/nOOJT2txYP1ZufIjPrwNFsqTlv7xCQlHjMzFGYwT816yD5qLRLbwOtjrkUPXNZLZ06T4upvWwJDmm8XgdeoDqMjHdcO4lwji1bl9EiIYJ/2qnsk9yZ2FqSaHzn4cbiL0f5u2HFlNAP0GUujGRlthGhHi6o4dFU+WAxKsFMKVt+SfoQUazNKHFVQgiAklTIZxIc/HUVzRvOLMxf+wFDerraBtcqGJg+g/5mrWYqeDBGhCBHtKiYf6244IJ4afzNTiH1/30SJcRzXwbEa3A7q1fJTx9/nLTOfVPrJKBQs7f/OQs2dA7LDCel8mzXdbjvsNQaeU5+iCIAq6zbTNKy1xT8wwj+VZrQmtNJs+qeznD+u29nCM24h8xCmRpvNPo4/Mww/lrTNrrNwLBSn1pMIwsH7yS9hH0v0oNAM3A6bVtk1D9qEkbyw+xZa+MZGpMP0D0CdcsqHalPcm5r/Ik="
},
"gender": {
"0": "Keine Anrede",
"1": "Enby",
"2": "Herr",
"4": "Frau"
},
"@shared/icon": "/assets/icons.json"
}

View File

@@ -1,174 +1,196 @@
import { Injectable } from '@angular/core';
import { Store } from '@ngrx/store';
import { BranchDTO } from '@generated/swagger/checkout-api';
import { isBoolean, isNumber } from '@utils/common';
import { BehaviorSubject, Observable } from 'rxjs';
import { first, map, switchMap } from 'rxjs/operators';
import { ApplicationProcess } from './defs';
import {
removeProcess,
selectSection,
selectProcesses,
setSection,
addProcess,
setActivatedProcess,
selectActivatedProcess,
patchProcess,
patchProcessData,
selectTitle,
setTitle,
} from './store';
@Injectable()
export class ApplicationService {
private activatedProcessIdSubject = new BehaviorSubject<number>(undefined);
get activatedProcessId() {
return this.activatedProcessIdSubject.value;
}
get activatedProcessId$() {
return this.activatedProcessIdSubject.asObservable();
}
constructor(private store: Store) {}
getProcesses$(section?: 'customer' | 'branch') {
const processes$ = this.store.select(selectProcesses);
return processes$.pipe(
map((processes) => processes.filter((process) => (section ? process.section === section : true))),
);
}
getProcessById$(processId: number): Observable<ApplicationProcess> {
return this.getProcesses$().pipe(map((processes) => processes.find((process) => process.id === processId)));
}
getSection$() {
return this.store.select(selectSection);
}
getTitle$() {
return this.getSection$().pipe(
map((section) => {
return section === 'customer' ? 'Kundenbereich' : 'Filialbereich';
}),
);
}
/** @deprecated */
getActivatedProcessId$() {
return this.store.select(selectActivatedProcess).pipe(map((process) => process?.id));
}
activateProcess(activatedProcessId: number) {
this.store.dispatch(setActivatedProcess({ activatedProcessId }));
this.activatedProcessIdSubject.next(activatedProcessId);
}
removeProcess(processId: number) {
this.store.dispatch(removeProcess({ processId }));
}
patchProcess(processId: number, changes: Partial<ApplicationProcess>) {
this.store.dispatch(patchProcess({ processId, changes }));
}
patchProcessData(processId: number, data: Record<string, any>) {
this.store.dispatch(patchProcessData({ processId, data }));
}
getSelectedBranch$(processId?: number): Observable<BranchDTO> {
if (!processId) {
return this.activatedProcessId$.pipe(
switchMap((processId) => this.getProcessById$(processId).pipe(map((process) => process?.data?.selectedBranch))),
);
}
return this.getProcessById$(processId).pipe(map((process) => process?.data?.selectedBranch));
}
readonly REGEX_PROCESS_NAME = /^Vorgang \d+$/;
async createCustomerProcess(processId?: number): Promise<ApplicationProcess> {
const processes = await this.getProcesses$('customer').pipe(first()).toPromise();
const processIds = processes.filter((x) => this.REGEX_PROCESS_NAME.test(x.name)).map((x) => +x.name.split(' ')[1]);
const maxId = processIds.length > 0 ? Math.max(...processIds) : 0;
const process: ApplicationProcess = {
id: processId ?? Date.now(),
type: 'cart',
name: `Vorgang ${maxId + 1}`,
section: 'customer',
closeable: true,
};
await this.createProcess(process);
return process;
}
async createProcess(process: ApplicationProcess) {
const existingProcess = await this.getProcessById$(process?.id).pipe(first()).toPromise();
if (existingProcess?.id === process?.id) {
throw new Error('Process Id existiert bereits');
}
if (!isNumber(process.id)) {
throw new Error('Process Id nicht gesetzt');
}
if (!isBoolean(process.closeable)) {
process.closeable = true;
}
if (!isBoolean(process.confirmClosing)) {
process.confirmClosing = true;
}
process.created = this._createTimestamp();
process.activated = 0;
this.store.dispatch(addProcess({ process }));
}
setSection(section: 'customer' | 'branch') {
this.store.dispatch(setSection({ section }));
}
getLastActivatedProcessWithSectionAndType$(
section: 'customer' | 'branch',
type: string,
): Observable<ApplicationProcess> {
return this.getProcesses$(section).pipe(
map((processes) =>
processes
?.filter((process) => process.type === type)
?.reduce((latest, current) => {
if (!latest) {
return current;
}
return latest?.activated > current?.activated ? latest : current;
}, undefined),
),
);
}
getLastActivatedProcessWithSection$(section: 'customer' | 'branch'): Observable<ApplicationProcess> {
return this.getProcesses$(section).pipe(
map((processes) =>
processes?.reduce((latest, current) => {
if (!latest) {
return current;
}
return latest?.activated > current?.activated ? latest : current;
}, undefined),
),
);
}
private _createTimestamp() {
return Date.now();
}
}
import { Injectable } from '@angular/core';
import { Store } from '@ngrx/store';
import { BranchDTO } from '@generated/swagger/checkout-api';
import { isBoolean, isNumber } from '@utils/common';
import { BehaviorSubject, Observable } from 'rxjs';
import { first, map, switchMap } from 'rxjs/operators';
import { ApplicationProcess } from './defs';
import {
removeProcess,
selectSection,
selectProcesses,
setSection,
addProcess,
setActivatedProcess,
selectActivatedProcess,
patchProcess,
patchProcessData,
selectTitle,
setTitle,
} from './store';
@Injectable()
export class ApplicationService {
private activatedProcessIdSubject = new BehaviorSubject<number>(undefined);
get activatedProcessId() {
return this.activatedProcessIdSubject.value;
}
get activatedProcessId$() {
return this.activatedProcessIdSubject.asObservable();
}
constructor(private store: Store) {}
getProcesses$(section?: 'customer' | 'branch') {
const processes$ = this.store.select(selectProcesses);
return processes$.pipe(
map((processes) =>
processes.filter((process) =>
section ? process.section === section : true,
),
),
);
}
getProcessById$(processId: number): Observable<ApplicationProcess> {
return this.getProcesses$().pipe(
map((processes) => processes.find((process) => process.id === processId)),
);
}
getSection$() {
return this.store.select(selectSection);
}
getTitle$() {
return this.getSection$().pipe(
map((section) => {
return section === 'customer' ? 'Kundenbereich' : 'Filialbereich';
}),
);
}
/** @deprecated */
getActivatedProcessId$() {
return this.store
.select(selectActivatedProcess)
.pipe(map((process) => process?.id));
}
activateProcess(activatedProcessId: number) {
this.store.dispatch(setActivatedProcess({ activatedProcessId }));
this.activatedProcessIdSubject.next(activatedProcessId);
}
removeProcess(processId: number) {
this.store.dispatch(removeProcess({ processId }));
}
patchProcess(processId: number, changes: Partial<ApplicationProcess>) {
this.store.dispatch(patchProcess({ processId, changes }));
}
patchProcessData(processId: number, data: Record<string, any>) {
this.store.dispatch(patchProcessData({ processId, data }));
}
getSelectedBranch$(processId?: number): Observable<BranchDTO> {
if (!processId) {
return this.activatedProcessId$.pipe(
switchMap((processId) =>
this.getProcessById$(processId).pipe(
map((process) => process?.data?.selectedBranch),
),
),
);
}
return this.getProcessById$(processId).pipe(
map((process) => process?.data?.selectedBranch),
);
}
readonly REGEX_PROCESS_NAME = /^Vorgang \d+$/;
async createCustomerProcess(processId?: number): Promise<ApplicationProcess> {
const processes = await this.getProcesses$('customer')
.pipe(first())
.toPromise();
const processIds = processes
.filter((x) => this.REGEX_PROCESS_NAME.test(x.name))
.map((x) => +x.name.split(' ')[1]);
const maxId = processIds.length > 0 ? Math.max(...processIds) : 0;
const process: ApplicationProcess = {
id: processId ?? Date.now(),
type: 'cart',
name: `Vorgang ${maxId + 1}`,
section: 'customer',
closeable: true,
};
await this.createProcess(process);
return process;
}
async createProcess(process: ApplicationProcess) {
const existingProcess = await this.getProcessById$(process?.id)
.pipe(first())
.toPromise();
if (existingProcess?.id === process?.id) {
throw new Error('Process Id existiert bereits');
}
if (!isNumber(process.id)) {
throw new Error('Process Id nicht gesetzt');
}
if (!isBoolean(process.closeable)) {
process.closeable = true;
}
if (!isBoolean(process.confirmClosing)) {
process.confirmClosing = true;
}
process.created = this._createTimestamp();
process.activated = 0;
this.store.dispatch(addProcess({ process }));
}
setSection(section: 'customer' | 'branch') {
this.store.dispatch(setSection({ section }));
}
getLastActivatedProcessWithSectionAndType$(
section: 'customer' | 'branch',
type: string,
): Observable<ApplicationProcess> {
return this.getProcesses$(section).pipe(
map((processes) =>
processes
?.filter((process) => process.type === type)
?.reduce((latest, current) => {
if (!latest) {
return current;
}
return latest?.activated > current?.activated ? latest : current;
}, undefined),
),
);
}
getLastActivatedProcessWithSection$(
section: 'customer' | 'branch',
): Observable<ApplicationProcess> {
return this.getProcesses$(section).pipe(
map((processes) =>
processes?.reduce((latest, current) => {
if (!latest) {
return current;
}
return latest?.activated > current?.activated ? latest : current;
}, undefined),
),
);
}
private _createTimestamp() {
return Date.now();
}
}

View File

@@ -1251,7 +1251,12 @@ export class DomainCheckoutService {
await this.updateItemInShoppingCart({
processId,
update: { availability },
update: {
availability: {
...availability,
price: item?.availability?.price ?? availability?.price,
}, // #5488 After Refreshing Availabilities in Cart make sure to keep the original selected price from purchasing options modal
},
shoppingCartItemId: item.id,
}).toPromise();

View File

@@ -35,7 +35,7 @@ const _domainCheckoutReducer = createReducer(
const now = Date.now();
for (let shoppingCartItem of addedShoppingCartItems) {
for (const shoppingCartItem of addedShoppingCartItems) {
if (shoppingCartItem.features?.orderType) {
entity.itemAvailabilityTimestamp[
`${shoppingCartItem.id}_${shoppingCartItem.features.orderType}`
@@ -275,7 +275,7 @@ function getOrCreateCheckoutEntity({
entities: Dictionary<CheckoutEntity>;
processId: number;
}): CheckoutEntity {
let entity = entities[processId];
const entity = entities[processId];
if (isNullOrUndefined(entity)) {
return {

View File

@@ -12,6 +12,7 @@ import {
} from '@isa/crm/data-access';
import { TabService } from '@isa/core/tabs';
import { BranchDTO } from '@generated/swagger/checkout-api';
import { CheckoutMetadataService } from '@isa/checkout/data-access';
/**
* Service for opening and managing the Purchase Options Modal.
@@ -39,6 +40,7 @@ export class PurchaseOptionsModalService {
#uiModal = inject(UiModalService);
#tabService = inject(TabService);
#crmTabMetadataService = inject(CrmTabMetadataService);
#checkoutMetadataService = inject(CheckoutMetadataService);
#customerFacade = inject(CustomerFacade);
/**
@@ -74,7 +76,10 @@ export class PurchaseOptionsModalService {
};
context.selectedCustomer = await this.#getSelectedCustomer(data);
context.selectedBranch = this.#getSelectedBranch(data.tabId);
context.selectedBranch = this.#getSelectedBranch(
data.tabId,
data.useRedemptionPoints,
);
return this.#uiModal.open<string, PurchaseOptionsModalContext>({
content: PurchaseOptionsModalComponent,
data: context,
@@ -95,7 +100,10 @@ export class PurchaseOptionsModalService {
return this.#customerFacade.fetchCustomer({ customerId });
}
#getSelectedBranch(tabId: number): BranchDTO | undefined {
#getSelectedBranch(
tabId: number,
useRedemptionPoints: boolean,
): BranchDTO | undefined {
const tab = untracked(() =>
this.#tabService.entities().find((t) => t.id === tabId),
);
@@ -104,6 +112,10 @@ export class PurchaseOptionsModalService {
return undefined;
}
if (useRedemptionPoints) {
return this.#checkoutMetadataService.getSelectedBranch(tabId);
}
const legacyProcessData = tab?.metadata?.process_data;
if (

View File

@@ -1,14 +1,19 @@
import { Routes } from '@angular/router';
import { AssortmentComponent } from './assortment.component';
import { PriceUpdateComponent } from './price-update/price-update.component';
export const routes: Routes = [
{
path: '',
component: AssortmentComponent,
children: [
{ path: 'price-update', component: PriceUpdateComponent },
{ path: '**', redirectTo: 'price-update' },
],
},
];
import { Routes } from '@angular/router';
import { AssortmentComponent } from './assortment.component';
import { PriceUpdateComponent } from './price-update/price-update.component';
export const routes: Routes = [
{
path: '',
component: AssortmentComponent,
title: 'Sortiment',
children: [
{
path: 'price-update',
component: PriceUpdateComponent,
title: 'Sortiment - Preisänderung',
},
{ path: '**', redirectTo: 'price-update' },
],
},
];

View File

@@ -1,125 +1,135 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { ArticleDetailsComponent } from './article-details/article-details.component';
import { ArticleSearchComponent } from './article-search/article-search.component';
import { ArticleSearchFilterComponent } from './article-search/search-filter/search-filter.component';
import { ArticleSearchMainComponent } from './article-search/search-main/search-main.component';
import { ArticleSearchResultsComponent } from './article-search/search-results/search-results.component';
import { PageCatalogComponent } from './page-catalog.component';
import { MatomoRouteData } from 'ngx-matomo-client';
const routes: Routes = [
{
path: '',
component: PageCatalogComponent,
data: {
matomo: {
title: 'Artikelsuche',
} as MatomoRouteData,
},
children: [
{
path: 'filter',
component: ArticleSearchFilterComponent,
data: {
matomo: {
title: 'Artikelsuche - Filter',
} as MatomoRouteData,
},
},
{
path: 'filter/:id',
component: ArticleSearchFilterComponent,
data: {
matomo: {
title: 'Artikelsuche - Filter',
} as MatomoRouteData,
},
},
{
path: 'search',
component: ArticleSearchComponent,
outlet: 'side',
data: {
matomo: {
title: 'Artikelsuche',
} as MatomoRouteData,
},
children: [
{
path: '',
component: ArticleSearchMainComponent,
},
],
},
{
path: 'results',
component: ArticleSearchResultsComponent,
data: {
matomo: {
title: 'Artikelsuche - Trefferliste',
} as MatomoRouteData,
},
},
{
path: 'results',
component: ArticleSearchResultsComponent,
outlet: 'side',
data: {
matomo: {
title: 'Artikelsuche - Trefferliste',
} as MatomoRouteData,
},
},
{
path: 'results/:id',
component: ArticleSearchResultsComponent,
outlet: 'side',
data: {
matomo: {
title: 'Artikelsuche - Artikeldetails',
} as MatomoRouteData,
},
},
{
path: 'results/:ean/ean',
component: ArticleSearchResultsComponent,
outlet: 'side',
data: {
matomo: {
title: 'Artikelsuche - Artikeldetails (EAN)',
} as MatomoRouteData,
},
},
{
path: 'details/:id',
component: ArticleDetailsComponent,
data: {
matomo: {
title: 'Artikelsuche - Artikeldetails (ID)',
} as MatomoRouteData,
},
},
{
path: 'details/:ean/ean',
component: ArticleDetailsComponent,
data: {
matomo: {
title: 'Artikelsuche - Artikeldetails (EAN)',
} as MatomoRouteData,
},
},
{
path: '',
pathMatch: 'full',
redirectTo: 'filter',
},
],
},
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule],
})
export class PageCatalogRoutingModule {}
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { ArticleDetailsComponent } from './article-details/article-details.component';
import { ArticleSearchComponent } from './article-search/article-search.component';
import { ArticleSearchFilterComponent } from './article-search/search-filter/search-filter.component';
import { ArticleSearchMainComponent } from './article-search/search-main/search-main.component';
import { ArticleSearchResultsComponent } from './article-search/search-results/search-results.component';
import { PageCatalogComponent } from './page-catalog.component';
import { MatomoRouteData } from 'ngx-matomo-client';
const routes: Routes = [
{
path: '',
component: PageCatalogComponent,
title: 'Artikelsuche',
data: {
matomo: {
title: 'Artikelsuche',
} as MatomoRouteData,
},
children: [
{
path: 'filter',
component: ArticleSearchFilterComponent,
title: 'Artikelsuche - Filter',
data: {
matomo: {
title: 'Artikelsuche - Filter',
} as MatomoRouteData,
},
},
{
path: 'filter/:id',
component: ArticleSearchFilterComponent,
title: 'Artikelsuche - Filter',
data: {
matomo: {
title: 'Artikelsuche - Filter',
} as MatomoRouteData,
},
},
{
path: 'search',
component: ArticleSearchComponent,
outlet: 'side',
title: 'Artikelsuche',
data: {
matomo: {
title: 'Artikelsuche',
} as MatomoRouteData,
},
children: [
{
path: '',
component: ArticleSearchMainComponent,
},
],
},
{
path: 'results',
component: ArticleSearchResultsComponent,
title: 'Artikelsuche - Trefferliste',
data: {
matomo: {
title: 'Artikelsuche - Trefferliste',
} as MatomoRouteData,
},
},
{
path: 'results',
component: ArticleSearchResultsComponent,
outlet: 'side',
title: 'Artikelsuche - Trefferliste',
data: {
matomo: {
title: 'Artikelsuche - Trefferliste',
} as MatomoRouteData,
},
},
{
path: 'results/:id',
component: ArticleSearchResultsComponent,
outlet: 'side',
title: 'Artikelsuche - Artikeldetails',
data: {
matomo: {
title: 'Artikelsuche - Artikeldetails',
} as MatomoRouteData,
},
},
{
path: 'results/:ean/ean',
component: ArticleSearchResultsComponent,
outlet: 'side',
title: 'Artikelsuche - Artikeldetails (EAN)',
data: {
matomo: {
title: 'Artikelsuche - Artikeldetails (EAN)',
} as MatomoRouteData,
},
},
{
path: 'details/:id',
component: ArticleDetailsComponent,
title: 'Artikelsuche - Artikeldetails (ID)',
data: {
matomo: {
title: 'Artikelsuche - Artikeldetails (ID)',
} as MatomoRouteData,
},
},
{
path: 'details/:ean/ean',
component: ArticleDetailsComponent,
title: 'Artikelsuche - Artikeldetails (EAN)',
data: {
matomo: {
title: 'Artikelsuche - Artikeldetails (EAN)',
} as MatomoRouteData,
},
},
{
path: '',
pathMatch: 'full',
redirectTo: 'filter',
},
],
},
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule],
})
export class PageCatalogRoutingModule {}

View File

@@ -132,7 +132,7 @@
}
}
<page-shopping-cart-item
(changeItem)="changeItem($event)"
(changeItem)="showPurchasingListModal([$event.shoppingCartItem])"
(changeDummyItem)="changeDummyItem($event)"
(changeQuantity)="updateItemQuantity($event)"
[quantityError]="

View File

@@ -388,26 +388,6 @@ export class CheckoutReviewComponent
this.openDummyModal({ data: shoppingCartItem, changeDataFromCart: true });
}
async changeItem({
shoppingCartItem,
}: {
shoppingCartItem: ShoppingCartItemDTO;
}) {
const shoppingCart = await firstValueFrom(this.shoppingCart$);
const modalRef = await this._purchaseOptionsModalService.open({
tabId: this.applicationService.activatedProcessId,
shoppingCartId: shoppingCart.id,
items: [shoppingCartItem],
type: 'update',
});
await firstValueFrom(modalRef.afterClosed$);
// Reload Shopping Cart after modal is closed to get updated items
this.#checkoutService.reloadShoppingCart({
processId: this.applicationService.activatedProcessId,
});
}
async openPrintModal() {
const shoppingCart = await this.shoppingCart$.pipe(first()).toPromise();
this.uiModal.open({

View File

@@ -14,20 +14,24 @@ const routes: Routes = [
{
path: 'details',
component: CheckoutReviewDetailsComponent,
title: 'Bestelldetails',
outlet: 'side',
},
{
path: 'review',
component: CheckoutReviewComponent,
title: 'Bestellung überprüfen',
},
{
path: 'summary',
component: CheckoutSummaryComponent,
title: 'Bestellübersicht',
canDeactivate: [canDeactivateTabCleanup],
},
{
path: 'summary/:orderIds',
component: CheckoutSummaryComponent,
title: 'Bestellübersicht',
canDeactivate: [canDeactivateTabCleanup],
},
{ path: '', pathMatch: 'full', redirectTo: 'review' },

View File

@@ -1,29 +1,66 @@
@if (orderItem$ | async; as orderItem) {
<div #features class="page-customer-order-details-item__features">
@if (orderItem?.features?.prebooked) {
<img [uiOverlayTrigger]="prebookedTooltip" src="/assets/images/tag_icon_preorder.svg" [alt]="orderItem?.features?.prebooked" />
<ui-tooltip yPosition="above" xPosition="after" [yOffset]="-11" [xOffset]="-8" #prebookedTooltip [closeable]="true">
<img
[uiOverlayTrigger]="prebookedTooltip"
src="/assets/images/tag_icon_preorder.svg"
[alt]="orderItem?.features?.prebooked"
/>
<ui-tooltip
yPosition="above"
xPosition="after"
[yOffset]="-11"
[xOffset]="-8"
#prebookedTooltip
[closeable]="true"
>
Artikel wird für Sie vorgemerkt.
</ui-tooltip>
}
@if (notificationsSent$ | async; as notificationsSent) {
@if (notificationsSent?.NOTIFICATION_EMAIL) {
<img [uiOverlayTrigger]="emailTooltip" src="/assets/images/email_bookmark.svg" />
<ui-tooltip yPosition="above" xPosition="after" [yOffset]="-11" [xOffset]="-8" #emailTooltip [closeable]="true">
<img
[uiOverlayTrigger]="emailTooltip"
src="/assets/images/email_bookmark.svg"
/>
<ui-tooltip
yPosition="above"
xPosition="after"
[yOffset]="-11"
[xOffset]="-8"
#emailTooltip
[closeable]="true"
>
Per E-Mail benachrichtigt
<br />
@for (notification of notificationsSent?.NOTIFICATION_EMAIL; track notification) {
@for (
notification of notificationsSent?.NOTIFICATION_EMAIL;
track notification
) {
{{ notification | date: 'dd.MM.yyyy | HH:mm' }} Uhr
<br />
}
</ui-tooltip>
}
@if (notificationsSent?.NOTIFICATION_SMS) {
<img [uiOverlayTrigger]="smsTooltip" src="/assets/images/sms_bookmark.svg" />
<ui-tooltip yPosition="above" xPosition="after" [yOffset]="-11" [xOffset]="-8" #smsTooltip [closeable]="true">
<img
[uiOverlayTrigger]="smsTooltip"
src="/assets/images/sms_bookmark.svg"
/>
<ui-tooltip
yPosition="above"
xPosition="after"
[yOffset]="-11"
[xOffset]="-8"
#smsTooltip
[closeable]="true"
>
Per SMS benachrichtigt
<br />
@for (notification of notificationsSent?.NOTIFICATION_SMS; track notification) {
@for (
notification of notificationsSent?.NOTIFICATION_SMS;
track notification
) {
{{ notification | date: 'dd.MM.yyyy | HH:mm' }} Uhr
<br />
}
@@ -33,7 +70,10 @@
</div>
<div class="page-customer-order-details-item__item-container">
<div class="page-customer-order-details-item__thumbnail">
<img [src]="orderItem.product?.ean | productImage" [alt]="orderItem.product?.name" />
<img
[src]="orderItem.product?.ean | productImage"
[alt]="orderItem.product?.name"
/>
</div>
<div class="page-customer-order-details-item__details">
<div class="flex flex-row justify-between items-start mb-[1.3125rem]">
@@ -42,19 +82,29 @@
#elementDistance="uiElementDistance"
[style.max-width.px]="elementDistance.distanceChange | async"
class="flex flex-col"
>
<div class="font-normal mb-[0.375rem]">{{ orderItem.product?.contributors }}</div>
>
@if (hasRewardPoints$ | async) {
<ui-label class="w-10 mb-2">Prämie</ui-label>
}
<div class="font-normal mb-[0.375rem]">
{{ orderItem.product?.contributors }}
</div>
<div>{{ orderItem.product?.name }}</div>
</h3>
<div class="history-wrapper flex flex-col items-end justify-center">
<button class="cta-history text-p1" (click)="historyClick.emit(orderItem)">Historie</button>
<button
class="cta-history text-p1"
(click)="historyClick.emit(orderItem)"
>
Historie
</button>
@if (selectable$ | async) {
<input
[ngModel]="selected$ | async"
(ngModelChange)="setSelected($event)"
class="isa-select-bullet mt-4"
type="checkbox"
/>
/>
}
</div>
</div>
@@ -72,19 +122,26 @@
[showSpinner]="false"
></ui-quantity-dropdown>
}
<span class="overall-quantity">(von {{ orderItem?.overallQuantity }})</span>
<span class="overall-quantity"
>(von {{ orderItem?.overallQuantity }})</span
>
</div>
</div>
@if (!!orderItem.product?.formatDetail) {
<div class="detail">
<div class="label">Format</div>
<div class="value">
@if (orderItem?.product?.format && orderItem?.product?.format !== 'UNKNOWN') {
@if (
orderItem?.product?.format &&
orderItem?.product?.format !== 'UNKNOWN'
) {
<img
class="format-icon"
[src]="'/assets/images/Icon_' + orderItem.product?.format + '.svg'"
[src]="
'/assets/images/Icon_' + orderItem.product?.format + '.svg'
"
alt="format icon"
/>
/>
}
<span>{{ orderItem.product?.formatDetail }}</span>
</div>
@@ -96,10 +153,17 @@
<div class="value">{{ orderItem.product?.ean }}</div>
</div>
}
@if (orderItem.price !== undefined) {
@if (orderItem.price !== undefined || (hasRewardPoints$ | async)) {
<div class="detail">
<div class="label">Preis</div>
<div class="value">{{ orderItem.price | currency: 'EUR' }}</div>
@if (hasRewardPoints$ | async) {
<div class="label">Prämie</div>
<div class="value">
{{ rewardPoints$ | async | number: '1.0-0' }} Lesepunkte
</div>
} @else {
<div class="label">Preis</div>
<div class="value">{{ orderItem.price | currency: 'EUR' }}</div>
}
</div>
}
@if (!!orderItem.retailPrice?.vat?.inPercent) {
@@ -133,14 +197,23 @@
orderItemFeature(orderItem) === 'Versand' ||
orderItemFeature(orderItem) === 'B2B-Versand' ||
orderItemFeature(orderItem) === 'DIG-Versand'
) {
{{ orderItem?.estimatedDelivery ? 'Lieferung zwischen' : 'Lieferung ab' }}
) {
{{
orderItem?.estimatedDelivery
? 'Lieferung zwischen'
: 'Lieferung ab'
}}
}
@if (orderItemFeature(orderItem) === 'Abholung' || orderItemFeature(orderItem) === 'Rücklage') {
@if (
orderItemFeature(orderItem) === 'Abholung' ||
orderItemFeature(orderItem) === 'Rücklage'
) {
Abholung ab
}
</div>
@if (!!orderItem?.estimatedDelivery || !!orderItem?.estimatedShippingDate) {
@if (
!!orderItem?.estimatedDelivery || !!orderItem?.estimatedShippingDate
) {
<div class="value bg-[#D8DFE5] rounded w-max px-2">
@if (!!orderItem?.estimatedDelivery) {
{{ orderItem?.estimatedDelivery?.start | date: 'dd.MM.yy' }} und
@@ -155,14 +228,22 @@
</div>
@if (getOrderItemTrackingData(orderItem); as trackingData) {
<div class="page-customer-order-details-item__tracking-details">
<div class="label">{{ trackingData.length > 1 ? 'Sendungsnummern' : 'Sendungsnummer' }}</div>
<div class="label">
{{ trackingData.length > 1 ? 'Sendungsnummern' : 'Sendungsnummer' }}
</div>
@for (tracking of trackingData; track tracking) {
@if (tracking.trackingProvider === 'DHL' && !isNative) {
<a class="value text-[#0556B4]" [href]="getTrackingNumberLink(tracking.trackingNumber)" target="_blank">
<a
class="value text-[#0556B4]"
[href]="getTrackingNumberLink(tracking.trackingNumber)"
target="_blank"
>
{{ tracking.trackingProvider }}: {{ tracking.trackingNumber }}
</a>
} @else {
<p class="value">{{ tracking.trackingProvider }}: {{ tracking.trackingNumber }}</p>
<p class="value">
{{ tracking.trackingProvider }}: {{ tracking.trackingNumber }}
</p>
}
}
</div>
@@ -206,7 +287,9 @@
@if (!!receipt?.printedDate) {
<div class="detail">
<div class="label">Erstellt am</div>
<div class="value">{{ receipt?.printedDate | date: 'dd.MM.yy | HH:mm' }} Uhr</div>
<div class="value">
{{ receipt?.printedDate | date: 'dd.MM.yy | HH:mm' }} Uhr
</div>
</div>
}
@if (!!receipt?.receiptText) {
@@ -219,12 +302,20 @@
<div class="detail">
<div class="label">Belegart</div>
<div class="value">
{{ receipt?.receiptType === 1 ? 'Lieferschein' : receipt?.receiptType === 64 ? 'Zahlungsbeleg' : '-' }}
{{
receipt?.receiptType === 1
? 'Lieferschein'
: receipt?.receiptType === 64
? 'Zahlungsbeleg'
: '-'
}}
</div>
</div>
}
}
<div class="page-customer-order-details-item__comment flex flex-col items-start mt-[1.625rem]">
<div
class="page-customer-order-details-item__comment flex flex-col items-start mt-[1.625rem]"
>
<div class="label mb-[0.375rem]">Anmerkung</div>
<div class="flex flex-row w-full">
<textarea
@@ -248,17 +339,23 @@
<button
type="reset"
class="clear"
(click)="specialCommentControl.setValue(''); saveSpecialComment(); triggerResize()"
>
(click)="
specialCommentControl.setValue('');
saveSpecialComment();
triggerResize()
"
>
<shared-icon icon="close" [size]="24"></shared-icon>
</button>
}
@if (specialCommentControl?.enabled && specialCommentControl.dirty) {
@if (
specialCommentControl?.enabled && specialCommentControl.dirty
) {
<button
class="cta-save"
type="submit"
(click)="saveSpecialComment()"
>
>
Speichern
</button>
}

View File

@@ -15,12 +15,25 @@ import { DomainOmsService, DomainReceiptService } from '@domain/oms';
import { ComponentStore } from '@ngrx/component-store';
import { tapResponse } from '@ngrx/operators';
import { OrderDTO, OrderItemListItemDTO, ReceiptDTO, ReceiptType } from '@generated/swagger/oms-api';
import {
OrderDTO,
OrderItemListItemDTO,
ReceiptDTO,
ReceiptType,
} from '@generated/swagger/oms-api';
import { isEqual } from 'lodash';
import { combineLatest, NEVER, Subject, Observable } from 'rxjs';
import { catchError, filter, first, map, switchMap, withLatestFrom } from 'rxjs/operators';
import {
catchError,
filter,
first,
map,
switchMap,
withLatestFrom,
} from 'rxjs/operators';
import { CustomerOrderDetailsStore } from '../customer-order-details.store';
import { EnvironmentService } from '@core/environment';
import { getOrderItemRewardFeature } from '@isa/oms/data-access';
export interface CustomerOrderDetailsItemComponentState {
orderItem?: OrderItemListItemDTO;
@@ -59,7 +72,12 @@ export class CustomerOrderDetailsItemComponent
// Remove Prev OrderItem from selected list
this._store.selectOrderItem(this.orderItem, false);
this.patchState({ orderItem, quantity: orderItem?.quantity, receipts: [], more: false });
this.patchState({
orderItem,
quantity: orderItem?.quantity,
receipts: [],
more: false,
});
this.specialCommentControl.reset(orderItem?.specialComment);
// Add New OrderItem to selected list if selected was set to true by its input
@@ -94,8 +112,23 @@ export class CustomerOrderDetailsItemComponent
),
);
canChangeQuantity$ = combineLatest([this.orderItem$, this._store.fetchPartial$]).pipe(
map(([item, partialPickup]) => ([16, 8192].includes(item?.processingStatus) || partialPickup) && item.quantity > 1),
hasRewardPoints$ = this.orderItem$.pipe(
map((orderItem) => getOrderItemRewardFeature(orderItem) !== undefined),
);
rewardPoints$ = this.orderItem$.pipe(
map((orderItem) => getOrderItemRewardFeature(orderItem)),
);
canChangeQuantity$ = combineLatest([
this.orderItem$,
this._store.fetchPartial$,
]).pipe(
map(
([item, partialPickup]) =>
([16, 8192].includes(item?.processingStatus) || partialPickup) &&
item.quantity > 1,
),
);
get quantity() {
@@ -111,7 +144,9 @@ export class CustomerOrderDetailsItemComponent
@Input()
get selected() {
return this._store.selectedeOrderItemSubsetIds.includes(this.orderItem?.orderItemSubsetId);
return this._store.selectedeOrderItemSubsetIds.includes(
this.orderItem?.orderItemSubsetId,
);
}
set selected(selected: boolean) {
if (this.selected !== selected) {
@@ -120,22 +155,36 @@ export class CustomerOrderDetailsItemComponent
}
}
readonly selected$ = combineLatest([this.orderItem$, this._store.selectedeOrderItemSubsetIds$]).pipe(
map(([orderItem, selectedItems]) => selectedItems.includes(orderItem?.orderItemSubsetId)),
readonly selected$ = combineLatest([
this.orderItem$,
this._store.selectedeOrderItemSubsetIds$,
]).pipe(
map(([orderItem, selectedItems]) =>
selectedItems.includes(orderItem?.orderItemSubsetId),
),
);
@Output()
selectedChange = new EventEmitter<boolean>();
get selectable() {
return this._store.itemsSelectable && this._store.items.length > 1 && this._store.fetchPartial;
return (
this._store.itemsSelectable &&
this._store.items.length > 1 &&
this._store.fetchPartial
);
}
readonly selectable$ = combineLatest([
this._store.items$,
this._store.itemsSelectable$,
this._store.fetchPartial$,
]).pipe(map(([orderItems, selectable, fetchPartial]) => orderItems.length > 1 && selectable && fetchPartial));
]).pipe(
map(
([orderItems, selectable, fetchPartial]) =>
orderItems.length > 1 && selectable && fetchPartial,
),
);
get receipts() {
return this.get((s) => s.receipts);
@@ -173,6 +222,7 @@ export class CustomerOrderDetailsItemComponent
});
}
// eslint-disable-next-line @typescript-eslint/no-empty-function
ngOnInit() {}
ngOnDestroy() {
@@ -182,37 +232,39 @@ export class CustomerOrderDetailsItemComponent
this._onDestroy$.complete();
}
loadReceipts = this.effect((done$: Observable<(receipts: ReceiptDTO[]) => void | undefined>) =>
done$.pipe(
withLatestFrom(this.orderItem$),
filter(([_, orderItem]) => !!orderItem),
switchMap(([done, orderItem]) =>
this._domainReceiptService
.getReceipts({
receiptType: 65 as ReceiptType,
ids: [orderItem.orderItemSubsetId],
eagerLoading: 1,
})
.pipe(
tapResponse(
(res) => {
const receipts = res.result.map((r) => r.item3?.data).filter((f) => !!f);
this.receipts = receipts;
loadReceipts = this.effect(
(done$: Observable<(receipts: ReceiptDTO[]) => void | undefined>) =>
done$.pipe(
withLatestFrom(this.orderItem$),
filter(([, orderItem]) => !!orderItem),
switchMap(([done, orderItem]) =>
this._domainReceiptService
.getReceipts({
receiptType: 65 as ReceiptType,
ids: [orderItem.orderItemSubsetId],
eagerLoading: 1,
})
.pipe(
tapResponse(
(res) => {
const receipts = res.result
.map((r) => r.item3?.data)
.filter((f) => !!f);
this.receipts = receipts;
if (typeof done === 'function') {
done?.(receipts);
}
},
(err) => {
if (typeof done === 'function') {
done?.([]);
}
},
() => {},
if (typeof done === 'function') {
done?.(receipts);
}
},
() => {
if (typeof done === 'function') {
done?.([]);
}
},
),
),
),
),
),
),
);
async saveSpecialComment() {
@@ -220,7 +272,7 @@ export class CustomerOrderDetailsItemComponent
try {
this.specialCommentControl.reset(this.specialCommentControl.value);
const res = await this._omsService
await this._omsService
.patchComment({
orderId,
orderItemId,
@@ -230,7 +282,10 @@ export class CustomerOrderDetailsItemComponent
.pipe(first())
.toPromise();
this.orderItem = { ...this.orderItem, specialComment: this.specialCommentControl.value ?? '' };
this.orderItem = {
...this.orderItem,
specialComment: this.specialCommentControl.value ?? '',
};
this._store.updateOrderItems([this.orderItem]);
this.specialCommentChanged.emit();
} catch (error) {
@@ -253,8 +308,9 @@ export class CustomerOrderDetailsItemComponent
orderItemFeature(orderItemListItem: OrderItemListItemDTO) {
const orderItems = this.order?.items;
return orderItems?.find((orderItem) => orderItem.data.id === orderItemListItem.orderItemId)?.data?.features
?.orderType;
return orderItems?.find(
(orderItem) => orderItem.data.id === orderItemListItem.orderItemId,
)?.data?.features?.orderType;
}
getOrderItemTrackingData(
@@ -263,15 +319,18 @@ export class CustomerOrderDetailsItemComponent
const orderItems = this.order?.items;
const completeTrackingInformation = orderItems
?.find((orderItem) => orderItem.data.id === orderItemListItem.orderItemId)
?.data?.subsetItems?.find((subsetItem) => subsetItem.id === orderItemListItem.orderItemSubsetId)
?.data?.trackingNumber;
?.data?.subsetItems?.find(
(subsetItem) => subsetItem.id === orderItemListItem.orderItemSubsetId,
)?.data?.trackingNumber;
if (!completeTrackingInformation) {
return;
}
// Beispielnummer: 'DHL: 124124' - Bei mehreren Tracking-Informationen muss noch ein Splitter eingebaut werden, je nach dem welcher Trenner verwendet wird
const trackingInformationPairs = completeTrackingInformation.split(':').map((obj) => obj.trim());
const trackingInformationPairs = completeTrackingInformation
.split(':')
.map((obj) => obj.trim());
return this._trackingTransformationHelper(trackingInformationPairs);
}
@@ -282,7 +341,10 @@ export class CustomerOrderDetailsItemComponent
return trackingInformationPairs.reduce(
(acc, current, index, array) => {
if (index % 2 === 0) {
acc.push({ trackingProvider: current, trackingNumber: array[index + 1] });
acc.push({
trackingProvider: current,
trackingNumber: array[index + 1],
});
}
return acc;
},

View File

@@ -17,6 +17,7 @@ import { ProductImageModule } from '@cdn/product-image';
import { CustomerOrderDetailsStore } from './customer-order-details.store';
import { UiDatepickerModule } from '@ui/datepicker';
import { UiDropdownModule } from '@ui/dropdown';
import { LabelComponent } from '@isa/ui/label';
@NgModule({
imports: [
@@ -34,6 +35,7 @@ import { UiDropdownModule } from '@ui/dropdown';
CustomerOrderPipesModule,
ProductImageModule,
CustomerOrderDetailsTagsComponent,
LabelComponent,
],
exports: [
CustomerOrderDetailsComponent,
@@ -41,7 +43,11 @@ import { UiDropdownModule } from '@ui/dropdown';
CustomerOrderDetailsHeaderComponent,
CustomerOrderDetailsTagsComponent,
],
declarations: [CustomerOrderDetailsComponent, CustomerOrderDetailsItemComponent, CustomerOrderDetailsHeaderComponent],
declarations: [
CustomerOrderDetailsComponent,
CustomerOrderDetailsItemComponent,
CustomerOrderDetailsHeaderComponent,
],
providers: [CustomerOrderDetailsStore],
})
export class CustomerOrderDetailsModule {}

View File

@@ -9,8 +9,7 @@ import { DecimalPipe } from '@angular/common';
import { Component, Input, OnInit, inject } from '@angular/core';
import { IconComponent } from '@shared/components/icon';
import { BonusCardInfoDTO } from '@generated/swagger/crm-api';
import { injectTabId } from '@isa/core/tabs';
import { NavigationStateService } from '@isa/core/navigation';
import { injectTabId, TabService } from '@isa/core/tabs';
import { Router } from '@angular/router';
import { CustomerSearchNavigation } from '@shared/services/navigation';
@@ -47,7 +46,7 @@ import { CustomerSearchNavigation } from '@shared/services/navigation';
export class KundenkarteComponent implements OnInit {
#tabId = injectTabId();
#router = inject(Router);
#navigationState = inject(NavigationStateService);
#tabService = inject(TabService);
#customerNavigationService = inject(CustomerSearchNavigation);
@Input() cardDetails: BonusCardInfoDTO;
@@ -69,13 +68,12 @@ export class KundenkarteComponent implements OnInit {
return;
}
this.#navigationState.preserveContext(
{
this.#tabService.patchTabMetadata(tabId, {
'select-customer': {
returnUrl: `/${tabId}/reward`,
autoTriggerContinueFn: true,
},
'select-customer',
);
});
await this.#router.navigate(
this.#customerNavigationService.detailsRoute({

View File

@@ -5,7 +5,7 @@ import {
inject,
} from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { NavigationStateService } from '@isa/core/navigation';
import { TabService } from '@isa/core/tabs';
import { map } from 'rxjs/operators';
@Component({
@@ -16,13 +16,16 @@ import { map } from 'rxjs/operators';
standalone: false,
})
export class CustomerComponent implements OnDestroy {
private _navigationState = inject(NavigationStateService);
private _tabService = inject(TabService);
processId$ = this._activatedRoute.data.pipe(map((data) => data.processId));
constructor(private _activatedRoute: ActivatedRoute) {}
async ngOnDestroy() {
ngOnDestroy() {
const tab = this._tabService.activatedTab();
// #5512 Always clear preserved select-customer context if navigating out of customer area
await this._navigationState.clearPreservedContext('select-customer');
this._tabService.patchTabMetadata(tab.id, {
'select-customer': null,
});
}
}

View File

@@ -49,9 +49,14 @@ import {
NavigateAfterRewardSelection,
RewardSelectionPopUpService,
} from '@isa/checkout/shared/reward-selection-dialog';
import { NavigationStateService } from '@isa/core/navigation';
import { TabService } from '@isa/core/tabs';
import { ShippingAddressDTO as CrmShippingAddressDTO } from '@generated/swagger/crm-api';
interface SelectCustomerContext {
returnUrl?: string;
autoTriggerContinueFn?: boolean;
}
export interface CustomerDetailsViewMainState {
isBusy: boolean;
shoppingCart: ShoppingCartDTO;
@@ -80,7 +85,7 @@ export class CustomerDetailsViewMainComponent
private _router = inject(Router);
private _activatedRoute = inject(ActivatedRoute);
private _genderSettings = inject(GenderSettingsService);
private _navigationState = inject(NavigationStateService);
private _tabService = inject(TabService);
private _onDestroy$ = new Subject<void>();
customerService = inject(CrmCustomerService);
@@ -97,18 +102,19 @@ export class CustomerDetailsViewMainComponent
map(([fetchingCustomer, fetchingList]) => fetchingCustomer || fetchingList),
);
async getReturnUrlFromContext(): Promise<string | null> {
// Get from preserved context (survives intermediate navigations, auto-scoped to tab)
const context = await this._navigationState.restoreContext<{
returnUrl?: string;
}>('select-customer');
getReturnUrlFromContext(): string | null {
// Get from preserved context (survives intermediate navigations, scoped to tab)
const context = this._tabService.activatedTab()?.metadata?.[
'select-customer'
] as SelectCustomerContext | undefined;
return context?.returnUrl ?? null;
}
async checkHasReturnUrl(): Promise<void> {
const hasContext =
await this._navigationState.hasPreservedContext('select-customer');
checkHasReturnUrl(): void {
const hasContext = !!this._tabService.activatedTab()?.metadata?.[
'select-customer'
];
this.hasReturnUrl.set(hasContext);
}
@@ -321,24 +327,23 @@ export class CustomerDetailsViewMainComponent
ngOnInit() {
// Check if we have a return URL context
this.checkHasReturnUrl().then(async () => {
// Check if we should auto-trigger continue() (only from Kundenkarte)
const context = await this._navigationState.restoreContext<{
returnUrl?: string;
autoTriggerContinueFn?: boolean;
}>('select-customer');
this.checkHasReturnUrl();
if (context?.autoTriggerContinueFn) {
// Clear the autoTriggerContinueFn flag immediately (preserves returnUrl automatically)
await this._navigationState.patchContext(
{ autoTriggerContinueFn: undefined },
'select-customer',
);
// Check if we should auto-trigger continue() (only from Kundenkarte)
const tab = this._tabService.activatedTab();
const context = tab?.metadata?.['select-customer'] as
| SelectCustomerContext
| undefined;
// Auto-trigger continue() ONLY when coming from Kundenkarte
this.continue();
}
});
if (context?.autoTriggerContinueFn && tab) {
// Clear the autoTriggerContinueFn flag immediately (preserves returnUrl)
this._tabService.patchTabMetadata(tab.id, {
'select-customer': { ...context, autoTriggerContinueFn: undefined },
});
// Auto-trigger continue() ONLY when coming from Kundenkarte
this.continue();
}
this.processId$
.pipe(
@@ -436,10 +441,18 @@ export class CustomerDetailsViewMainComponent
// #5262 Check for reward selection flow before navigation
if (this.hasReturnUrl()) {
// Restore from preserved context (auto-scoped to current tab) and clean up
const context = await this._navigationState.restoreAndClearContext<{
returnUrl?: string;
}>('select-customer');
// Restore from preserved context (scoped to current tab) and clean up
const tab = this._tabService.activatedTab();
const context = tab?.metadata?.['select-customer'] as
| SelectCustomerContext
| undefined;
// Clear the context
if (tab) {
this._tabService.patchTabMetadata(tab.id, {
'select-customer': null,
});
}
if (context?.returnUrl) {
await this._router.navigateByUrl(context.returnUrl);

View File

@@ -10,7 +10,7 @@ import {
import { CustomerSearchStore } from '../store';
import { ActivatedRoute, Router } from '@angular/router';
import { map } from 'rxjs/operators';
import { NavigationStateService } from '@isa/core/navigation';
import { TabService } from '@isa/core/tabs';
import { CustomerSearchNavigation } from '@shared/services/navigation';
import { AsyncPipe } from '@angular/common';
import { CustomerMenuComponent } from '../../components/customer-menu';
@@ -51,7 +51,7 @@ export class KundenkarteMainViewComponent implements OnDestroy {
#cardTransactionsResource = inject(CustomerCardTransactionsResource);
elementRef = inject(ElementRef);
#router = inject(Router);
#navigationState = inject(NavigationStateService);
#tabService = inject(TabService);
#customerNavigationService = inject(CustomerSearchNavigation);
/**
@@ -120,13 +120,12 @@ export class KundenkarteMainViewComponent implements OnDestroy {
}
// Preserve context for auto-triggering continue() in details view
this.#navigationState.preserveContext(
{
this.#tabService.patchTabMetadata(tabId, {
'select-customer': {
returnUrl: `/${tabId}/reward`,
autoTriggerContinueFn: true,
},
'select-customer',
);
});
// Navigate to customer details - will auto-trigger continue()
await this.#router.navigate(

View File

@@ -11,13 +11,11 @@
[alt]="name"
/>
}
@if (hasRewardPoints$ | async) {
<ui-label [type]="Labeltype.Tag" [priority]="LabelPriority.High">
Prämie
</ui-label>
}
</div>
<div class="grid grid-flow-row gap-2">
@if (hasRewardPoints$ | async) {
<ui-label class="w-10">Prämie</ui-label>
}
<div class="grid grid-flow-col justify-between items-end">
<span>{{ orderItem.product?.contributors }}</span>
@if (orderDetailsHistoryRoute$ | async; as orderDetailsHistoryRoute) {
@@ -25,7 +23,7 @@
[routerLink]="orderDetailsHistoryRoute.path"
[queryParams]="orderDetailsHistoryRoute.urlTree.queryParams"
[queryParamsHandling]="'merge'"
class="text-brand font-bold text-xl"
class="text-brand font-bold text-xl relative -top-8"
>
Historie
</a>
@@ -64,7 +62,9 @@
<div class="col-data">
@if (hasRewardPoints$ | async) {
<div class="col-label">Prämie</div>
<div class="col-value">{{ rewardPoints$ | async | number: '1.0-0' }} Lesepunkte</div>
<div class="col-value">
{{ rewardPoints$ | async | number: '1.0-0' }} Lesepunkte
</div>
} @else {
<div class="col-label">Preis</div>
<div class="col-value">

View File

@@ -21,7 +21,7 @@ import { map, takeUntil } from 'rxjs/operators';
import { CustomerSearchStore } from '../../store';
import { CustomerSearchNavigation } from '@shared/services/navigation';
import { PaymentTypePipe } from '@shared/pipes/customer';
import { LabelComponent, Labeltype, LabelPriority } from '@isa/ui/label';
import { LabelComponent } from '@isa/ui/label';
import { getOrderItemRewardFeature } from '@isa/oms/data-access';
import { IconComponent } from '@shared/components/icon';
@@ -113,9 +113,6 @@ export class CustomerOrderItemListItemComponent implements OnInit, OnDestroy {
map((orderItem) => getOrderItemRewardFeature(orderItem)),
);
Labeltype = Labeltype;
LabelPriority = LabelPriority;
ngOnInit() {
this.customerId$
.pipe(takeUntil(this._onDestroy))

View File

@@ -1,170 +1,187 @@
import { Routes } from '@angular/router';
import { CustomerComponent } from './customer-page.component';
import { CustomerSearchComponent } from './customer-search/customer-search.component';
import { CustomerResultsMainViewComponent } from './customer-search/results-main-view/results-main-view.component';
import { CustomerDetailsViewMainComponent } from './customer-search/details-main-view/details-main-view.component';
import { CustomerHistoryMainViewComponent } from './customer-search/history-main-view/history-main-view.component';
import { CustomerFilterMainViewComponent } from './customer-search/filter-main-view/filter-main-view.component';
import { CustomerCreateGuard } from './guards/customer-create.guard';
import {
CreateB2BCustomerComponent,
CreateGuestCustomerComponent,
// CreateP4MCustomerComponent,
CreateStoreCustomerComponent,
CreateWebshopCustomerComponent,
} from './create-customer';
// import { UpdateP4MWebshopCustomerComponent } from './create-customer/update-p4m-webshop-customer';
import { CreateCustomerComponent } from './create-customer/create-customer.component';
import { CustomerDataEditB2BComponent } from './customer-search/edit-main-view/customer-data-edit-b2b.component';
import { CustomerDataEditB2CComponent } from './customer-search/edit-main-view/customer-data-edit-b2c.component';
import { AddBillingAddressMainViewComponent } from './customer-search/add-billing-address-main-view/add-billing-address-main-view.component';
import { AddShippingAddressMainViewComponent } from './customer-search/add-shipping-address-main-view/add-shipping-address-main-view.component';
import { EditBillingAddressMainViewComponent } from './customer-search/edit-billing-address-main-view/edit-billing-address-main-view.component';
import { EditShippingAddressMainViewComponent } from './customer-search/edit-shipping-address-main-view/edit-shipping-address-main-view.component';
import { CustomerOrdersMainViewComponent } from './customer-search/orders-main-view/orders-main-view.component';
import { OrderDetailsMainViewComponent } from './customer-search/order-details-main-view/order-details-main-view.component';
import { KundenkarteMainViewComponent } from './customer-search/kundenkarte-main-view/kundenkarte-main-view.component';
import { CustomerOrderDetailsHistoryMainViewComponent } from './customer-search/order-details-history-main-view/order-details-history-main-view.component';
import { CustomerMainViewComponent } from './customer-search/main-view/main-view.component';
import { MainSideViewComponent } from './customer-search/main-side-view/main-side-view.component';
import { CustomerResultsSideViewComponent } from './customer-search/results-side-view/results-side-view.component';
import { OrderDetailsSideViewComponent } from './customer-search/order-details-side-view/order-details-side-view.component';
import { CustomerCreateSideViewComponent } from './create-customer/customer-create-side-view';
export const routes: Routes = [
{
path: '',
component: CustomerComponent,
children: [
{
path: '',
component: CustomerSearchComponent,
children: [
{
path: 'search',
component: CustomerMainViewComponent,
data: { side: 'main', breadcrumb: 'main' },
},
{
path: 'search/list',
component: CustomerResultsMainViewComponent,
data: { breadcrumb: 'search' },
},
{
path: 'search/filter',
component: CustomerFilterMainViewComponent,
data: { side: 'results', breadcrumb: 'filter' },
},
{
path: 'search/:customerId',
component: CustomerDetailsViewMainComponent,
data: { side: 'results', breadcrumb: 'details' },
},
{
path: 'search/:customerId/history',
component: CustomerHistoryMainViewComponent,
data: { side: 'results', breadcrumb: 'history' },
},
{
path: 'search/:customerId/kundenkarte',
component: KundenkarteMainViewComponent,
data: { side: 'results', breadcrumb: 'kundenkarte' },
},
{
path: 'search/:customerId/orders',
component: CustomerOrdersMainViewComponent,
data: { side: 'results', breadcrumb: 'orders' },
},
{
path: 'search/:customerId/orders/:orderId',
component: OrderDetailsMainViewComponent,
data: { side: 'order-details', breadcrumb: 'order-details' },
},
{
path: 'search/:customerId/orders/:orderId/:orderItemId',
component: OrderDetailsMainViewComponent,
data: { side: 'order-details', breadcrumb: 'order-details' },
},
{
path: 'search/:customerId/orders/:orderId/:orderItemId/history',
component: CustomerOrderDetailsHistoryMainViewComponent,
data: {
side: 'order-details',
breadcrumb: 'order-details-history',
},
},
{
path: 'search/:customerId/edit/b2b',
component: CustomerDataEditB2BComponent,
data: { side: 'results', breadcrumb: 'edit' },
},
{
path: 'search/:customerId/edit',
component: CustomerDataEditB2CComponent,
data: { side: 'results', breadcrumb: 'edit' },
},
{
path: 'search/:customerId/billingaddress/add',
component: AddBillingAddressMainViewComponent,
data: { side: 'results', breadcrumb: 'add-billing-address' },
},
{
path: 'search/:customerId/billingaddress/:payerId/edit',
component: EditBillingAddressMainViewComponent,
data: { side: 'results', breadcrumb: 'edit-billing-address' },
},
{
path: 'search/:customerId/shippingaddress/add',
component: AddShippingAddressMainViewComponent,
data: { side: 'results', breadcrumb: 'add-shipping-address' },
},
{
path: 'search/:customerId/shippingaddress/:shippingAddressId/edit',
component: EditShippingAddressMainViewComponent,
data: { side: 'results', breadcrumb: 'edit-shipping-address' },
},
{
path: 'search-customer-main',
outlet: 'side',
component: MainSideViewComponent,
},
{
path: 'results',
outlet: 'side',
component: CustomerResultsSideViewComponent,
},
{
path: 'order-details',
outlet: 'side',
component: OrderDetailsSideViewComponent,
},
],
},
{
path: '',
component: CreateCustomerComponent,
canActivate: [CustomerCreateGuard],
canActivateChild: [CustomerCreateGuard],
children: [
{ path: 'create', component: CreateStoreCustomerComponent },
{ path: 'create/store', component: CreateStoreCustomerComponent },
{ path: 'create/webshop', component: CreateWebshopCustomerComponent },
{ path: 'create/b2b', component: CreateB2BCustomerComponent },
{ path: 'create/guest', component: CreateGuestCustomerComponent },
// { path: 'create/webshop-p4m', component: CreateP4MCustomerComponent, data: { customerType: 'webshop' } },
// { path: 'create/store-p4m', component: CreateP4MCustomerComponent, data: { customerType: 'store' } },
// {
// path: 'create/webshop-p4m/update',
// component: UpdateP4MWebshopCustomerComponent,
// data: { customerType: 'webshop' },
// },
{
path: 'create-customer-main',
outlet: 'side',
component: CustomerCreateSideViewComponent,
},
],
},
],
},
];
import { Routes } from '@angular/router';
import { CustomerComponent } from './customer-page.component';
import { CustomerSearchComponent } from './customer-search/customer-search.component';
import { CustomerResultsMainViewComponent } from './customer-search/results-main-view/results-main-view.component';
import { CustomerDetailsViewMainComponent } from './customer-search/details-main-view/details-main-view.component';
import { CustomerHistoryMainViewComponent } from './customer-search/history-main-view/history-main-view.component';
import { CustomerFilterMainViewComponent } from './customer-search/filter-main-view/filter-main-view.component';
import { CustomerCreateGuard } from './guards/customer-create.guard';
import {
CreateB2BCustomerComponent,
CreateGuestCustomerComponent,
// CreateP4MCustomerComponent,
CreateStoreCustomerComponent,
CreateWebshopCustomerComponent,
} from './create-customer';
// import { UpdateP4MWebshopCustomerComponent } from './create-customer/update-p4m-webshop-customer';
import { CreateCustomerComponent } from './create-customer/create-customer.component';
import { CustomerDataEditB2BComponent } from './customer-search/edit-main-view/customer-data-edit-b2b.component';
import { CustomerDataEditB2CComponent } from './customer-search/edit-main-view/customer-data-edit-b2c.component';
import { AddBillingAddressMainViewComponent } from './customer-search/add-billing-address-main-view/add-billing-address-main-view.component';
import { AddShippingAddressMainViewComponent } from './customer-search/add-shipping-address-main-view/add-shipping-address-main-view.component';
import { EditBillingAddressMainViewComponent } from './customer-search/edit-billing-address-main-view/edit-billing-address-main-view.component';
import { EditShippingAddressMainViewComponent } from './customer-search/edit-shipping-address-main-view/edit-shipping-address-main-view.component';
import { CustomerOrdersMainViewComponent } from './customer-search/orders-main-view/orders-main-view.component';
import { OrderDetailsMainViewComponent } from './customer-search/order-details-main-view/order-details-main-view.component';
import { KundenkarteMainViewComponent } from './customer-search/kundenkarte-main-view/kundenkarte-main-view.component';
import { CustomerOrderDetailsHistoryMainViewComponent } from './customer-search/order-details-history-main-view/order-details-history-main-view.component';
import { CustomerMainViewComponent } from './customer-search/main-view/main-view.component';
import { MainSideViewComponent } from './customer-search/main-side-view/main-side-view.component';
import { CustomerResultsSideViewComponent } from './customer-search/results-side-view/results-side-view.component';
import { OrderDetailsSideViewComponent } from './customer-search/order-details-side-view/order-details-side-view.component';
import { CustomerCreateSideViewComponent } from './create-customer/customer-create-side-view';
export const routes: Routes = [
{
path: '',
component: CustomerComponent,
children: [
{
path: '',
component: CustomerSearchComponent,
children: [
{
path: 'search',
component: CustomerMainViewComponent,
title: 'Kundensuche',
data: { side: 'main', breadcrumb: 'main' },
},
{
path: 'search/list',
component: CustomerResultsMainViewComponent,
title: 'Kundensuche - Trefferliste',
data: { breadcrumb: 'search' },
},
{
path: 'search/filter',
component: CustomerFilterMainViewComponent,
title: 'Kundensuche - Filter',
data: { side: 'results', breadcrumb: 'filter' },
},
{
path: 'search/:customerId',
component: CustomerDetailsViewMainComponent,
title: 'Kundendetails',
data: { side: 'results', breadcrumb: 'details' },
},
{
path: 'search/:customerId/history',
component: CustomerHistoryMainViewComponent,
title: 'Kundendetails - Verlauf',
data: { side: 'results', breadcrumb: 'history' },
},
{
path: 'search/:customerId/kundenkarte',
component: KundenkarteMainViewComponent,
title: 'Kundendetails - Kundenkarte',
data: { side: 'results', breadcrumb: 'kundenkarte' },
},
{
path: 'search/:customerId/orders',
component: CustomerOrdersMainViewComponent,
title: 'Kundendetails - Bestellungen',
data: { side: 'results', breadcrumb: 'orders' },
},
{
path: 'search/:customerId/orders/:orderId',
component: OrderDetailsMainViewComponent,
title: 'Kundendetails - Bestelldetails',
data: { side: 'order-details', breadcrumb: 'order-details' },
},
{
path: 'search/:customerId/orders/:orderId/:orderItemId',
component: OrderDetailsMainViewComponent,
title: 'Kundendetails - Bestelldetails',
data: { side: 'order-details', breadcrumb: 'order-details' },
},
{
path: 'search/:customerId/orders/:orderId/:orderItemId/history',
component: CustomerOrderDetailsHistoryMainViewComponent,
title: 'Kundendetails - Bestelldetails Verlauf',
data: {
side: 'order-details',
breadcrumb: 'order-details-history',
},
},
{
path: 'search/:customerId/edit/b2b',
component: CustomerDataEditB2BComponent,
title: 'Kundendetails - Bearbeiten (B2B)',
data: { side: 'results', breadcrumb: 'edit' },
},
{
path: 'search/:customerId/edit',
component: CustomerDataEditB2CComponent,
title: 'Kundendetails - Bearbeiten',
data: { side: 'results', breadcrumb: 'edit' },
},
{
path: 'search/:customerId/billingaddress/add',
component: AddBillingAddressMainViewComponent,
title: 'Kundendetails - Neue Rechnungsadresse',
data: { side: 'results', breadcrumb: 'add-billing-address' },
},
{
path: 'search/:customerId/billingaddress/:payerId/edit',
component: EditBillingAddressMainViewComponent,
title: 'Kundendetails - Rechnungsadresse bearbeiten',
data: { side: 'results', breadcrumb: 'edit-billing-address' },
},
{
path: 'search/:customerId/shippingaddress/add',
component: AddShippingAddressMainViewComponent,
title: 'Kundendetails - Neue Lieferadresse',
data: { side: 'results', breadcrumb: 'add-shipping-address' },
},
{
path: 'search/:customerId/shippingaddress/:shippingAddressId/edit',
component: EditShippingAddressMainViewComponent,
title: 'Kundendetails - Lieferadresse bearbeiten',
data: { side: 'results', breadcrumb: 'edit-shipping-address' },
},
{
path: 'search-customer-main',
outlet: 'side',
component: MainSideViewComponent,
},
{
path: 'results',
outlet: 'side',
component: CustomerResultsSideViewComponent,
},
{
path: 'order-details',
outlet: 'side',
component: OrderDetailsSideViewComponent,
},
],
},
{
path: '',
component: CreateCustomerComponent,
canActivate: [CustomerCreateGuard],
canActivateChild: [CustomerCreateGuard],
title: 'Kundendaten erfassen',
children: [
{ path: 'create', component: CreateStoreCustomerComponent },
{ path: 'create/store', component: CreateStoreCustomerComponent },
{ path: 'create/webshop', component: CreateWebshopCustomerComponent },
{ path: 'create/b2b', component: CreateB2BCustomerComponent },
{ path: 'create/guest', component: CreateGuestCustomerComponent },
// { path: 'create/webshop-p4m', component: CreateP4MCustomerComponent, data: { customerType: 'webshop' } },
// { path: 'create/store-p4m', component: CreateP4MCustomerComponent, data: { customerType: 'store' } },
// {
// path: 'create/webshop-p4m/update',
// component: UpdateP4MWebshopCustomerComponent,
// data: { customerType: 'webshop' },
// },
{
path: 'create-customer-main',
outlet: 'side',
component: CustomerCreateSideViewComponent,
},
],
},
],
},
];

View File

@@ -1,16 +1,17 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { DashboardComponent } from './dashboard.component';
const routes: Routes = [
{
path: '',
component: DashboardComponent,
},
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule],
})
export class DashboardRoutingModule {}
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { DashboardComponent } from './dashboard.component';
const routes: Routes = [
{
path: '',
component: DashboardComponent,
title: 'Dashboard',
},
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule],
})
export class DashboardRoutingModule {}

View File

@@ -1,35 +1,51 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { GoodsInCleanupListComponent } from './goods-in-cleanup-list/goods-in-cleanup-list.component';
import { GoodsInCleanupListModule } from './goods-in-cleanup-list/goods-in-cleanup-list.module';
import { GoodsInListComponent } from './goods-in-list/goods-in-list.component';
import { GoodsInListModule } from './goods-in-list/goods-in-list.module';
import { GoodsInRemissionPreviewComponent } from './goods-in-remission-preview/goods-in-remission-preview.component';
import { GoodsInRemissionPreviewModule } from './goods-in-remission-preview/goods-in-remission-preview.module';
import { GoodsInReservationComponent } from './goods-in-reservation/goods-in-reservation.component';
import { GoodsInReservationModule } from './goods-in-reservation/goods-in-reservation.module';
import { GoodsInComponent } from './goods-in.component';
const routes: Routes = [
{
path: '',
component: GoodsInComponent,
children: [
{ path: 'list', component: GoodsInListComponent },
{ path: 'reservation', component: GoodsInReservationComponent },
{ path: 'cleanup', component: GoodsInCleanupListComponent },
{ path: 'preview', component: GoodsInRemissionPreviewComponent },
],
},
];
@NgModule({
imports: [
RouterModule.forChild(routes),
GoodsInListModule,
GoodsInCleanupListModule,
GoodsInReservationModule,
GoodsInRemissionPreviewModule,
],
exports: [RouterModule],
})
export class GoodsInRoutingModule {}
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { GoodsInCleanupListComponent } from './goods-in-cleanup-list/goods-in-cleanup-list.component';
import { GoodsInCleanupListModule } from './goods-in-cleanup-list/goods-in-cleanup-list.module';
import { GoodsInListComponent } from './goods-in-list/goods-in-list.component';
import { GoodsInListModule } from './goods-in-list/goods-in-list.module';
import { GoodsInRemissionPreviewComponent } from './goods-in-remission-preview/goods-in-remission-preview.component';
import { GoodsInRemissionPreviewModule } from './goods-in-remission-preview/goods-in-remission-preview.module';
import { GoodsInReservationComponent } from './goods-in-reservation/goods-in-reservation.component';
import { GoodsInReservationModule } from './goods-in-reservation/goods-in-reservation.module';
import { GoodsInComponent } from './goods-in.component';
const routes: Routes = [
{
path: '',
component: GoodsInComponent,
children: [
{
path: 'list',
component: GoodsInListComponent,
title: 'Abholfach - Fehlende',
},
{
path: 'reservation',
component: GoodsInReservationComponent,
title: 'Abholfach - Reservierung',
},
{
path: 'cleanup',
component: GoodsInCleanupListComponent,
title: 'Abholfach - Ausräumen',
},
{
path: 'preview',
component: GoodsInRemissionPreviewComponent,
title: 'Abholfach - Vorschau',
},
],
},
];
@NgModule({
imports: [
RouterModule.forChild(routes),
GoodsInListModule,
GoodsInCleanupListModule,
GoodsInReservationModule,
GoodsInRemissionPreviewModule,
],
exports: [RouterModule],
})
export class GoodsInRoutingModule {}

View File

@@ -1,22 +1,24 @@
import { Routes } from '@angular/router';
import { PackageDetailsComponent } from './package-details';
import { PackageInspectionComponent } from './package-inspection.component';
import { PackageResultComponent } from './package-result';
export const packageInspectionRoutes: Routes = [
{
path: '',
component: PackageInspectionComponent,
children: [
{
path: 'packages',
component: PackageResultComponent,
},
{
path: 'packages/:id',
component: PackageDetailsComponent,
},
{ path: '**', redirectTo: 'packages' },
],
},
];
import { Routes } from '@angular/router';
import { PackageDetailsComponent } from './package-details';
import { PackageInspectionComponent } from './package-inspection.component';
import { PackageResultComponent } from './package-result';
export const packageInspectionRoutes: Routes = [
{
path: '',
component: PackageInspectionComponent,
title: 'Packstück-Prüfung',
children: [
{
path: 'packages',
component: PackageResultComponent,
},
{
path: 'packages/:id',
component: PackageDetailsComponent,
title: 'Packstück-Prüfung - Details',
},
{ path: '**', redirectTo: 'packages' },
],
},
];

View File

@@ -1,146 +1,168 @@
import { Routes } from '@angular/router';
import { PickupShelfInComponent } from './pickup-shelf-in.component';
import { PickupShelfFilterComponent } from '../shared/pickup-shelf-filter/pickup-shelf-filter.component';
import { PickupShelfInDetailsComponent } from './pickup-shelf-in-details/pickup-shelf-in-details.component';
import { viewResolver } from '../resolvers/view.resolver';
import { PickUpShelfHistoryComponent } from '../shared/pickup-shelf-history/pickup-shelf-history.component';
import { PickUpShelfInMainSideViewComponent } from './pickup-shelf-in-main-side-view/pickup-shelf-in-main-side-view.component';
import { PickUpShelfInMainComponent } from './pickup-shelf-in-main/pickup-shelf-in-main.component';
import { PickUpShelfInListComponent } from './pickup-shelf-in-list/pickup-shelf-in-list.component';
import { PickupShelfInEditComponent } from './pickup-shelf-in-edit/pickup-shelf-in-edit.component';
import { MatomoRouteData } from 'ngx-matomo-client';
export const routes: Routes = [
{
path: '',
component: PickupShelfInComponent,
resolve: {
view: viewResolver,
},
runGuardsAndResolvers: 'always',
children: [
{
path: 'main',
component: PickUpShelfInMainComponent,
data: {
view: 'main',
matomo: {
title: 'Abholfach',
} as MatomoRouteData,
},
},
{
path: 'list',
component: PickUpShelfInListComponent,
data: {
view: 'list',
matomo: {
title: 'Abholfach - Trefferliste',
} as MatomoRouteData,
},
},
{
path: 'list/filter',
component: PickupShelfFilterComponent,
data: {
view: 'filter',
matomo: {
title: 'Abholfach - Filter',
} as MatomoRouteData,
},
},
{
path: 'order/:orderId/:orderNumber/item/status/:orderItemProcessingStatus/:orderItemSubsetId/edit',
component: PickupShelfInEditComponent,
data: {
view: 'edit',
matomo: {
title: 'Abholfach - Bearbeiten',
} as MatomoRouteData,
},
},
{
path: 'order/:orderId/compartment/:compartmentCode/:orderItemSubsetId/edit',
component: PickupShelfInEditComponent,
data: {
view: 'edit',
matomo: {
title: 'Abholfach - Bearbeiten',
} as MatomoRouteData,
},
},
{
path: 'order/:orderId/compartment/:compartmentCode/:compartmentInfo/:orderItemSubsetId/edit',
component: PickupShelfInEditComponent,
data: {
view: 'edit',
matomo: {
title: 'Abholfach - Bearbeiten',
} as MatomoRouteData,
},
},
{
path: 'order/:orderId/:orderNumber/item/status/:orderItemProcessingStatus/:orderItemSubsetId/history',
component: PickUpShelfHistoryComponent,
data: {
view: 'history',
matomo: {
title: 'Abholfach - Historie',
} as MatomoRouteData,
},
},
{
path: 'order/:orderId/compartment/:compartmentCode/:orderItemSubsetId/history',
component: PickUpShelfHistoryComponent,
data: {
view: 'history',
matomo: {
title: 'Abholfach - Historie',
} as MatomoRouteData,
},
},
{
path: 'order/:orderId/compartment/:compartmentCode/:compartmentInfo/:orderItemSubsetId/history',
component: PickUpShelfHistoryComponent,
data: {
view: 'history',
matomo: {
title: 'Abholfach - Historie',
} as MatomoRouteData,
},
},
{
path: 'order/:orderId/:orderNumber/item/status/:orderItemProcessingStatus/:orderItemSubsetId',
component: PickupShelfInDetailsComponent,
data: {
view: 'details',
matomo: {
title: 'Abholfach - Details',
} as MatomoRouteData,
},
},
{
path: 'order/:orderId/compartment/:compartmentCode/:orderItemSubsetId',
component: PickupShelfInDetailsComponent,
data: {
view: 'details',
matomo: {
title: 'Abholfach - Details',
} as MatomoRouteData,
},
},
{
path: 'order/:orderId/compartment/:compartmentCode/:compartmentInfo/:orderItemSubsetId',
component: PickupShelfInDetailsComponent,
data: {
view: 'details',
matomo: {
title: 'Abholfach - Details',
} as MatomoRouteData,
},
},
{ path: 'search', component: PickUpShelfInMainSideViewComponent, outlet: 'side' },
{ path: 'list', component: PickUpShelfInListComponent, data: { view: 'list' }, outlet: 'side' },
],
},
];
import { Routes } from '@angular/router';
import { PickupShelfInComponent } from './pickup-shelf-in.component';
import { PickupShelfFilterComponent } from '../shared/pickup-shelf-filter/pickup-shelf-filter.component';
import { PickupShelfInDetailsComponent } from './pickup-shelf-in-details/pickup-shelf-in-details.component';
import { viewResolver } from '../resolvers/view.resolver';
import { PickUpShelfHistoryComponent } from '../shared/pickup-shelf-history/pickup-shelf-history.component';
import { PickUpShelfInMainSideViewComponent } from './pickup-shelf-in-main-side-view/pickup-shelf-in-main-side-view.component';
import { PickUpShelfInMainComponent } from './pickup-shelf-in-main/pickup-shelf-in-main.component';
import { PickUpShelfInListComponent } from './pickup-shelf-in-list/pickup-shelf-in-list.component';
import { PickupShelfInEditComponent } from './pickup-shelf-in-edit/pickup-shelf-in-edit.component';
import { MatomoRouteData } from 'ngx-matomo-client';
export const routes: Routes = [
{
path: '',
component: PickupShelfInComponent,
resolve: {
view: viewResolver,
},
runGuardsAndResolvers: 'always',
title: 'Abholfach - Einbuchen',
children: [
{
path: 'main',
component: PickUpShelfInMainComponent,
data: {
view: 'main',
matomo: {
title: 'Abholfach',
} as MatomoRouteData,
},
},
{
path: 'list',
component: PickUpShelfInListComponent,
title: 'Abholfach - Trefferliste',
data: {
view: 'list',
matomo: {
title: 'Abholfach - Trefferliste',
} as MatomoRouteData,
},
},
{
path: 'list/filter',
component: PickupShelfFilterComponent,
title: 'Abholfach - Filter',
data: {
view: 'filter',
matomo: {
title: 'Abholfach - Filter',
} as MatomoRouteData,
},
},
{
path: 'order/:orderId/:orderNumber/item/status/:orderItemProcessingStatus/:orderItemSubsetId/edit',
component: PickupShelfInEditComponent,
title: 'Abholfach - Bearbeiten',
data: {
view: 'edit',
matomo: {
title: 'Abholfach - Bearbeiten',
} as MatomoRouteData,
},
},
{
path: 'order/:orderId/compartment/:compartmentCode/:orderItemSubsetId/edit',
component: PickupShelfInEditComponent,
title: 'Abholfach - Bearbeiten',
data: {
view: 'edit',
matomo: {
title: 'Abholfach - Bearbeiten',
} as MatomoRouteData,
},
},
{
path: 'order/:orderId/compartment/:compartmentCode/:compartmentInfo/:orderItemSubsetId/edit',
component: PickupShelfInEditComponent,
title: 'Abholfach - Bearbeiten',
data: {
view: 'edit',
matomo: {
title: 'Abholfach - Bearbeiten',
} as MatomoRouteData,
},
},
{
path: 'order/:orderId/:orderNumber/item/status/:orderItemProcessingStatus/:orderItemSubsetId/history',
component: PickUpShelfHistoryComponent,
title: 'Abholfach - Historie',
data: {
view: 'history',
matomo: {
title: 'Abholfach - Historie',
} as MatomoRouteData,
},
},
{
path: 'order/:orderId/compartment/:compartmentCode/:orderItemSubsetId/history',
component: PickUpShelfHistoryComponent,
title: 'Abholfach - Historie',
data: {
view: 'history',
matomo: {
title: 'Abholfach - Historie',
} as MatomoRouteData,
},
},
{
path: 'order/:orderId/compartment/:compartmentCode/:compartmentInfo/:orderItemSubsetId/history',
component: PickUpShelfHistoryComponent,
title: 'Abholfach - Historie',
data: {
view: 'history',
matomo: {
title: 'Abholfach - Historie',
} as MatomoRouteData,
},
},
{
path: 'order/:orderId/:orderNumber/item/status/:orderItemProcessingStatus/:orderItemSubsetId',
component: PickupShelfInDetailsComponent,
title: 'Abholfach - Details',
data: {
view: 'details',
matomo: {
title: 'Abholfach - Details',
} as MatomoRouteData,
},
},
{
path: 'order/:orderId/compartment/:compartmentCode/:orderItemSubsetId',
component: PickupShelfInDetailsComponent,
title: 'Abholfach - Details',
data: {
view: 'details',
matomo: {
title: 'Abholfach - Details',
} as MatomoRouteData,
},
},
{
path: 'order/:orderId/compartment/:compartmentCode/:compartmentInfo/:orderItemSubsetId',
component: PickupShelfInDetailsComponent,
title: 'Abholfach - Details',
data: {
view: 'details',
matomo: {
title: 'Abholfach - Details',
} as MatomoRouteData,
},
},
{
path: 'search',
component: PickUpShelfInMainSideViewComponent,
outlet: 'side',
},
{
path: 'list',
component: PickUpShelfInListComponent,
data: { view: 'list' },
outlet: 'side',
},
],
},
];

View File

@@ -1,75 +1,107 @@
import { Routes } from '@angular/router';
import { PickupShelfOutComponent } from './pickup-shelf-out.component';
import { PickupShelfOutMainComponent } from './pickup-shelf-out-main/pickup-shelf-out-main.component';
import { PickupShelfOutListComponent } from './pickup-shelf-out-list/pickup-shelf-out-list.component';
import { PickupShelfFilterComponent } from '../shared/pickup-shelf-filter/pickup-shelf-filter.component';
import { PickupShelfOutDetailsComponent } from './pickup-shelf-out-details/pickup-shelf-out-details.component';
import { PickupShelfOutMainSideViewComponent } from './pickup-shelf-out-main-side-view/pickup-shelf-out-main-side-view.component';
import { viewResolver } from '../resolvers/view.resolver';
import { PickUpShelfHistoryComponent } from '../shared/pickup-shelf-history/pickup-shelf-history.component';
import { PickupShelfOutEditComponent } from './pickup-shelf-out-edit/pickup-shelf-out-edit.component';
export const routes: Routes = [
{
path: '',
component: PickupShelfOutComponent,
resolve: {
view: viewResolver,
},
runGuardsAndResolvers: 'always',
children: [
{ path: 'main', component: PickupShelfOutMainComponent, data: { view: 'main' } },
{ path: 'list', component: PickupShelfOutListComponent, data: { view: 'list' } },
{ path: 'list/filter', component: PickupShelfFilterComponent, data: { view: 'filter' } },
{
path: 'order/:orderId/:orderNumber/item/status/:orderItemProcessingStatus/edit',
component: PickupShelfOutEditComponent,
data: { view: 'edit' },
},
{
path: 'order/:orderId/compartment/:compartmentCode/status/:orderItemProcessingStatus/edit',
component: PickupShelfOutEditComponent,
data: { view: 'edit' },
},
{
path: 'order/:orderId/compartment/:compartmentCode/:compartmentInfo/status/:orderItemProcessingStatus/edit',
component: PickupShelfOutEditComponent,
data: { view: 'edit' },
},
{
path: 'order/:orderId/:orderNumber/item/status/:orderItemProcessingStatus/history',
component: PickUpShelfHistoryComponent,
data: { view: 'history' },
},
{
path: 'order/:orderId/compartment/:compartmentCode/status/:orderItemProcessingStatus/history',
component: PickUpShelfHistoryComponent,
data: { view: 'history' },
},
{
path: 'order/:orderId/compartment/:compartmentCode/:compartmentInfo/status/:orderItemProcessingStatus/history',
component: PickUpShelfHistoryComponent,
data: { view: 'history' },
},
{
path: 'order/:orderId/:orderNumber/item/status/:orderItemProcessingStatus',
component: PickupShelfOutDetailsComponent,
data: { view: 'details' },
},
{
path: 'order/:orderId/compartment/:compartmentCode/status/:orderItemProcessingStatus',
component: PickupShelfOutDetailsComponent,
data: { view: 'details' },
},
{
path: 'order/:orderId/compartment/:compartmentCode/:compartmentInfo/status/:orderItemProcessingStatus',
component: PickupShelfOutDetailsComponent,
data: { view: 'details' },
},
{ path: 'search', component: PickupShelfOutMainSideViewComponent, outlet: 'side' },
{ path: 'list', component: PickupShelfOutListComponent, data: { view: 'list' }, outlet: 'side' },
],
},
];
import { Routes } from '@angular/router';
import { PickupShelfOutComponent } from './pickup-shelf-out.component';
import { PickupShelfOutMainComponent } from './pickup-shelf-out-main/pickup-shelf-out-main.component';
import { PickupShelfOutListComponent } from './pickup-shelf-out-list/pickup-shelf-out-list.component';
import { PickupShelfFilterComponent } from '../shared/pickup-shelf-filter/pickup-shelf-filter.component';
import { PickupShelfOutDetailsComponent } from './pickup-shelf-out-details/pickup-shelf-out-details.component';
import { PickupShelfOutMainSideViewComponent } from './pickup-shelf-out-main-side-view/pickup-shelf-out-main-side-view.component';
import { viewResolver } from '../resolvers/view.resolver';
import { PickUpShelfHistoryComponent } from '../shared/pickup-shelf-history/pickup-shelf-history.component';
import { PickupShelfOutEditComponent } from './pickup-shelf-out-edit/pickup-shelf-out-edit.component';
export const routes: Routes = [
{
path: '',
component: PickupShelfOutComponent,
resolve: {
view: viewResolver,
},
runGuardsAndResolvers: 'always',
title: 'Warenausgabe',
children: [
{
path: 'main',
component: PickupShelfOutMainComponent,
data: { view: 'main' },
},
{
path: 'list',
component: PickupShelfOutListComponent,
data: { view: 'list' },
},
{
path: 'list/filter',
component: PickupShelfFilterComponent,
data: { view: 'filter' },
},
{
path: 'order/:orderId/:orderNumber/item/status/:orderItemProcessingStatus/edit',
component: PickupShelfOutEditComponent,
title: 'Warenausgabe - Details',
data: { view: 'edit' },
},
{
path: 'order/:orderId/compartment/:compartmentCode/status/:orderItemProcessingStatus/edit',
component: PickupShelfOutEditComponent,
title: 'Warenausgabe - Details',
data: { view: 'edit' },
},
{
path: 'order/:orderId/compartment/:compartmentCode/:compartmentInfo/status/:orderItemProcessingStatus/edit',
component: PickupShelfOutEditComponent,
title: 'Warenausgabe - Details',
data: { view: 'edit' },
},
{
path: 'order/:orderId/:orderNumber/item/status/:orderItemProcessingStatus/history',
component: PickUpShelfHistoryComponent,
title: 'Warenausgabe - Verlauf',
data: { view: 'history' },
},
{
path: 'order/:orderId/compartment/:compartmentCode/status/:orderItemProcessingStatus/history',
component: PickUpShelfHistoryComponent,
title: 'Warenausgabe - Verlauf',
data: { view: 'history' },
},
{
path: 'order/:orderId/compartment/:compartmentCode/:compartmentInfo/status/:orderItemProcessingStatus/history',
component: PickUpShelfHistoryComponent,
title: 'Warenausgabe - Verlauf',
data: { view: 'history' },
},
{
path: 'order/:orderId/:orderNumber/item/status/:orderItemProcessingStatus',
component: PickupShelfOutDetailsComponent,
title: 'Warenausgabe - Details',
data: { view: 'details' },
},
{
path: 'order/:orderId/compartment/:compartmentCode/status/:orderItemProcessingStatus',
component: PickupShelfOutDetailsComponent,
title: 'Warenausgabe - Details',
data: { view: 'details' },
},
{
path: 'order/:orderId/compartment/:compartmentCode/:compartmentInfo/status/:orderItemProcessingStatus',
component: PickupShelfOutDetailsComponent,
title: 'Warenausgabe - Details',
data: { view: 'details' },
},
{
path: 'search',
component: PickupShelfOutMainSideViewComponent,
outlet: 'side',
data: { view: 'search' },
},
{
path: 'list',
component: PickupShelfOutListComponent,
data: { view: 'list' },
outlet: 'side',
},
],
},
];

View File

@@ -1,31 +1,75 @@
@if (orderItem) {
<div #features class="page-pickup-shelf-details-item__features">
@if (orderItem?.features?.prebooked) {
<img [uiOverlayTrigger]="prebookedTooltip" src="/assets/images/tag_icon_preorder.svg" [alt]="orderItem?.features?.prebooked" />
<ui-tooltip yPosition="above" xPosition="after" [yOffset]="-11" [xOffset]="-8" #prebookedTooltip [closeable]="true">
<img
[uiOverlayTrigger]="prebookedTooltip"
src="/assets/images/tag_icon_preorder.svg"
[alt]="orderItem?.features?.prebooked"
/>
<ui-tooltip
yPosition="above"
xPosition="after"
[yOffset]="-11"
[xOffset]="-8"
#prebookedTooltip
[closeable]="true"
>
Artikel wird für Sie vorgemerkt.
</ui-tooltip>
}
@if (hasEmailNotification$ | async) {
<img [uiOverlayTrigger]="emailTooltip" src="/assets/images/email_bookmark.svg" />
<ui-tooltip yPosition="above" xPosition="after" [yOffset]="-11" [xOffset]="-8" #emailTooltip [closeable]="true">
<img
[uiOverlayTrigger]="emailTooltip"
src="/assets/images/email_bookmark.svg"
/>
<ui-tooltip
yPosition="above"
xPosition="after"
[yOffset]="-11"
[xOffset]="-8"
#emailTooltip
[closeable]="true"
>
Per E-Mail benachrichtigt
<br />
@for (notifications of emailNotificationDates$ | async; track notifications) {
@for (notificationDate of notifications.dates; track notificationDate) {
{{ notifications.type | notificationType }} {{ notificationDate | date: 'dd.MM.yyyy | HH:mm' }} Uhr
@for (
notifications of emailNotificationDates$ | async;
track notifications
) {
@for (
notificationDate of notifications.dates;
track notificationDate
) {
{{ notifications.type | notificationType }}
{{ notificationDate | date: 'dd.MM.yyyy | HH:mm' }} Uhr
<br />
}
}
</ui-tooltip>
}
@if (hasSmsNotification$ | async) {
<img [uiOverlayTrigger]="smsTooltip" src="/assets/images/sms_bookmark.svg" />
<ui-tooltip yPosition="above" xPosition="after" [yOffset]="-11" [xOffset]="-8" #smsTooltip [closeable]="true">
<img
[uiOverlayTrigger]="smsTooltip"
src="/assets/images/sms_bookmark.svg"
/>
<ui-tooltip
yPosition="above"
xPosition="after"
[yOffset]="-11"
[xOffset]="-8"
#smsTooltip
[closeable]="true"
>
Per SMS benachrichtigt
<br />
@for (notifications of smsNotificationDates$ | async; track notifications) {
@for (notificationDate of notifications.dates; track notificationDate) {
@for (
notifications of smsNotificationDates$ | async;
track notifications
) {
@for (
notificationDate of notifications.dates;
track notificationDate
) {
{{ notificationDate | date: 'dd.MM.yyyy | HH:mm' }} Uhr
<br />
}
@@ -39,280 +83,336 @@
[productImageNavigation]="orderItem?.product?.ean"
[src]="orderItem.product?.ean | productImage"
[alt]="orderItem.product?.name"
/>
@if (hasRewardPoints$ | async) {
<ui-label [type]="Labeltype.Tag" [priority]="LabelPriority.High">
Prämie
</ui-label>
}
/>
</div>
<div class="page-pickup-shelf-details-item__details">
<div class="flex flex-row justify-between items-start mb-[1.3125rem]">
<h3
[uiElementDistance]="features"
#elementDistance="uiElementDistance"
[style.max-width.px]="elementDistance.distanceChange | async"
class="flex flex-col"
>
@if (hasRewardPoints$ | async) {
<ui-label class="w-10 mb-2">Prämie</ui-label>
}
<div class="font-normal mb-[0.375rem]">
{{ orderItem.product?.contributors }}
</div>
<div>{{ orderItem.product?.name }}</div>
</h3>
<div class="history-wrapper flex flex-col items-end justify-center">
<button
class="cta-history text-p1"
(click)="historyClick.emit(orderItem)"
>
Historie
</button>
@if (selectable$ | async) {
<input
[ngModel]="selected$ | async"
(ngModelChange)="
setSelected($event);
tracker.trackEvent({
category: 'pickup-shelf-list-item',
action: 'select',
name: orderItem?.product?.name,
value: $event ? 1 : 0,
})
"
class="isa-select-bullet mt-4"
type="checkbox"
matomoTracker
#tracker="matomo"
/>
}
</div>
</div>
<div class="page-pickup-shelf-details-item__details">
<div class="flex flex-row justify-between items-start mb-[1.3125rem]">
<h3
[uiElementDistance]="features"
#elementDistance="uiElementDistance"
[style.max-width.px]="elementDistance.distanceChange | async"
class="flex flex-col"
>
<div class="font-normal mb-[0.375rem]">{{ orderItem.product?.contributors }}</div>
<div>{{ orderItem.product?.name }}</div>
</h3>
<div class="history-wrapper flex flex-col items-end justify-center">
<button class="cta-history text-p1" (click)="historyClick.emit(orderItem)">Historie</button>
@if (selectable$ | async) {
<input
[ngModel]="selected$ | async"
(ngModelChange)="
setSelected($event);
tracker.trackEvent({
category: 'pickup-shelf-list-item',
action: 'select',
name: orderItem?.product?.name,
value: $event ? 1 : 0,
})
"
class="isa-select-bullet mt-4"
type="checkbox"
matomoTracker
#tracker="matomo"
/>
}
</div>
<div class="detail">
<div class="label">Menge</div>
<div class="value">
@if (!(canChangeQuantity$ | async)) {
{{ orderItem?.quantity }}x
}
@if (canChangeQuantity$ | async) {
<ui-quantity-dropdown
[showTrash]="false"
[range]="orderItem?.quantity"
[(ngModel)]="quantity"
(ngModelChange)="
tracker.trackEvent({
category: 'pickup-shelf-list-item',
action: 'quantity',
name: orderItem?.product?.name,
value: $event,
})
"
[showSpinner]="false"
matomoTracker
#tracker="matomo"
></ui-quantity-dropdown>
}
<span class="overall-quantity"
>(von {{ orderItem?.overallQuantity }})</span
>
</div>
</div>
@if (!!orderItem.product?.formatDetail) {
<div class="detail">
<div class="label">Menge</div>
<div class="label">Format</div>
<div class="value">
@if (!(canChangeQuantity$ | async)) {
{{ orderItem?.quantity }}x
@if (
orderItem?.product?.format &&
orderItem?.product?.format !== 'UNKNOWN'
) {
<img
class="format-icon"
[src]="
'/assets/images/Icon_' + orderItem.product?.format + '.svg'
"
alt="format icon"
/>
}
@if (canChangeQuantity$ | async) {
<ui-quantity-dropdown
[showTrash]="false"
[range]="orderItem?.quantity"
[(ngModel)]="quantity"
(ngModelChange)="
tracker.trackEvent({ category: 'pickup-shelf-list-item', action: 'quantity', name: orderItem?.product?.name, value: $event })
"
[showSpinner]="false"
matomoTracker
#tracker="matomo"
></ui-quantity-dropdown>
}
<span class="overall-quantity">(von {{ orderItem?.overallQuantity }})</span>
<span>{{ orderItem.product?.formatDetail }}</span>
</div>
</div>
@if (!!orderItem.product?.formatDetail) {
<div class="detail">
<div class="label">Format</div>
}
@if (!!orderItem.product?.ean) {
<div class="detail">
<div class="label">ISBN/EAN</div>
<div class="value">{{ orderItem.product?.ean }}</div>
</div>
}
@if (orderItem.price !== undefined || (hasRewardPoints$ | async)) {
<div class="detail">
@if (hasRewardPoints$ | async) {
<div class="label">Prämie</div>
<div class="value">
@if (orderItem?.product?.format && orderItem?.product?.format !== 'UNKNOWN') {
<img
class="format-icon"
[src]="'/assets/images/Icon_' + orderItem.product?.format + '.svg'"
alt="format icon"
/>
}
<span>{{ orderItem.product?.formatDetail }}</span>
</div>
</div>
}
@if (!!orderItem.product?.ean) {
<div class="detail">
<div class="label">ISBN/EAN</div>
<div class="value">{{ orderItem.product?.ean }}</div>
</div>
}
@if (orderItem.price !== undefined || (hasRewardPoints$ | async)) {
<div class="detail">
@if (hasRewardPoints$ | async) {
<div class="label">Prämie</div>
<div class="value">{{ rewardPoints$ | async | number: '1.0-0' }} Lesepunkte</div>
} @else {
<div class="label">Preis</div>
<div class="value">{{ orderItem.price | currency: 'EUR' }}</div>
}
</div>
}
@if (!!orderItem.retailPrice?.vat?.inPercent) {
<div class="detail">
<div class="label">MwSt</div>
<div class="value">{{ orderItem.retailPrice?.vat?.inPercent }}%</div>
</div>
}
<hr class="border-[#EDEFF0] border-t-2 my-4" />
@if (orderItem.supplier) {
<div class="detail">
<div class="label">Lieferant</div>
<div class="value">{{ orderItem.supplier }}</div>
@if (!expanded) {
<button
(click)="expanded = !expanded"
type="button"
class="page-pickup-shelf-details-item__more text-[#0556B4] font-bold flex flex-row items-center justify-center"
[class.flex-row-reverse]="!expanded"
>
<shared-icon class="mr-1" icon="arrow-back" [size]="20" [class.ml-1]="!expanded" [class.rotate-180]="!expanded"></shared-icon>
{{ expanded ? 'Weniger' : 'Mehr' }}
</button>
}
</div>
}
@if (!!orderItem.ssc || !!orderItem.sscText) {
<div class="detail">
<div class="label">Meldenummer</div>
<div class="value">{{ orderItem.ssc }} - {{ orderItem.sscText }}</div>
</div>
}
@if (expanded) {
@if (!!orderItem.targetBranch) {
<div class="detail">
<div class="label">Zielfiliale</div>
<div class="value">{{ orderItem.targetBranch }}</div>
{{ rewardPoints$ | async | number: '1.0-0' }} Lesepunkte
</div>
} @else {
<div class="label">Preis</div>
<div class="value">{{ orderItem.price | currency: 'EUR' }}</div>
}
<div class="detail">
<div class="label">
@if (
orderItemFeature(orderItem) === 'Versand' ||
orderItemFeature(orderItem) === 'B2B-Versand' ||
orderItemFeature(orderItem) === 'DIG-Versand'
) {
{{ orderItem?.estimatedDelivery ? 'Lieferung zwischen' : 'Lieferung ab' }}
}
@if (orderItemFeature(orderItem) === 'Abholung' || orderItemFeature(orderItem) === 'Rücklage') {
Abholung ab
}
</div>
@if (!!orderItem?.estimatedDelivery || !!orderItem?.estimatedShippingDate) {
<div class="value bg-[#D8DFE5] rounded w-max px-2">
@if (!!orderItem?.estimatedDelivery) {
{{ orderItem?.estimatedDelivery?.start | date: 'dd.MM.yy' }} und
{{ orderItem?.estimatedDelivery?.stop | date: 'dd.MM.yy' }}
} @else {
@if (!!orderItem?.estimatedShippingDate) {
{{ orderItem?.estimatedShippingDate | date: 'dd.MM.yy' }}
}
}
</div>
}
</div>
<hr class="border-[#EDEFF0] border-t-2 my-4" />
@if (!!orderItem?.compartmentCode) {
<div class="detail">
<div class="label">Abholfachnr.</div>
<div class="value">{{ orderItem?.compartmentCode }}</div>
</div>
}
<div class="detail">
<div class="label">Vormerker</div>
<div class="value">{{ orderItem.isPrebooked ? 'Ja' : 'Nein' }}</div>
</div>
<hr class="border-[#EDEFF0] border-t-2 my-4" />
@if (!!orderItem.paymentProcessing) {
<div class="detail">
<div class="label">Zahlungsweg</div>
<div class="value">{{ orderItem.paymentProcessing || '-' }}</div>
</div>
}
@if (!!orderItem.paymentType) {
<div class="detail">
<div class="label">Zahlungsart</div>
<div class="value">{{ orderItem.paymentType | paymentType }}</div>
</div>
}
@if (receiptCount$ | async; as count) {
<h4 class="receipt-header">
{{ count > 1 ? 'Belege' : 'Beleg' }}
</h4>
}
@for (receipt of receipts$ | async; track receipt) {
@if (!!receipt?.receiptNumber) {
<div class="detail">
<div class="label">Belegnummer</div>
<div class="value">{{ receipt?.receiptNumber }}</div>
</div>
}
@if (!!receipt?.printedDate) {
<div class="detail">
<div class="label">Erstellt am</div>
<div class="value">{{ receipt?.printedDate | date: 'dd.MM.yy | HH:mm' }} Uhr</div>
</div>
}
@if (!!receipt?.receiptText) {
<div class="detail">
<div class="label">Rechnungstext</div>
<div class="value">{{ receipt?.receiptText || '-' }}</div>
</div>
}
@if (!!receipt?.receiptType) {
<div class="detail">
<div class="label">Belegart</div>
<div class="value">
{{ receipt?.receiptType === 1 ? 'Lieferschein' : receipt?.receiptType === 64 ? 'Zahlungsbeleg' : '-' }}
</div>
</div>
}
}
@if (!!orderItem.paymentProcessing || !!orderItem.paymentType || !!(receiptCount$ | async)) {
<hr
class="border-[#EDEFF0] border-t-2 my-4"
/>
}
@if (expanded) {
</div>
}
@if (!!orderItem.retailPrice?.vat?.inPercent) {
<div class="detail">
<div class="label">MwSt</div>
<div class="value">{{ orderItem.retailPrice?.vat?.inPercent }}%</div>
</div>
}
<hr class="border-[#EDEFF0] border-t-2 my-4" />
@if (orderItem.supplier) {
<div class="detail">
<div class="label">Lieferant</div>
<div class="value">{{ orderItem.supplier }}</div>
@if (!expanded) {
<button
(click)="expanded = !expanded"
type="button"
class="page-pickup-shelf-details-item__less text-[#0556B4] font-bold flex flex-row items-center justify-center"
>
<shared-icon class="mr-1" icon="arrow-back" [size]="20"></shared-icon>
Weniger
class="page-pickup-shelf-details-item__more text-[#0556B4] font-bold flex flex-row items-center justify-center"
[class.flex-row-reverse]="!expanded"
>
<shared-icon
class="mr-1"
icon="arrow-back"
[size]="20"
[class.ml-1]="!expanded"
[class.rotate-180]="!expanded"
></shared-icon>
{{ expanded ? 'Weniger' : 'Mehr' }}
</button>
}
</div>
}
@if (!!orderItem.ssc || !!orderItem.sscText) {
<div class="detail">
<div class="label">Meldenummer</div>
<div class="value">{{ orderItem.ssc }} - {{ orderItem.sscText }}</div>
</div>
}
@if (expanded) {
@if (!!orderItem.targetBranch) {
<div class="detail">
<div class="label">Zielfiliale</div>
<div class="value">{{ orderItem.targetBranch }}</div>
</div>
}
<div class="page-pickup-shelf-details-item__comment flex flex-col items-start mt-[1.625rem]">
<div class="label mb-[0.375rem]">Anmerkung</div>
<div class="flex flex-row w-full">
<textarea
matInput
cdkTextareaAutosize
#autosize="cdkTextareaAutosize"
cdkAutosizeMinRows="1"
cdkAutosizeMaxRows="5"
maxlength="200"
#specialCommentInput
(keydown.delete)="triggerResize()"
(keydown.backspace)="triggerResize()"
type="text"
name="comment"
placeholder="Eine Anmerkung hinzufügen"
[formControl]="specialCommentControl"
[class.inactive]="!specialCommentControl.dirty"
></textarea>
<div class="comment-actions">
@if (!!specialCommentControl.value?.length) {
<button
type="reset"
class="clear"
(click)="specialCommentControl.setValue(''); saveSpecialComment(); triggerResize()"
>
<shared-icon icon="close" [size]="24"></shared-icon>
</button>
}
@if (specialCommentControl?.enabled && specialCommentControl.dirty) {
<button
class="cta-save"
type="submit"
(click)="saveSpecialComment()"
matomoClickCategory="pickup-shelf-details-item"
matomoClickAction="save"
matomoClickName="special-comment"
>
Speichern
</button>
<div class="detail">
<div class="label">
@if (
orderItemFeature(orderItem) === 'Versand' ||
orderItemFeature(orderItem) === 'B2B-Versand' ||
orderItemFeature(orderItem) === 'DIG-Versand'
) {
{{
orderItem?.estimatedDelivery
? 'Lieferung zwischen'
: 'Lieferung ab'
}}
}
@if (
orderItemFeature(orderItem) === 'Abholung' ||
orderItemFeature(orderItem) === 'Rücklage'
) {
Abholung ab
}
</div>
@if (
!!orderItem?.estimatedDelivery || !!orderItem?.estimatedShippingDate
) {
<div class="value bg-[#D8DFE5] rounded w-max px-2">
@if (!!orderItem?.estimatedDelivery) {
{{ orderItem?.estimatedDelivery?.start | date: 'dd.MM.yy' }} und
{{ orderItem?.estimatedDelivery?.stop | date: 'dd.MM.yy' }}
} @else {
@if (!!orderItem?.estimatedShippingDate) {
{{ orderItem?.estimatedShippingDate | date: 'dd.MM.yy' }}
}
}
</div>
}
</div>
<hr class="border-[#EDEFF0] border-t-2 my-4" />
@if (!!orderItem?.compartmentCode) {
<div class="detail">
<div class="label">Abholfachnr.</div>
<div class="value">{{ orderItem?.compartmentCode }}</div>
</div>
}
<div class="detail">
<div class="label">Vormerker</div>
<div class="value">{{ orderItem.isPrebooked ? 'Ja' : 'Nein' }}</div>
</div>
<hr class="border-[#EDEFF0] border-t-2 my-4" />
@if (!!orderItem.paymentProcessing) {
<div class="detail">
<div class="label">Zahlungsweg</div>
<div class="value">{{ orderItem.paymentProcessing || '-' }}</div>
</div>
}
@if (!!orderItem.paymentType) {
<div class="detail">
<div class="label">Zahlungsart</div>
<div class="value">{{ orderItem.paymentType | paymentType }}</div>
</div>
}
@if (receiptCount$ | async; as count) {
<h4 class="receipt-header">
{{ count > 1 ? 'Belege' : 'Beleg' }}
</h4>
}
@for (receipt of receipts$ | async; track receipt) {
@if (!!receipt?.receiptNumber) {
<div class="detail">
<div class="label">Belegnummer</div>
<div class="value">{{ receipt?.receiptNumber }}</div>
</div>
}
@if (!!receipt?.printedDate) {
<div class="detail">
<div class="label">Erstellt am</div>
<div class="value">
{{ receipt?.printedDate | date: 'dd.MM.yy | HH:mm' }} Uhr
</div>
</div>
}
@if (!!receipt?.receiptText) {
<div class="detail">
<div class="label">Rechnungstext</div>
<div class="value">{{ receipt?.receiptText || '-' }}</div>
</div>
}
@if (!!receipt?.receiptType) {
<div class="detail">
<div class="label">Belegart</div>
<div class="value">
{{
receipt?.receiptType === 1
? 'Lieferschein'
: receipt?.receiptType === 64
? 'Zahlungsbeleg'
: '-'
}}
</div>
</div>
}
}
@if (
!!orderItem.paymentProcessing ||
!!orderItem.paymentType ||
!!(receiptCount$ | async)
) {
<hr class="border-[#EDEFF0] border-t-2 my-4" />
}
@if (expanded) {
<button
(click)="expanded = !expanded"
type="button"
class="page-pickup-shelf-details-item__less text-[#0556B4] font-bold flex flex-row items-center justify-center"
>
<shared-icon
class="mr-1"
icon="arrow-back"
[size]="20"
></shared-icon>
Weniger
</button>
}
}
<div
class="page-pickup-shelf-details-item__comment flex flex-col items-start mt-[1.625rem]"
>
<div class="label mb-[0.375rem]">Anmerkung</div>
<div class="flex flex-row w-full">
<textarea
matInput
cdkTextareaAutosize
#autosize="cdkTextareaAutosize"
cdkAutosizeMinRows="1"
cdkAutosizeMaxRows="5"
maxlength="200"
#specialCommentInput
(keydown.delete)="triggerResize()"
(keydown.backspace)="triggerResize()"
type="text"
name="comment"
placeholder="Eine Anmerkung hinzufügen"
[formControl]="specialCommentControl"
[class.inactive]="!specialCommentControl.dirty"
></textarea>
<div class="comment-actions">
@if (!!specialCommentControl.value?.length) {
<button
type="reset"
class="clear"
(click)="
specialCommentControl.setValue('');
saveSpecialComment();
triggerResize()
"
>
<shared-icon icon="close" [size]="24"></shared-icon>
</button>
}
@if (
specialCommentControl?.enabled && specialCommentControl.dirty
) {
<button
class="cta-save"
type="submit"
(click)="saveSpecialComment()"
matomoClickCategory="pickup-shelf-details-item"
matomoClickAction="save"
matomoClickName="special-comment"
>
Speichern
</button>
}
</div>
</div>
</div>
</div>
}
</div>
}

View File

@@ -1,5 +1,10 @@
import { CdkTextareaAutosize, TextFieldModule } from '@angular/cdk/text-field';
import { AsyncPipe, CurrencyPipe, DatePipe, DecimalPipe } from '@angular/common';
import {
AsyncPipe,
CurrencyPipe,
DatePipe,
DecimalPipe,
} from '@angular/common';
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
@@ -11,12 +16,23 @@ import {
inject,
OnDestroy,
} from '@angular/core';
import { FormsModule, ReactiveFormsModule, UntypedFormControl } from '@angular/forms';
import { NavigateOnClickDirective, ProductImageModule } from '@cdn/product-image';
import { DBHOrderItemListItemDTO, OrderDTO, ReceiptDTO } from '@generated/swagger/oms-api';
import {
FormsModule,
ReactiveFormsModule,
UntypedFormControl,
} from '@angular/forms';
import {
NavigateOnClickDirective,
ProductImageModule,
} from '@cdn/product-image';
import {
DBHOrderItemListItemDTO,
OrderDTO,
ReceiptDTO,
} from '@generated/swagger/oms-api';
import { getOrderItemRewardFeature } from '@isa/oms/data-access';
import { UiCommonModule } from '@ui/common';
import { LabelComponent, Labeltype, LabelPriority } from '@isa/ui/label';
import { LabelComponent } from '@isa/ui/label';
import { UiTooltipModule } from '@ui/tooltip';
import { PickupShelfPaymentTypePipe } from '../pipes/payment-type.pipe';
import { IconModule } from '@shared/components/icon';
@@ -60,8 +76,8 @@ export interface PickUpShelfDetailsItemComponentState {
NotificationTypePipe,
NavigateOnClickDirective,
MatomoModule,
LabelComponent
],
LabelComponent,
],
})
export class PickUpShelfDetailsItemComponent
extends ComponentStore<PickUpShelfDetailsItemComponentState>
@@ -88,7 +104,11 @@ export class PickUpShelfDetailsItemComponent
this._store.selectOrderItem(this.orderItem, false);
}
this.patchState({ orderItem, quantity: orderItem?.quantity, more: false });
this.patchState({
orderItem,
quantity: orderItem?.quantity,
more: false,
});
this.specialCommentControl.reset(orderItem?.specialComment);
// Add New OrderItem to selected list if selected was set to true by its input
if (this.get((s) => s.selected)) {
@@ -110,16 +130,24 @@ export class PickUpShelfDetailsItemComponent
readonly orderItem$ = this.select((s) => s.orderItem);
emailNotificationDates$ = this.orderItem$.pipe(
switchMap((orderItem) => this._store.getEmailNotificationDate$(orderItem?.orderItemSubsetId)),
switchMap((orderItem) =>
this._store.getEmailNotificationDate$(orderItem?.orderItemSubsetId),
),
);
hasEmailNotification$ = this.emailNotificationDates$.pipe(map((dates) => dates?.length > 0));
hasEmailNotification$ = this.emailNotificationDates$.pipe(
map((dates) => dates?.length > 0),
);
smsNotificationDates$ = this.orderItem$.pipe(
switchMap((orderItem) => this._store.getSmsNotificationDate$(orderItem?.orderItemSubsetId)),
switchMap((orderItem) =>
this._store.getSmsNotificationDate$(orderItem?.orderItemSubsetId),
),
);
hasSmsNotification$ = this.smsNotificationDates$.pipe(map((dates) => dates?.length > 0));
hasSmsNotification$ = this.smsNotificationDates$.pipe(
map((dates) => dates?.length > 0),
);
/**
* Observable that indicates whether the order item has reward points (Lesepunkte).
@@ -137,8 +165,15 @@ export class PickUpShelfDetailsItemComponent
map((orderItem) => getOrderItemRewardFeature(orderItem)),
);
canChangeQuantity$ = combineLatest([this.orderItem$, this._store.fetchPartial$]).pipe(
map(([item, partialPickup]) => ([16, 8192].includes(item?.processingStatus) || partialPickup) && item.quantity > 1),
canChangeQuantity$ = combineLatest([
this.orderItem$,
this._store.fetchPartial$,
]).pipe(
map(
([item, partialPickup]) =>
([16, 8192].includes(item?.processingStatus) || partialPickup) &&
item.quantity > 1,
),
);
get quantity() {
@@ -147,7 +182,10 @@ export class PickUpShelfDetailsItemComponent
set quantity(quantity: number) {
if (this.quantity !== quantity) {
this.patchState({ quantity });
this._store.setSelectedOrderItemQuantity({ orderItemSubsetId: this.orderItem.orderItemSubsetId, quantity });
this._store.setSelectedOrderItemQuantity({
orderItemSubsetId: this.orderItem.orderItemSubsetId,
quantity,
});
}
}
@@ -155,7 +193,9 @@ export class PickUpShelfDetailsItemComponent
@Input()
get selected() {
return this._store.selectedOrderItemIds.includes(this.orderItem?.orderItemSubsetId);
return this._store.selectedOrderItemIds.includes(
this.orderItem?.orderItemSubsetId,
);
}
set selected(selected: boolean) {
if (this.selected !== selected) {
@@ -164,23 +204,40 @@ export class PickUpShelfDetailsItemComponent
}
}
readonly selected$ = combineLatest([this.orderItem$, this._store.selectedOrderItemIds$]).pipe(
map(([orderItem, selectedItems]) => selectedItems.includes(orderItem?.orderItemSubsetId)),
readonly selected$ = combineLatest([
this.orderItem$,
this._store.selectedOrderItemIds$,
]).pipe(
map(([orderItem, selectedItems]) =>
selectedItems.includes(orderItem?.orderItemSubsetId),
),
);
@Output()
selectedChange = new EventEmitter<boolean>();
get isItemSelectable() {
return this._store.orderItems?.some((item) => !!item?.actions && item?.actions?.length > 0);
return this._store.orderItems?.some(
(item) => !!item?.actions && item?.actions?.length > 0,
);
}
get selectable() {
return this.isItemSelectable && this._store.orderItems.length > 1 && this._store.fetchPartial;
return (
this.isItemSelectable &&
this._store.orderItems.length > 1 &&
this._store.fetchPartial
);
}
readonly selectable$ = combineLatest([this._store.orderItems$, this._store.fetchPartial$]).pipe(
map(([orderItems, fetchPartial]) => orderItems.length > 1 && this.isItemSelectable && fetchPartial),
readonly selectable$ = combineLatest([
this._store.orderItems$,
this._store.fetchPartial$,
]).pipe(
map(
([orderItems, fetchPartial]) =>
orderItems.length > 1 && this.isItemSelectable && fetchPartial,
),
);
get receipts() {
@@ -193,7 +250,9 @@ export class PickUpShelfDetailsItemComponent
readonly receipts$ = this._store.receipts$;
readonly receiptCount$ = this.receipts$.pipe(map((receipts) => receipts?.length));
readonly receiptCount$ = this.receipts$.pipe(
map((receipts) => receipts?.length),
);
specialCommentControl = new UntypedFormControl();
@@ -203,10 +262,6 @@ export class PickUpShelfDetailsItemComponent
expanded = false;
// Expose to template
Labeltype = Labeltype;
LabelPriority = LabelPriority;
constructor(private _cdr: ChangeDetectorRef) {
super({
more: false,
@@ -239,8 +294,9 @@ export class PickUpShelfDetailsItemComponent
orderItemFeature(orderItemListItem: DBHOrderItemListItemDTO) {
const orderItems = this.order?.items;
return orderItems?.find((orderItem) => orderItem.data.id === orderItemListItem.orderItemId)?.data?.features
?.orderType;
return orderItems?.find(
(orderItem) => orderItem.data.id === orderItemListItem.orderItemId,
)?.data?.features?.orderType;
}
triggerResize() {

View File

@@ -4,12 +4,16 @@
[routerLinkActive]="!isTablet && !primaryOutletActive ? 'active' : ''"
queryParamsHandling="preserve"
(click)="onDetailsClick()"
>
>
<div
class="page-pickup-shelf-list-item__item-grid-container"
[class.page-pickup-shelf-list-item__item-grid-container-main]="primaryOutletActive"
[class.page-pickup-shelf-list-item__item-grid-container-secondary]="primaryOutletActive && isItemSelectable === undefined"
>
[class.page-pickup-shelf-list-item__item-grid-container-main]="
primaryOutletActive
"
[class.page-pickup-shelf-list-item__item-grid-container-secondary]="
primaryOutletActive && isItemSelectable === undefined
"
>
<div class="page-pickup-shelf-list-item__item-thumbnail text-center">
@if (item?.product?.ean | productImage; as productImage) {
<img
@@ -18,88 +22,126 @@
[productImageNavigation]="item?.product?.ean"
[src]="productImage"
[alt]="item?.product?.name"
/>
}
@if (hasRewardPoints) {
<ui-label [type]="Labeltype.Tag" [priority]="LabelPriority.High">
Prämie
</ui-label>
/>
}
</div>
<div
class="page-pickup-shelf-list-item__item-title-contributors flex flex-col"
[class.mr-32]="showCompartmentCode && item.features?.paid && (isTablet || isDesktopSmall || primaryOutletActive)"
>
[class.mr-32]="
showCompartmentCode &&
item.features?.paid &&
(isTablet || isDesktopSmall || primaryOutletActive)
"
>
@if (hasRewardPoints) {
<ui-label class="w-10 mb-2">Prämie</ui-label>
}
<div
class="page-pickup-shelf-list-item__item-contributors text-p2 font-normal text-ellipsis overflow-hidden max-w-[24rem] whitespace-nowrap mb-[0.375rem]"
>
@for (contributor of contributors; track contributor; let last = $last) {
>
@for (
contributor of contributors;
track contributor;
let last = $last
) {
{{ contributor }}{{ last ? '' : ';' }}
}
</div>
<div
class="page-pickup-shelf-list-item__item-title font-bold text-p1 desktop-small:text-p2"
[class.page-pickup-shelf-list-item__item-title-bigger-text-size]="!primaryOutletActive"
>
[class.page-pickup-shelf-list-item__item-title-bigger-text-size]="
!primaryOutletActive
"
>
{{ item?.product?.name }}
</div>
</div>
<div class="page-pickup-shelf-list-item__item-ean-quantity-changed flex flex-col">
<div class="page-pickup-shelf-list-item__item-ean text-p2 flex flex-row mb-[0.375rem]" [attr.data-ean]="item?.product?.ean">
<div
class="page-pickup-shelf-list-item__item-ean-quantity-changed flex flex-col"
>
<div
class="page-pickup-shelf-list-item__item-ean text-p2 flex flex-row mb-[0.375rem]"
[attr.data-ean]="item?.product?.ean"
>
<div class="min-w-[7.5rem]">EAN</div>
<div class="font-bold">{{ item?.product?.ean }}</div>
</div>
<div class="page-pickup-shelf-list-item__item-quantity flex flex-row text-p2 mb-[0.375rem]" [attr.data-menge]="item.quantity">
<div
class="page-pickup-shelf-list-item__item-quantity flex flex-row text-p2 mb-[0.375rem]"
[attr.data-menge]="item.quantity"
>
<div class="min-w-[7.5rem]">Menge</div>
<div class="font-bold">{{ item.quantity }} x</div>
</div>
<div class="page-pickup-shelf-list-item__item-changed text-p2" [attr.data-geaendert]="item?.processingStatusDate">
<div
class="page-pickup-shelf-list-item__item-changed text-p2"
[attr.data-geaendert]="item?.processingStatusDate"
>
@if (showChangeDate) {
<div class="flex flex-row">
<div class="min-w-[7.5rem]">Geändert</div>
<div class="font-bold">{{ item?.processingStatusDate | date: 'dd.MM.yy | HH:mm' }} Uhr</div>
<div class="font-bold">
{{ item?.processingStatusDate | date: 'dd.MM.yy | HH:mm' }} Uhr
</div>
</div>
} @else {
<div class="flex flex-row" [attr.data-bestelldatum]="item?.orderDate">
<div class="min-w-[7.5rem]">Bestelldatum</div>
<div class="font-bold">{{ item?.orderDate | date: 'dd.MM.yy | HH:mm' }} Uhr</div>
<div class="font-bold">
{{ item?.orderDate | date: 'dd.MM.yy | HH:mm' }} Uhr
</div>
</div>
}
</div>
</div>
<div class="page-pickup-shelf-list-item__item-order-number-processing-status-paid flex flex-col">
<div
class="page-pickup-shelf-list-item__item-order-number-processing-status-paid flex flex-col"
>
@if (showCompartmentCode) {
<div
class="page-pickup-shelf-list-item__item-order-number text-h3 mb-[0.375rem] self-end font-bold break-all text-right"
[attr.data-compartment-code]="item?.compartmentCode"
[attr.data-compartment-info]="item?.compartmentInfo"
>
{{ item?.compartmentCode }}{{ item?.compartmentInfo && '_' + item?.compartmentInfo }}
>
{{ item?.compartmentCode
}}{{ item?.compartmentInfo && '_' + item?.compartmentInfo }}
</div>
}
<div class="page-pickup-shelf-list-item__item-processing-paid-status flex flex-col font-bold self-end text-p2 mb-[0.375rem]">
<div
class="page-pickup-shelf-list-item__item-processing-paid-status flex flex-col font-bold self-end text-p2 mb-[0.375rem]"
>
<div
class="page-pickup-shelf-list-item__item-processing-status flex flex-row mb-[0.375rem] rounded p-3 py-[0.125rem] text-white"
[style]="processingStatusColor"
[attr.data-processing-status]="item.processingStatus"
>
>
{{ item.processingStatus | processingStatus }}
</div>
<div class="page-pickup-shelf-list-item__item-paid self-end flex flex-row">
@if (item.features?.paid && (isTablet || isDesktopSmall || primaryOutletActive)) {
<div
class="page-pickup-shelf-list-item__item-paid self-end flex flex-row"
>
@if (
item.features?.paid &&
(isTablet || isDesktopSmall || primaryOutletActive)
) {
<div
class="font-bold flex flex-row items-center justify-center text-p2 text-[#26830C]"
[attr.data-paid]="item.features?.paid"
>
<shared-icon class="flex items-center justify-center mr-[0.375rem]" [size]="24" icon="credit-card"></shared-icon>
>
<shared-icon
class="flex items-center justify-center mr-[0.375rem]"
[size]="24"
icon="credit-card"
></shared-icon>
{{ item.features?.paid }}
</div>
}
@@ -110,28 +152,35 @@
@if (isItemSelectable) {
<div
class="page-pickup-shelf-list-item__item-select-bullet justify-self-end self-center mb-2"
[class.page-pickup-shelf-list-item__item-select-bullet-primary]="primaryOutletActive"
>
[class.page-pickup-shelf-list-item__item-select-bullet-primary]="
primaryOutletActive
"
>
<input
(click)="$event.stopPropagation()"
[ngModel]="selectedItem"
(ngModelChange)="
setSelected();
tracker.trackEvent({ category: 'pickup-shelf-list-item', action: 'select', name: item?.product?.name, value: $event ? 1 : 0 })
"
(ngModelChange)="
setSelected();
tracker.trackEvent({
category: 'pickup-shelf-list-item',
action: 'select',
name: item?.product?.name,
value: $event ? 1 : 0,
})
"
class="isa-select-bullet"
type="checkbox"
matomoTracker
#tracker="matomo"
/>
</div>
}
<div
[attr.data-special-comment]="item?.specialComment"
class="page-pickup-shelf-list-item__item-special-comment break-words font-bold text-p2 mt-[0.375rem] text-[#996900]"
>
{{ item?.specialComment }}
/>
</div>
}
<div
[attr.data-special-comment]="item?.specialComment"
class="page-pickup-shelf-list-item__item-special-comment break-words font-bold text-p2 mt-[0.375rem] text-[#996900]"
>
{{ item?.specialComment }}
</div>
</a>
</div>
</a>

View File

@@ -1,12 +1,21 @@
import { DatePipe } from '@angular/common';
import { ChangeDetectionStrategy, Component, ElementRef, Input, inject } from '@angular/core';
import {
ChangeDetectionStrategy,
Component,
ElementRef,
Input,
inject,
} from '@angular/core';
import { RouterLink, RouterLinkActive } from '@angular/router';
import { NavigateOnClickDirective, ProductImageModule } from '@cdn/product-image';
import {
NavigateOnClickDirective,
ProductImageModule,
} from '@cdn/product-image';
import { EnvironmentService } from '@core/environment';
import { IconModule } from '@shared/components/icon';
import { DBHOrderItemListItemDTO } from '@generated/swagger/oms-api';
import { getOrderItemRewardFeature } from '@isa/oms/data-access';
import { LabelComponent, Labeltype, LabelPriority } from '@isa/ui/label';
import { LabelComponent } from '@isa/ui/label';
import { UiCommonModule } from '@ui/common';
import { PickupShelfProcessingStatusPipe } from '../pipes/processing-status.pipe';
import { FormsModule } from '@angular/forms';
@@ -32,8 +41,8 @@ import { MatomoModule } from 'ngx-matomo-client';
PickupShelfProcessingStatusPipe,
NavigateOnClickDirective,
MatomoModule,
LabelComponent
],
LabelComponent,
],
providers: [PickupShelfProcessingStatusPipe],
})
export class PickUpShelfListItemComponent {
@@ -45,6 +54,7 @@ export class PickUpShelfListItemComponent {
@Input() primaryOutletActive = false;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@Input() itemDetailsLink: any[] = [];
@Input() isItemSelectable?: boolean = undefined;
@@ -64,7 +74,9 @@ export class PickUpShelfListItemComponent {
}
get contributors() {
return this.item?.product?.contributors?.split(';').map((val) => val.trim());
return this.item?.product?.contributors
?.split(';')
.map((val) => val.trim());
}
get showChangeDate() {
@@ -73,11 +85,19 @@ export class PickUpShelfListItemComponent {
// Zeige nur CompartmentCode an wenn verfügbar
get showCompartmentCode() {
return !!this.item?.compartmentCode && (this.isTablet || this.isDesktopSmall || this.primaryOutletActive);
return (
!!this.item?.compartmentCode &&
(this.isTablet || this.isDesktopSmall || this.primaryOutletActive)
);
}
get processingStatusColor() {
return { 'background-color': this._processingStatusPipe.transform(this.item?.processingStatus, true) };
return {
'background-color': this._processingStatusPipe.transform(
this.item?.processingStatus,
true,
),
};
}
/**
@@ -90,14 +110,12 @@ export class PickUpShelfListItemComponent {
selected$ = this.store.selectedListItems$.pipe(
map((selectedListItems) =>
selectedListItems?.find((item) => item?.orderItemSubsetId === this.item?.orderItemSubsetId),
selectedListItems?.find(
(item) => item?.orderItemSubsetId === this.item?.orderItemSubsetId,
),
),
);
// Expose to template
Labeltype = Labeltype;
LabelPriority = LabelPriority;
constructor(
private _elRef: ElementRef,
private _environment: EnvironmentService,
@@ -125,7 +143,9 @@ export class PickUpShelfListItemComponent {
store: 'PickupShelfDetailsStore',
})) ?? [];
return items.some((i) => i.orderItemSubsetId === this.item.orderItemSubsetId);
return items.some(
(i) => i.orderItemSubsetId === this.item.orderItemSubsetId,
);
}
addOrderItemIntoCache() {
@@ -144,7 +164,10 @@ export class PickUpShelfListItemComponent {
}
scrollIntoView() {
this._elRef.nativeElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
this._elRef.nativeElement.scrollIntoView({
behavior: 'smooth',
block: 'center',
});
}
setSelected() {

View File

@@ -1,27 +1,36 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { PageTaskCalendarComponent } from './page-task-calendar.component';
import { CalendarComponent } from './pages/calendar/calendar.component';
import { CalendarModule } from './pages/calendar/calendar.module';
import { TaskSearchComponent } from './pages/task-search';
import { TasksComponent } from './pages/tasks/tasks.component';
import { TasksModule } from './pages/tasks/tasks.module';
const routes: Routes = [
{
path: '',
component: PageTaskCalendarComponent,
children: [
{ path: 'calendar', component: CalendarComponent },
{ path: 'tasks', component: TasksComponent },
{ path: 'search', component: TaskSearchComponent },
{ path: '', pathMatch: 'full', redirectTo: 'tasks' },
],
},
];
@NgModule({
imports: [RouterModule.forChild(routes), CalendarModule, TasksModule],
exports: [RouterModule],
})
export class PageTaskCalendarRoutingModule {}
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { PageTaskCalendarComponent } from './page-task-calendar.component';
import { CalendarComponent } from './pages/calendar/calendar.component';
import { CalendarModule } from './pages/calendar/calendar.module';
import { TaskSearchComponent } from './pages/task-search';
import { TasksComponent } from './pages/tasks/tasks.component';
import { TasksModule } from './pages/tasks/tasks.module';
const routes: Routes = [
{
path: '',
component: PageTaskCalendarComponent,
title: 'Tätigkeitskalender',
children: [
{ path: 'calendar', component: CalendarComponent },
{
path: 'tasks',
title: 'Tätigkeitskalender - Aufgaben',
component: TasksComponent,
},
{
path: 'search',
title: 'Tätigkeitskalender - Suche',
component: TaskSearchComponent,
},
{ path: '', pathMatch: 'full', redirectTo: 'tasks' },
],
},
];
@NgModule({
imports: [RouterModule.forChild(routes), CalendarModule, TasksModule],
exports: [RouterModule],
})
export class PageTaskCalendarRoutingModule {}

View File

@@ -20,6 +20,7 @@
@import "../../../libs/ui/skeleton-loader/src/skeleton-loader.scss";
@import "../../../libs/ui/tooltip/src/tooltip.scss";
@import "../../../libs/ui/label/src/label.scss";
@import "../../../libs/ui/notice/src/notice.scss";
@import "../../../libs/ui/switch/src/switch.scss";
.input-control {

View File

@@ -1,51 +1,106 @@
import {
DropdownAppearance,
DropdownButtonComponent,
DropdownOptionComponent,
} from '@isa/ui/input-controls';
import { type Meta, type StoryObj, argsToTemplate, moduleMetadata } from '@storybook/angular';
type DropdownInputProps = {
value: string;
label: string;
appearance: DropdownAppearance;
};
const meta: Meta<DropdownInputProps> = {
title: 'ui/input-controls/Dropdown',
decorators: [
moduleMetadata({
imports: [DropdownButtonComponent, DropdownOptionComponent],
}),
],
argTypes: {
value: { control: 'text' },
label: { control: 'text' },
appearance: {
control: 'select',
options: Object.values(DropdownAppearance),
},
},
render: (args) => ({
props: args,
template: `
<ui-dropdown ${argsToTemplate(args)}>
<ui-dropdown-option value="">Select an option</ui-dropdown-option>
<ui-dropdown-option value="1">Option 1</ui-dropdown-option>
<ui-dropdown-option value="2">Option 2</ui-dropdown-option>
<ui-dropdown-option value="3">Option 3</ui-dropdown-option>
</ui-dropdown>
`,
}),
};
export default meta;
type Story = StoryObj<DropdownInputProps>;
export const Default: Story = {
args: {
value: undefined,
label: 'Label',
},
};
import {
DropdownAppearance,
DropdownButtonComponent,
DropdownFilterComponent,
DropdownOptionComponent,
} from '@isa/ui/input-controls';
import {
type Meta,
type StoryObj,
argsToTemplate,
moduleMetadata,
} from '@storybook/angular';
type DropdownInputProps = {
value: string;
label: string;
appearance: DropdownAppearance;
};
const meta: Meta<DropdownInputProps> = {
title: 'ui/input-controls/Dropdown',
decorators: [
moduleMetadata({
imports: [
DropdownButtonComponent,
DropdownFilterComponent,
DropdownOptionComponent,
],
}),
],
argTypes: {
value: { control: 'text' },
label: { control: 'text' },
appearance: {
control: 'select',
options: Object.values(DropdownAppearance),
},
},
render: (args) => ({
props: args,
template: `
<ui-dropdown ${argsToTemplate(args)}>
<ui-dropdown-option value="">Select an option</ui-dropdown-option>
<ui-dropdown-option value="1">Option 1</ui-dropdown-option>
<ui-dropdown-option value="2">Option 2</ui-dropdown-option>
<ui-dropdown-option value="3">Option 3</ui-dropdown-option>
</ui-dropdown>
`,
}),
};
export default meta;
type Story = StoryObj<DropdownInputProps>;
export const Default: Story = {
args: {
value: undefined,
label: 'Label',
},
};
export const WithFilter: Story = {
args: {
value: undefined,
label: 'Select a country',
},
render: (args) => ({
props: args,
template: `
<ui-dropdown ${argsToTemplate(args)}>
<ui-dropdown-filter placeholder="Search countries..."></ui-dropdown-filter>
<ui-dropdown-option value="">Select a country</ui-dropdown-option>
<ui-dropdown-option value="de">Germany</ui-dropdown-option>
<ui-dropdown-option value="at">Austria</ui-dropdown-option>
<ui-dropdown-option value="ch">Switzerland</ui-dropdown-option>
<ui-dropdown-option value="fr">France</ui-dropdown-option>
<ui-dropdown-option value="it">Italy</ui-dropdown-option>
<ui-dropdown-option value="es">Spain</ui-dropdown-option>
<ui-dropdown-option value="nl">Netherlands</ui-dropdown-option>
<ui-dropdown-option value="be">Belgium</ui-dropdown-option>
<ui-dropdown-option value="pl">Poland</ui-dropdown-option>
<ui-dropdown-option value="cz">Czech Republic</ui-dropdown-option>
</ui-dropdown>
`,
}),
};
export const GreyAppearance: Story = {
args: {
value: undefined,
label: 'Filter',
appearance: DropdownAppearance.Grey,
},
};
export const Disabled: Story = {
render: () => ({
template: `
<ui-dropdown label="Disabled dropdown" [disabled]="true">
<ui-dropdown-option value="1">Option 1</ui-dropdown-option>
<ui-dropdown-option value="2">Option 2</ui-dropdown-option>
</ui-dropdown>
`,
}),
};

View File

@@ -1,33 +1,25 @@
import { argsToTemplate, type Meta, type StoryObj } from '@storybook/angular';
import { Labeltype, LabelPriority, LabelComponent } from '@isa/ui/label';
import { LabelComponent } from '@isa/ui/label';
type UiLabelInputs = {
type: Labeltype;
priority: LabelPriority;
type LabelInputs = {
active: boolean;
};
const meta: Meta<UiLabelInputs> = {
const meta: Meta<LabelInputs> = {
component: LabelComponent,
title: 'ui/label/Label',
argTypes: {
type: {
control: { type: 'select' },
options: Object.values(Labeltype),
description: 'Determines the label type',
},
priority: {
control: { type: 'select' },
options: Object.values(LabelPriority),
description: 'Determines the label priority',
active: {
control: { type: 'boolean' },
description: 'Determines if the label is active (hover/pressed state)',
},
},
args: {
type: 'tag',
priority: 'high',
active: false,
},
render: (args) => ({
props: args,
template: `<ui-label ${argsToTemplate(args)}>Prio 1</ui-label>`,
template: `<ui-label ${argsToTemplate(args)}>Prämie</ui-label>`,
}),
};
export default meta;
@@ -37,3 +29,21 @@ type Story = StoryObj<LabelComponent>;
export const Default: Story = {
args: {},
};
export const Active: Story = {
args: {
active: true,
},
};
export const RewardExample: Story = {
args: {},
render: () => ({
template: `
<div style="display: flex; gap: 1rem; align-items: center;">
<ui-label>Prämie</ui-label>
<ui-label [active]="true">Active</ui-label>
</div>
`,
}),
};

View File

@@ -0,0 +1,59 @@
import { argsToTemplate, type Meta, type StoryObj } from '@storybook/angular';
import { PrioLabelComponent } from '@isa/ui/label';
type PrioLabelInputs = {
priority: 1 | 2;
};
const meta: Meta<PrioLabelInputs> = {
component: PrioLabelComponent,
title: 'ui/label/PrioLabel',
argTypes: {
priority: {
control: { type: 'select' },
options: [1, 2],
description: 'The priority level (1 = high, 2 = low)',
},
},
args: {
priority: 1,
},
render: (args) => ({
props: args,
template: `<ui-prio-label ${argsToTemplate(args)}>Pflicht</ui-prio-label>`,
}),
};
export default meta;
type Story = StoryObj<PrioLabelComponent>;
export const Priority1: Story = {
args: {
priority: 1,
},
render: (args) => ({
props: args,
template: `<ui-prio-label [priority]="priority">Pflicht</ui-prio-label>`,
}),
};
export const Priority2: Story = {
args: {
priority: 2,
},
render: (args) => ({
props: args,
template: `<ui-prio-label [priority]="priority">Prio 2</ui-prio-label>`,
}),
};
export const AllPriorities: Story = {
render: () => ({
template: `
<div style="display: flex; gap: 1rem; align-items: center;">
<ui-prio-label [priority]="1">Pflicht</ui-prio-label>
<ui-prio-label [priority]="2">Prio 2</ui-prio-label>
</div>
`,
}),
};

View File

@@ -0,0 +1,70 @@
import { argsToTemplate, type Meta, type StoryObj } from '@storybook/angular';
import { NoticeComponent, NoticePriority } from '@isa/ui/notice';
type NoticeInputs = {
priority: NoticePriority;
};
const meta: Meta<NoticeInputs> = {
component: NoticeComponent,
title: 'ui/notice/Notice',
argTypes: {
priority: {
control: { type: 'select' },
options: Object.values(NoticePriority),
description: 'The priority level (high, medium, low)',
},
},
args: {
priority: NoticePriority.High,
},
render: (args) => ({
props: args,
template: `<ui-notice ${argsToTemplate(args)}>Important message</ui-notice>`,
}),
};
export default meta;
type Story = StoryObj<NoticeComponent>;
export const High: Story = {
args: {
priority: NoticePriority.High,
},
render: (args) => ({
props: args,
template: `<ui-notice [priority]="priority">Action Required</ui-notice>`,
}),
};
export const Medium: Story = {
args: {
priority: NoticePriority.Medium,
},
render: (args) => ({
props: args,
template: `<ui-notice [priority]="priority">Secondary Information</ui-notice>`,
}),
};
export const Low: Story = {
args: {
priority: NoticePriority.Low,
},
render: (args) => ({
props: args,
template: `<ui-notice [priority]="priority">Info Message</ui-notice>`,
}),
};
export const AllPriorities: Story = {
render: () => ({
template: `
<div style="display: flex; flex-direction: column; gap: 1rem;">
<ui-notice priority="high">High Priority</ui-notice>
<ui-notice priority="medium">Medium Priority</ui-notice>
<ui-notice priority="low">Low Priority</ui-notice>
</div>
`,
}),
};

View File

@@ -1,11 +1,11 @@
# Library Reference Guide
> **Last Updated:** 2025-11-25
> **Last Updated:** 2025-11-28
> **Angular Version:** 20.3.6
> **Nx Version:** 21.3.2
> **Total Libraries:** 72
> **Total Libraries:** 74
All 72 libraries in the monorepo have comprehensive README.md documentation located at `libs/[domain]/[layer]/[feature]/README.md`.
All 74 libraries in the monorepo have comprehensive README.md documentation located at `libs/[domain]/[layer]/[feature]/README.md`.
**IMPORTANT: Always use the `docs-researcher` subagent** to retrieve and analyze library documentation. This keeps the main context clean and prevents pollution.
@@ -29,7 +29,7 @@ A comprehensive product catalogue search service for Angular applications, provi
---
## Checkout Domain (6 libraries)
## Checkout Domain (7 libraries)
### `@isa/checkout/data-access`
A comprehensive checkout and shopping cart management library for Angular applications supporting multiple order types, reward redemption, and complex multi-step checkout workflows across retail and e-commerce operations.
@@ -37,7 +37,7 @@ A comprehensive checkout and shopping cart management library for Angular applic
**Location:** `libs/checkout/data-access/`
### `@isa/checkout/feature/reward-order-confirmation`
## Overview
A feature library providing a comprehensive order confirmation page for reward orders with support for printing, address display, and loyalty reward collection.
**Location:** `libs/checkout/feature/reward-order-confirmation/`
@@ -46,6 +46,11 @@ A comprehensive reward shopping cart feature for Angular applications supporting
**Location:** `libs/checkout/feature/reward-shopping-cart/`
### `@isa/checkout/feature/select-branch-dropdown`
Branch selection dropdown components for the checkout domain, enabling users to select a branch for their checkout session.
**Location:** `libs/checkout/feature/select-branch-dropdown/`
### `@isa/checkout/shared/product-info`
A comprehensive collection of presentation components for displaying product information, destination details, and stock availability in checkout and rewards workflows.
@@ -120,22 +125,22 @@ A sophisticated tab management system for Angular applications providing browser
**Location:** `libs/crm/data-access/`
### `@isa/crm/feature/customer-bon-redemption`
This library was generated with [Nx](https://nx.dev).
A feature library providing customer loyalty receipt (Bon) redemption functionality with validation and point allocation capabilities.
**Location:** `libs/crm/feature/customer-bon-redemption/`
### `@isa/crm/feature/customer-booking`
This library was generated with [Nx](https://nx.dev).
A standalone Angular component that enables customer loyalty card point booking and reversal with configurable booking reasons.
**Location:** `libs/crm/feature/customer-booking/`
### `@isa/crm/feature/customer-card-transactions`
This library was generated with [Nx](https://nx.dev).
A standalone Angular component that displays customer loyalty card transaction history with automatic data loading and refresh capabilities.
**Location:** `libs/crm/feature/customer-card-transactions/`
### `@isa/crm/feature/customer-loyalty-cards`
This library was generated with [Nx](https://nx.dev).
Customer loyalty cards feature module for displaying and managing customer bonus cards (Kundenkarten) with points tracking and card management actions.
**Location:** `libs/crm/feature/customer-loyalty-cards/`
@@ -144,7 +149,7 @@ This library was generated with [Nx](https://nx.dev).
## Icons (1 library)
### `@isa/icons`
## Overview
A centralized icon library for the ISA-Frontend monorepo providing inline SVG icons as string constants.
**Location:** `libs/icons/`
@@ -256,7 +261,7 @@ Angular library for generating Code 128 barcodes using [JsBarcode](https://githu
**Location:** `libs/shared/barcode/`
### `@isa/shared/delivery`
This library was generated with [Nx](https://nx.dev).
A reusable Angular component library for displaying order destination information with support for multiple delivery types (shipping, pickup, in-store, and digital downloads).
**Location:** `libs/shared/delivery/`
@@ -286,18 +291,13 @@ An accessible, feature-rich Angular quantity selector component with dropdown pr
**Location:** `libs/shared/quantity-control/`
### `@isa/shared/scanner`
## Overview
Enterprise-grade barcode scanning library for ISA-Frontend using the Scandit SDK, providing mobile barcode scanning capabilities for iOS and Android platforms.
**Location:** `libs/shared/scanner/`
---
## UI Component Libraries (18 libraries)
### `@isa/ui/label`
A flexible label component for displaying tags and notices with configurable priority levels across Angular applications.
**Location:** `libs/ui/label/`
## UI Component Libraries (19 libraries)
### `@isa/ui/bullet-list`
A lightweight bullet list component system for Angular applications supporting customizable icons and hierarchical content presentation.
@@ -344,6 +344,11 @@ A collection of reusable row components for displaying structured data with cons
**Location:** `libs/ui/item-rows/`
### `@isa/ui/label`
Label components for displaying tags, categories, and priority indicators.
**Location:** `libs/ui/label/`
### `@isa/ui/layout`
This library provides utilities and directives for responsive design and viewport behavior in Angular applications.
@@ -354,6 +359,11 @@ A lightweight Angular component library providing accessible menu components bui
**Location:** `libs/ui/menu/`
### `@isa/ui/notice`
A notice component for displaying prominent notifications and alerts with configurable priority levels.
**Location:** `libs/ui/notice/`
### `@isa/ui/progress-bar`
A lightweight Angular progress bar component supporting both determinate and indeterminate modes.
@@ -394,17 +404,17 @@ Lightweight Angular utility library for validating EAN (European Article Number)
**Location:** `libs/utils/ean-validation/`
### `@isa/utils/format-name`
This library was generated with [Nx](https://nx.dev).
A utility library for consistently formatting person and organisation names across the ISA-Frontend application.
**Location:** `libs/utils/format-name/`
### `@isa/utils/positive-integer-input`
An Angular directive that ensures only positive integers can be entered into number input fields.
An Angular directive that ensures only positive integers can be entered into number input fields through keyboard blocking, paste sanitization, and input validation.
**Location:** `libs/utils/positive-integer-input/`
### `@isa/utils/scroll-position`
## Overview
Utility library providing scroll position restoration and scroll-to-top functionality for Angular applications.
**Location:** `libs/utils/scroll-position/`

View File

@@ -1,5 +1,6 @@
/* tslint:disable */
import { EntityDTOBaseOfDisplayOrderItemDTOAndIOrderItem } from './entity-dtobase-of-display-order-item-dtoand-iorder-item';
import { KeyValueDTOOfStringAndString } from './key-value-dtoof-string-and-string';
import { LoyaltyDTO } from './loyalty-dto';
import { DisplayOrderDTO } from './display-order-dto';
import { PriceDTO } from './price-dto';
@@ -9,6 +10,11 @@ import { QuantityUnitType } from './quantity-unit-type';
import { DisplayOrderItemSubsetDTO } from './display-order-item-subset-dto';
export interface DisplayOrderItemDTO extends EntityDTOBaseOfDisplayOrderItemDTOAndIOrderItem{
/**
* Mögliche Aktionen
*/
actions?: Array<KeyValueDTOOfStringAndString>;
/**
* Bemerkung des Auftraggebers
*/

View File

@@ -1,10 +1,16 @@
/* tslint:disable */
import { EntityDTOBaseOfDisplayOrderItemSubsetDTOAndIOrderItemStatus } from './entity-dtobase-of-display-order-item-subset-dtoand-iorder-item-status';
import { KeyValueDTOOfStringAndString } from './key-value-dtoof-string-and-string';
import { DateRangeDTO } from './date-range-dto';
import { DisplayOrderItemDTO } from './display-order-item-dto';
import { OrderItemProcessingStatusValue } from './order-item-processing-status-value';
export interface DisplayOrderItemSubsetDTO extends EntityDTOBaseOfDisplayOrderItemSubsetDTOAndIOrderItemStatus{
/**
* Mögliche Aktionen
*/
actions?: Array<KeyValueDTOOfStringAndString>;
/**
* Abholfachnummer
*/
@@ -40,6 +46,11 @@ export interface DisplayOrderItemSubsetDTO extends EntityDTOBaseOfDisplayOrderIt
*/
estimatedShippingDate?: string;
/**
* Zusätzliche Markierungen (z.B. Abo, ...)
*/
features?: {[key: string]: string};
/**
* Bestellposten
*/

View File

@@ -118,6 +118,11 @@ export interface ReceiptDTO extends EntityDTOBaseOfReceiptDTOAndIReceipt{
*/
receiptNumber?: string;
/**
* Subtype of the receipt / Beleg-Unterart
*/
receiptSubType?: string;
/**
* Belegtext
*/

View File

@@ -1,15 +1,16 @@
import { Injectable, inject } from '@angular/core';
import { CheckoutMetadataService } from '../services/checkout-metadata.service';
import { Branch } from '../schemas/branch.schema';
@Injectable({ providedIn: 'root' })
export class BranchFacade {
#checkoutMetadataService = inject(CheckoutMetadataService);
getSelectedBranchId(tabId: number): number | undefined {
return this.#checkoutMetadataService.getSelectedBranchId(tabId);
getSelectedBranch(tabId: number): Branch | undefined {
return this.#checkoutMetadataService.getSelectedBranch(tabId);
}
setSelectedBranchId(tabId: number, branchId: number | undefined): void {
this.#checkoutMetadataService.setSelectedBranchId(tabId, branchId);
setSelectedBranch(tabId: number, branch: Branch | undefined): void {
this.#checkoutMetadataService.setSelectedBranch(tabId, branch);
}
}

View File

@@ -1,8 +1,10 @@
import { inject, Injectable, resource, signal } from '@angular/core';
import { logger } from '@isa/core/logging';
import { BranchService } from '../services';
@Injectable()
export class BranchResource {
#logger = logger({ service: 'BranchResource' });
#branchService = inject(BranchService);
#params = signal<
@@ -24,17 +26,20 @@ export class BranchResource {
if ('branchId' in params && !params.branchId) {
return null;
}
this.#logger.debug('Loading branch', () => params);
const res = await this.#branchService.fetchBranches(abortSignal);
return res.find((b) => {
const branch = res.find((b) => {
if ('branchId' in params) {
return b.id === params.branchId;
} else {
return b.branchNumber === params.branchNumber;
}
});
this.#logger.debug('Branch loaded', () => ({
found: !!branch,
branchId: branch?.id,
}));
return branch;
},
});
}
@Injectable()
export class AssignedBranchResource {}

View File

@@ -0,0 +1,26 @@
import { inject, Injectable, resource } from '@angular/core';
import { logger } from '@isa/core/logging';
import { BranchService } from '../services';
@Injectable()
export class BranchesResource {
#logger = logger({ service: 'BranchesResource' });
#branchService = inject(BranchService);
readonly resource = resource({
loader: async ({ abortSignal }) => {
this.#logger.debug('Loading all branches');
const branches = await this.#branchService.fetchBranches(abortSignal);
this.#logger.debug('Branches loaded', () => ({ count: branches.length }));
return branches
.filter(
(branch) =>
branch.isOnline &&
branch.isShippingEnabled &&
branch.isOrderingEnabled &&
branch.name !== undefined,
)
.sort((a, b) => a.name!.localeCompare(b.name!));
},
});
}

View File

@@ -1,2 +1,3 @@
export * from './branch.resource';
export * from './branches.resource';
export * from './shopping-cart.resource';

View File

@@ -4,27 +4,26 @@ import {
CHECKOUT_REWARD_SELECTION_POPUP_OPENED_STATE_KEY,
CHECKOUT_REWARD_SHOPPING_CART_ID_METADATA_KEY,
CHECKOUT_SHOPPING_CART_ID_METADATA_KEY,
COMPLETED_SHOPPING_CARTS_METADATA_KEY,
SELECTED_BRANCH_METADATA_KEY,
} from '../constants';
import z from 'zod';
import { ShoppingCart } from '../models';
import { Branch, BranchSchema } from '../schemas/branch.schema';
@Injectable({ providedIn: 'root' })
export class CheckoutMetadataService {
#tabService = inject(TabService);
setSelectedBranchId(tabId: number, branchId: number | undefined) {
setSelectedBranch(tabId: number, branch: Branch | undefined) {
this.#tabService.patchTabMetadata(tabId, {
[SELECTED_BRANCH_METADATA_KEY]: branchId,
[SELECTED_BRANCH_METADATA_KEY]: branch,
});
}
getSelectedBranchId(tabId: number): number | undefined {
getSelectedBranch(tabId: number): Branch | undefined {
return getMetadataHelper(
tabId,
SELECTED_BRANCH_METADATA_KEY,
z.number().optional(),
BranchSchema.optional(),
this.#tabService.entityMap(),
);
}

View File

@@ -1,4 +1,11 @@
import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core';
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
OnInit,
OnDestroy,
} from '@angular/core';
import { OpenRewardTasksResource } from '@isa/oms/data-access';
import { CarouselComponent } from '@isa/ui/carousel';
import { OpenTaskCardComponent } from './open-task-card.component';
@@ -32,7 +39,7 @@ import { OpenTaskCardComponent } from './open-task-card.component';
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class OpenTasksCarouselComponent {
export class OpenTasksCarouselComponent implements OnInit, OnDestroy {
/**
* Global resource managing open reward tasks data
*/
@@ -48,7 +55,7 @@ export class OpenTasksCarouselComponent {
const tasks = this.openTasksResource.tasks();
const seenOrderIds = new Set<number>();
return tasks.filter(task => {
return tasks.filter((task) => {
if (!task.orderId || seenOrderIds.has(task.orderId)) {
return false;
}
@@ -56,4 +63,21 @@ export class OpenTasksCarouselComponent {
return true;
});
});
/**
* Initializes the component by loading open tasks and starting auto-refresh.
* Ensures the carousel displays current data when mounted.
*/
ngOnInit(): void {
this.openTasksResource.refresh();
this.openTasksResource.startAutoRefresh();
}
/**
* Cleanup lifecycle hook that stops auto-refresh polling.
* Prevents memory leaks by clearing the refresh interval.
*/
ngOnDestroy(): void {
this.openTasksResource.stopAutoRefresh();
}
}

View File

@@ -11,14 +11,13 @@ import {
ShoppingCartFacade,
SelectedRewardShoppingCartResource,
} from '@isa/checkout/data-access';
import { injectTabId } from '@isa/core/tabs';
import { injectTabId, TabService } from '@isa/core/tabs';
import { StatefulButtonComponent, StatefulButtonState } from '@isa/ui/buttons';
import { PurchaseOptionsModalService } from '@modal/purchase-options';
import { firstValueFrom } from 'rxjs';
import { Router } from '@angular/router';
import { getRouteToCustomer } from '../helpers';
import { PrimaryCustomerCardResource } from '@isa/crm/data-access';
import { NavigationStateService } from '@isa/core/navigation';
@Component({
selector: 'reward-action',
@@ -32,7 +31,7 @@ export class RewardActionComponent {
#store = inject(RewardCatalogStore);
#tabId = injectTabId();
#navigationState = inject(NavigationStateService);
#tabService = inject(TabService);
#purchasingOptionsModal = inject(PurchaseOptionsModalService);
#shoppingCartFacade = inject(ShoppingCartFacade);
#checkoutMetadataService = inject(CheckoutMetadataService);
@@ -122,12 +121,9 @@ export class RewardActionComponent {
const route = getRouteToCustomer(tabId);
// Preserve context: Store current reward page URL to return to after customer selection
await this.#navigationState.preserveContext(
{
returnUrl: this.#router.url,
},
'select-customer',
);
this.#tabService.patchTabMetadata(tabId, {
'select-customer': { returnUrl: this.#router.url },
});
await this.#router.navigate(route.path, {
queryParams: route.queryParams,

View File

@@ -1,11 +1,17 @@
<reward-catalog-open-tasks-carousel></reward-catalog-open-tasks-carousel>
<reward-header></reward-header>
<filter-controls-panel
[switchFilters]="displayStockFilterSwitch()"
(triggerSearch)="search($event)"
></filter-controls-panel>
<reward-list
[searchTrigger]="searchTrigger()"
(searchTriggerChange)="searchTrigger.set($event)"
></reward-list>
<reward-action></reward-action>
@let tId = tabId();
<reward-catalog-open-tasks-carousel></reward-catalog-open-tasks-carousel>
<reward-header></reward-header>
<filter-controls-panel
[forceMobileLayout]="isCallCenter"
[switchFilters]="displayStockFilterSwitch()"
(triggerSearch)="search($event)"
>
@if (isCallCenter && tId) {
<checkout-selected-branch-dropdown [tabId]="tId" />
}
</filter-controls-panel>
<reward-list
[searchTrigger]="searchTrigger()"
(searchTriggerChange)="searchTrigger.set($event)"
></reward-list>
<reward-action></reward-action>

View File

@@ -14,14 +14,18 @@ import {
SearchTrigger,
FilterService,
FilterInput,
SwitchMenuButtonComponent,
} 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 { SelectedBranchDropdownComponent } from '@isa/checkout/feature/select-branch-dropdown';
import { SelectedCustomerResource } from '@isa/crm/data-access';
import { OpenTasksCarouselComponent } from './open-tasks-carousel/open-tasks-carousel.component';
import { injectTabId } from '@isa/core/tabs';
import { Role, RoleService } from '@isa/core/auth';
/**
* Factory function to retrieve query settings from the activated route data.
@@ -50,6 +54,7 @@ function querySettingsFactory() {
RewardHeaderComponent,
RewardListComponent,
RewardActionComponent,
SelectedBranchDropdownComponent,
],
host: {
'[class]':
@@ -57,6 +62,10 @@ function querySettingsFactory() {
},
})
export class RewardCatalogComponent {
readonly tabId = injectTabId();
readonly isCallCenter = inject(RoleService).hasRole(Role.CallCenter);
restoreScrollPosition = injectRestoreScrollPosition();
searchTrigger = signal<SearchTrigger | 'reload' | 'initial'>('initial');
@@ -64,6 +73,9 @@ export class RewardCatalogComponent {
#filterService = inject(FilterService);
displayStockFilterSwitch = computed(() => {
if (this.isCallCenter) {
return [];
}
const stockInput = this.#filterService
.inputs()
?.filter((input) => input.target === 'filter')

View File

@@ -1,9 +1,8 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { ButtonComponent } from '@isa/ui/buttons';
import { injectTabId } from '@isa/core/tabs';
import { injectTabId, TabService } from '@isa/core/tabs';
import { Router } from '@angular/router';
import { getRouteToCustomer } from '../../helpers';
import { NavigationStateService } from '@isa/core/navigation';
@Component({
selector: 'reward-start-card',
@@ -13,7 +12,7 @@ import { NavigationStateService } from '@isa/core/navigation';
imports: [ButtonComponent],
})
export class RewardStartCardComponent {
readonly #navigationState = inject(NavigationStateService);
readonly #tabService = inject(TabService);
readonly #router = inject(Router);
tabId = injectTabId();
@@ -22,19 +21,19 @@ export class RewardStartCardComponent {
* Called when "Kund*in auswählen" button is clicked.
* Preserves the current URL as returnUrl before navigating to customer search.
*/
async onSelectCustomer() {
onSelectCustomer() {
const customerRoute = getRouteToCustomer(this.tabId());
const tabId = this.#tabService.activatedTabId();
// Preserve context: Store current reward page URL to return to after customer selection
await this.#navigationState.preserveContext(
{
returnUrl: this.#router.url,
},
'select-customer',
);
if (tabId) {
this.#tabService.patchTabMetadata(tabId, {
'select-customer': { returnUrl: this.#router.url },
});
}
// Navigate to customer search
await this.#router.navigate(customerRoute.path, {
this.#router.navigate(customerRoute.path, {
queryParams: customerRoute.queryParams,
});
}

View File

@@ -1,28 +1,31 @@
@let i = item();
<ui-client-row data-what="reward-list-item" [attr.data-which]="i.id">
<ui-client-row-content
class="flex-grow"
[class.row-start-1]="!desktopBreakpoint()"
>
<checkout-product-info-redemption
[item]="i"
[orientation]="productInfoOrientation()"
></checkout-product-info-redemption>
</ui-client-row-content>
<ui-item-row-data
class="flex-grow"
[class.stock-row-tablet]="!desktopBreakpoint()"
>
<checkout-stock-info [item]="i"></checkout-stock-info>
</ui-item-row-data>
<ui-item-row-data
class="justify-center"
[class.select-row-tablet]="!desktopBreakpoint()"
>
<reward-list-item-select
class="self-end"
[item]="i"
></reward-list-item-select>
</ui-item-row-data>
</ui-client-row>
@let i = item();
<ui-client-row data-what="reward-list-item" [attr.data-which]="i.id">
<ui-client-row-content
class="flex-grow"
[class.row-start-1]="!desktopBreakpoint()"
>
<checkout-product-info-redemption
[item]="i"
[orientation]="productInfoOrientation()"
></checkout-product-info-redemption>
</ui-client-row-content>
<ui-item-row-data
class="flex-grow"
[class.stock-row-tablet]="!desktopBreakpoint()"
>
<checkout-stock-info
[item]="i"
[branchId]="selectedBranchId()"
></checkout-stock-info>
</ui-item-row-data>
<ui-item-row-data
class="justify-center"
[class.select-row-tablet]="!desktopBreakpoint()"
>
<reward-list-item-select
class="self-end"
[item]="i"
></reward-list-item-select>
</ui-item-row-data>
</ui-client-row>

View File

@@ -1,15 +1,19 @@
import {
ChangeDetectionStrategy,
Component,
inject,
input,
linkedSignal,
computed,
} from '@angular/core';
import { Item } from '@isa/catalogue/data-access';
import { ClientRowImports, ItemRowDataImports } from '@isa/ui/item-rows';
import { Breakpoint, breakpoint } from '@isa/ui/layout';
import { CheckoutMetadataService } from '@isa/checkout/data-access';
import { ProductInfoRedemptionComponent } from '@isa/checkout/shared/product-info';
import { StockInfoComponent } from '@isa/checkout/shared/product-info';
import { RewardListItemSelectComponent } from './reward-list-item-select/reward-list-item-select.component';
import { injectTabId } from '@isa/core/tabs';
@Component({
selector: 'reward-list-item',
@@ -25,6 +29,18 @@ import { RewardListItemSelectComponent } from './reward-list-item-select/reward-
],
})
export class RewardListItemComponent {
#checkoutMetadataService = inject(CheckoutMetadataService);
tabId = injectTabId();
selectedBranchId = computed(() => {
const tabid = this.tabId();
if (!tabid) {
return null;
}
return this.#checkoutMetadataService.getSelectedBranch(tabid)?.id;
});
item = input.required<Item>();
desktopBreakpoint = breakpoint([

View File

@@ -6,6 +6,7 @@ export const routes: Routes = [
{
path: '',
component: RewardCatalogComponent,
title: 'Prämienshop',
resolve: { querySettings: querySettingsResolverFn },
data: {
scrollPositionRestoration: true,

View File

@@ -1,135 +1,249 @@
# checkout-feature-reward-order-confirmation
# @isa/checkout/feature/reward-order-confirmation
A feature library providing a comprehensive order confirmation page for reward orders with support for printing, address display, and loyalty reward collection.
## Overview
The `@isa/checkout/feature/reward-order-confirmation` library provides the **reward order confirmation screen** displayed after customers complete a loyalty reward redemption purchase. It shows a comprehensive summary of completed reward orders, including shipping/billing addresses, product details, loyalty points used, and delivery method information.
This feature provides a complete order confirmation experience after a reward order has been placed. It displays order details grouped by delivery destination, shows payer and shipping information, and enables users with appropriate permissions to print order confirmations. For items with the 'Rücklage' (layaway) feature, it provides an action card that allows staff to collect loyalty rewards in various ways (collect, donate, or cancel).
**Type:** Routed feature library with lazy-loaded components
The library uses NgRx Signal Store for reactive state management, integrating with the OMS (Order Management System) API to load and display order data. It supports multiple orders being displayed simultaneously by accepting comma-separated display order IDs in the route parameter.
## Features
## Architecture
- **Multi-Order Support**: Display multiple orders from a single shopping cart session
- **Order Type Awareness**: Conditional display based on delivery method (Delivery, Pickup, In-Store)
- **Address Deduplication**: Smart handling of duplicate addresses across multiple orders
- **Loyalty Points Display**: Shows loyalty points (Lesepunkte) used for each reward item
- **Product Information**: Comprehensive product details including name, contributors, EAN, and quantity
- **Destination Information**: Delivery/pickup destination details
### State Management
## Main Components
The library uses a **signalStore** (`OrderConfiramtionStore`) that:
- Loads display orders via `DisplayOrdersResource`
- Computes derived state (payers, shipping addresses, target branches)
- Determines which order type features are present (delivery, pickup, in-store)
- Provides reactive data to all child components
### RewardOrderConfirmationComponent (Main Container)
- **Selector:** `checkout-reward-order-confirmation`
- **Route:** `/:orderIds` (accepts multiple order IDs separated by `+`)
- Orchestrates the entire confirmation view
- Manages state via `OrderConfiramtionStore`
### Route Configuration
The feature is accessible via the route pattern: `:displayOrderIds`
Example: `/order-confirmation/1234,5678` displays orders with display IDs 1234 and 5678.
The route includes:
- OMS action handlers for command execution
- Tab cleanup on deactivation
- Lazy-loaded main component
## Components
### RewardOrderConfirmationComponent
**Selector:** `checkout-reward-order-confirmation`
Main container component that orchestrates the order confirmation page.
**Key Responsibilities:**
- Parses display order IDs from route parameters (comma-separated)
- Initializes the store with tab ID and order IDs
- Triggers display order loading via `DisplayOrdersResource`
- Composes child components (header, addresses, item list)
**Effects:**
- Updates store state when route parameters or tab ID change
- Loads display orders when order IDs change
### OrderConfirmationHeaderComponent
- **Selector:** `checkout-order-confirmation-header`
- Displays header: "Prämienausgabe abgeschlossen" (Reward distribution completed)
**Selector:** `checkout-order-confirmation-header`
Displays the page header with print functionality.
**Features:**
- Print button that triggers `CheckoutPrintFacade.printOrderConfirmation()`
- Role-based access control (print feature restricted by role)
- Integrates with the ISA printing system
**Dependencies:**
- `@isa/common/print` - Print button and printer selection
- `@isa/core/auth` - Role-based directive (`*ifRole`)
### OrderConfirmationAddressesComponent
- **Selector:** `checkout-order-confirmation-addresses`
- Displays billing addresses, shipping addresses, and pickup branch information
- Conditionally shows sections based on order type
**Selector:** `checkout-order-confirmation-addresses`
Displays payer and destination information based on order types.
**Displayed Information:**
- **Payers:** Deduplicated list of buyers across all orders
- **Shipping Addresses:** Shown only if orders have delivery features (Delivery, DigitalShipping, B2BShipping)
- **Target Branches:** Shown only if orders have in-store features (InStore, Pickup)
**Dependencies:**
- `@isa/shared/address` - Address display component
- `@isa/crm/data-access` - Customer name formatting and address deduplication
### OrderConfirmationItemListComponent
- **Selector:** `checkout-order-confirmation-item-list`
- Displays order type badge with icon (Delivery/Pickup/In-Store)
- Renders list of order items for each order
**Selector:** `checkout-order-confirmation-item-list`
Groups and displays order items by delivery destination and type.
**Key Features:**
- Groups items using `groupItemsByDeliveryDestination()` helper
- Displays delivery type icons (Versand, Rücklage, B2B Versand)
- Optimized rendering with `trackBy` function for item groups
**Grouping Logic:**
Items are grouped by:
1. Order type (e.g., Delivery, Pickup, InStore)
2. Shipping address (for delivery orders)
3. Target branch (for pickup/in-store orders)
### OrderConfirmationItemListItemComponent
- **Selector:** `checkout-order-confirmation-item-list-item`
- Displays individual product information, quantity, loyalty points, and destination info
**Selector:** `checkout-order-confirmation-item-list-item`
Displays individual order item details within a group.
**Inputs:**
- `item` (required): `DisplayOrderItemDTO` - The order item to display
- `order` (required): `OrderItemGroup` - The group containing delivery type and destination
**Features:**
- Product information display via `ProductInfoComponent`
- Destination information via `DisplayOrderDestinationInfoComponent`
- Action card for loyalty reward collection (conditional)
- Loyalty points display
### ConfirmationListItemActionCardComponent
- **Selector:** `checkout-confirmation-list-item-action-card`
- Placeholder component for future action functionality
## State Management
**Selector:** `checkout-confirmation-list-item-action-card`
**OrderConfiramtionStore** (NgRx Signals)
Provides loyalty reward collection interface for layaway items.
**State:**
- `tabId`: Current tab identifier
- `orderIds`: Array of order IDs to display
**Inputs:**
- `item` (required): `DisplayOrderItem` - The order item with loyalty information
**Computed Properties:**
- `orders`: Filtered display orders from OMS metadata service
- `shoppingCart`: Associated completed shopping cart
- `payers`: Deduplicated billing addressees
- `shippingAddresses`: Deduplicated shipping addressees
- `targetBranches`: Deduplicated pickup branches
- `hasDeliveryOrderTypeFeature`: Boolean indicating delivery orders
- `hasTargetBranchFeature`: Boolean indicating pickup/in-store orders
**Display Conditions:**
The action card is displayed when ALL of the following are met:
- Item has the 'Rücklage' (layaway) order type feature
- AND either:
- Item has a loyalty collect command available, OR
- Item processing is already complete
**Features:**
- **Action Selection:** Dropdown to choose collection type:
- `Collect` - Collect loyalty points for customer
- `Donate` - Donate points to charity
- `Cancel` - Cancel the reward collection
- **Processing States:** Shows current processing status (Ordered, InProgress, Complete)
- **Command Execution:** Integrates with OMS command system for reward collection
- **Loading States:** Displays loading indicators during API calls
- **Completion Status:** Shows check icon when processing is complete
**Role-Based Access:** Collection functionality is restricted by role permissions.
**Dependencies:**
- `@isa/oms/data-access` - Order reward collection facade and command handling
- `@isa/ui/buttons` - Button and dropdown components
- `@isa/checkout/data-access` - Order type feature detection and item utilities
## Routes
```typescript
export const routes: Routes = [
{
path: ':displayOrderIds',
loadComponent: () => import('./reward-order-confirmation.component'),
canDeactivate: [canDeactivateTabCleanup],
providers: [CoreCommandModule.forChild(OMS_ACTION_HANDLERS)]
}
];
```
**Route Parameters:**
- `displayOrderIds` - Comma-separated list of display order IDs (e.g., "1234,5678")
## Usage
### Routing Integration
### Importing Routes
```typescript
// In parent routing module
{
path: 'reward-confirmation',
loadChildren: () => import('@isa/checkout/feature/reward-order-confirmation')
.then(m => m.routes)
}
import { routes as rewardOrderConfirmationRoutes } from '@isa/checkout/feature/reward-order-confirmation';
const appRoutes: Routes = [
{
path: 'order-confirmation',
children: rewardOrderConfirmationRoutes
}
];
```
### URL Structure
### Navigation Example
```
/reward-confirmation/123+456+789
```
```typescript
// Navigate to confirmation for single order
router.navigate(['/order-confirmation', '1234']);
This displays confirmation for orders with IDs 123, 456, and 789.
// Navigate to confirmation for multiple orders
router.navigate(['/order-confirmation', '1234,5678,9012']);
```
## Dependencies
**Data Access:**
- `@isa/oms/data-access` - Order metadata and models
- `@isa/checkout/data-access` - Checkout metadata and order type utilities
- `@isa/crm/data-access` - Address deduplication utilities
### Core Angular & NgRx
- `@angular/core` - Angular framework
- `@angular/router` - Routing
- `@ngrx/signals` - Signal-based state management
**Core:**
- `@isa/core/tabs` - Tab context management
### ISA Libraries
**Shared Components:**
- `@isa/shared/address` - Address rendering
- `@isa/checkout/shared/product-info` - Product and destination information
#### Data Access
- `@isa/checkout/data-access` - Order type features, grouping utilities
- `@isa/oms/data-access` - Display orders resource, reward collection, command handling
- `@isa/crm/data-access` - Customer name formatting, address/branch deduplication
**UI:**
- `@isa/icons` - Delivery method icons
#### Shared Components
- `@isa/checkout/shared/product-info` - Product display components
- `@isa/shared/address` - Address display component
- `@isa/common/print` - Print button and printer integration
- `@isa/ui/buttons` - Button components
- `@isa/ui/input-controls` - Dropdown components
## Data Flow
#### Core Services
- `@isa/core/tabs` - Tab service and cleanup guard
- `@isa/core/auth` - Role-based access control
- `@isa/core/command` - Command module integration
1. Route parameter (`orderIds`) is parsed into array of integers
2. Store receives `tabId` from `TabService` and `orderIds` from route
3. Store fetches orders from `OmsMetadataService` filtered by IDs
4. Store fetches completed shopping cart from `CheckoutMetadataService`
5. Components consume computed properties from store (addresses, order items, points)
#### Icons
- `@isa/icons` - ISA custom icon set
- `@ng-icons/core` - Icon component
## Order Type Support
The library supports three delivery methods with specific icons:
- **Delivery** (`isaDeliveryVersand`): Shows shipping addresses
- **Pickup** (`isaDeliveryRuecklage2`): Shows pickup branch
- **In-Store** (`isaDeliveryRuecklage1`): Shows store branch
### Generated APIs
- `@generated/swagger/oms-api` - OMS API types (DisplayOrderItemDTO)
## Testing
Run tests with Vitest:
The library uses Vitest for testing with comprehensive coverage of:
- Component rendering and interactions
- State management and computed signals
- Route parameter parsing
- Reward collection workflows
- Conditional display logic
Run tests:
```bash
npx nx test checkout-feature-reward-order-confirmation --skip-nx-cache
nx test checkout-feature-reward-order-confirmation
```
**Test Framework:** Vitest
**Coverage Output:** `coverage/libs/checkout/feature/reward-order-confirmation`
## Key Features Summary
## Architecture Notes
1. **Multi-Order Support:** Display confirmation for multiple orders simultaneously
2. **Smart Grouping:** Items grouped by delivery destination for clarity
3. **Conditional Displays:** Address sections shown based on order type features
4. **Print Integration:** Role-based printing with printer selection
5. **Loyalty Rewards:** In-page reward collection for layaway items
6. **Reactive State:** Signal-based state management with computed values
7. **Tab Integration:** Proper cleanup on tab deactivation
8. **Role-Based Access:** Permissions enforced for sensitive operations
- **Component Prefix:** `checkout`
- **Change Detection:** All components use `OnPush` strategy
- **Standalone Architecture:** All components are standalone with explicit imports
- **Project Name:** `checkout-feature-reward-order-confirmation`
## Related Libraries
- `@isa/checkout/feature/checkout-process` - Main checkout flow
- `@isa/oms/data-access` - Order management system integration
- `@isa/checkout/data-access` - Checkout domain logic and utilities

View File

@@ -1,3 +1,7 @@
:host {
@apply w-full flex flex-row items-center justify-between;
}
ui-item-row-data-label {
width: 12.4rem;
}

View File

@@ -1,7 +1,23 @@
<h1 class="text-isa-neutral-900 isa-text-subtitle-1-regular">
Prämienausgabe abgeschlossen
</h1>
<div class="flex flex-col gap-4">
<h1 class="text-isa-neutral-900 isa-text-subtitle-1-regular">
Prämienausgabe abgeschlossen
</h1>
<ui-item-row-data>
<ui-item-row-data-row>
<ui-item-row-data-label>Vorgangs-ID</ui-item-row-data-label>
<ui-item-row-data-value>{{ orderNumbers() }}</ui-item-row-data-value>
</ui-item-row-data-row>
<ui-item-row-data-row>
<ui-item-row-data-label>Bestelldatum</ui-item-row-data-label>
<ui-item-row-data-value>{{ orderDates() }}</ui-item-row-data-value>
</ui-item-row-data-row>
</ui-item-row-data>
</div>
<common-print-button printerType="label" [printFn]="printFn"
<common-print-button
class="self-start"
*ifNotRole="Role.CallCenter"
printerType="label"
[printFn]="printFn"
>Prämienbeleg drucken</common-print-button
>

View File

@@ -1,18 +1,28 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import {
ChangeDetectionStrategy,
Component,
inject,
computed,
} from '@angular/core';
import { DatePipe } from '@angular/common';
import { CheckoutPrintFacade } from '@isa/checkout/data-access';
import { PrintButtonComponent, Printer } from '@isa/common/print';
import { OrderConfiramtionStore } from '../reward-order-confirmation.store';
import { IfRoleDirective, Role } from '@isa/core/auth';
import { ItemRowDataImports } from '@isa/ui/item-rows';
@Component({
selector: 'checkout-order-confirmation-header',
templateUrl: './order-confirmation-header.component.html',
styleUrls: ['./order-confirmation-header.component.css'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [PrintButtonComponent],
imports: [PrintButtonComponent, IfRoleDirective, ItemRowDataImports],
})
export class OrderConfirmationHeaderComponent {
protected readonly Role = Role;
#checkoutPrintFacade = inject(CheckoutPrintFacade);
#store = inject(OrderConfiramtionStore);
#datePipe = new DatePipe('de-DE');
orderIds = this.#store.orderIds;
@@ -22,4 +32,35 @@ export class OrderConfirmationHeaderComponent {
data: this.orderIds() ?? [],
});
};
orderNumbers = computed(() => {
const orders = this.#store.orders();
if (!orders || orders.length === 0) {
return '';
}
return orders
.map((order) => order.orderNumber)
.filter(Boolean)
.join('; ');
});
orderDates = computed(() => {
const orders = this.#store.orders();
if (!orders || orders.length === 0) {
return '';
}
return orders
.map((order) => {
if (!order.orderDate) {
return null;
}
const formatted = this.#datePipe.transform(
order.orderDate,
'dd.MM.yyyy | HH:mm',
);
return formatted ? `${formatted} Uhr` : null;
})
.filter(Boolean)
.join('; ');
});
}

View File

@@ -19,6 +19,7 @@ import {
HandleCommandService,
HandleCommand,
getMainActions,
DisplayOrdersResource,
} from '@isa/oms/data-access';
import { ButtonComponent } from '@isa/ui/buttons';
import { NgIcon } from '@ng-icons/core';
@@ -61,6 +62,7 @@ export class ConfirmationListItemActionCardComponent {
#orderRewardCollectFacade = inject(OrderRewardCollectFacade);
#store = inject(OrderConfiramtionStore);
#orderItemSubsetResource = inject(OrderItemSubsetResource);
#displayOrdersResource = inject(DisplayOrdersResource);
#handleCommandFacade = inject(HandleCommandFacade);
item = input.required<DisplayOrderItem>();
@@ -165,7 +167,7 @@ export class ConfirmationListItemActionCardComponent {
}
}
}
this.#orderItemSubsetResource.refresh();
this.reloadResources();
}
} finally {
this.isLoading.set(false);
@@ -175,4 +177,9 @@ export class ConfirmationListItemActionCardComponent {
async handleCommand(params: HandleCommand) {
await this.#handleCommandFacade.handle(params);
}
reloadResources() {
this.#orderItemSubsetResource.refresh();
this.#displayOrdersResource.refresh();
}
}

View File

@@ -1,3 +1,3 @@
:host {
@apply block w-full text-isa-neutral-900 mt-[1.42rem];
@apply flex flex-col w-full text-isa-neutral-900 mt-[1.42rem] gap-4;
}

View File

@@ -1,3 +1,6 @@
@if (!hasPendingActions()) {
<tabs-navigate-back-button [navigateTo]="rewardCatalogRoute()" />
}
<div
class="bg-isa-white p-6 rounded-2xl flex flex-col gap-6 items-start self-stretch"
>

View File

@@ -12,9 +12,21 @@ import { OrderConfirmationAddressesComponent } from './order-confirmation-addres
import { OrderConfirmationHeaderComponent } from './order-confirmation-header/order-confirmation-header.component';
import { OrderConfirmationItemListComponent } from './order-confirmation-item-list/order-confirmation-item-list.component';
import { ActivatedRoute } from '@angular/router';
import { TabService } from '@isa/core/tabs';
import {
NavigateBackButtonComponent,
TabService,
injectTabId,
} from '@isa/core/tabs';
import { OrderConfiramtionStore } from './reward-order-confirmation.store';
import { DisplayOrdersResource } from '@isa/oms/data-access';
import {
DisplayOrdersResource,
getProcessingStatusState,
ProcessingStatusState,
} from '@isa/oms/data-access';
import {
hasOrderTypeFeature,
hasLoyaltyCollectCommand,
} from '@isa/checkout/data-access';
@Component({
selector: 'checkout-reward-order-confirmation',
@@ -25,13 +37,14 @@ import { DisplayOrdersResource } from '@isa/oms/data-access';
OrderConfirmationHeaderComponent,
OrderConfirmationAddressesComponent,
OrderConfirmationItemListComponent,
NavigateBackButtonComponent,
],
providers: [OrderConfiramtionStore, DisplayOrdersResource],
})
export class RewardOrderConfirmationComponent {
#store = inject(OrderConfiramtionStore);
#displayOrdersResource = inject(DisplayOrdersResource);
#tabId = inject(TabService).activatedTabId;
#tabId = injectTabId();
#activatedRoute = inject(ActivatedRoute);
params = toSignal(this.#activatedRoute.paramMap);
@@ -49,6 +62,43 @@ export class RewardOrderConfirmationComponent {
orderIds = this.displayOrderIds;
orders = this.#store.orders;
/**
* Checks if there are any items with pending actions (Rücklage items that still need to be collected).
* Returns true if at least one item requires action.
*/
hasPendingActions = computed(() => {
const orders = this.#store.orders();
if (!orders) {
return false;
}
const allItems = orders.flatMap((order) => order.items ?? []);
return allItems.some((item) => {
const isRuecklage = hasOrderTypeFeature(item.features, ['Rücklage']);
if (!isRuecklage) {
return false;
}
const hasCollectCommand = hasLoyaltyCollectCommand(item.subsetItems);
const statuses = item.subsetItems?.map(
(subset) => subset.processingStatus,
);
const processingStatus = getProcessingStatusState(statuses);
const isComplete =
processingStatus !== undefined &&
processingStatus !== ProcessingStatusState.Ordered;
// Item has pending action if it has collect command and is not complete
return hasCollectCommand && !isComplete;
});
});
/**
* Route to the reward catalog for the current tab.
*/
rewardCatalogRoute = computed(() => `/${this.#tabId()}/reward`);
constructor() {
// Update store state
effect(() => {

View File

@@ -6,6 +6,7 @@ import { canDeactivateTabCleanup } from '@isa/core/tabs';
export const routes: Routes = [
{
path: ':displayOrderIds',
title: 'Prämienshop - Bestellbestätigung',
providers: [
CoreCommandModule.forChild(OMS_ACTION_HANDLERS).providers ?? [],
],

View File

@@ -14,8 +14,8 @@ import { isaActionEdit } from '@isa/icons';
import { IconButtonComponent } from '@isa/ui/buttons';
import { provideIcons } from '@ng-icons/core';
import { AddressComponent } from '@isa/shared/address';
import { injectTabId } from '@isa/core/tabs';
import { NavigationStateService } from '@isa/core/navigation';
import { injectTabId, TabService } from '@isa/core/tabs';
import { Router } from '@angular/router';
@Component({
selector: 'checkout-billing-and-shipping-address-card',
@@ -26,7 +26,8 @@ import { NavigationStateService } from '@isa/core/navigation';
providers: [provideIcons({ isaActionEdit })],
})
export class BillingAndShippingAddressCardComponent {
#navigationState = inject(NavigationStateService);
#tabService = inject(TabService);
#router = inject(Router);
#shippingAddressResource = inject(SelectedCustomerShippingAddressResource);
#payerAddressResource = inject(SelectedCustomerPayerAddressResource);
@@ -45,18 +46,19 @@ export class BillingAndShippingAddressCardComponent {
return this.#customerResource.value();
});
async navigateToCustomer() {
navigateToCustomer() {
const customerId = this.customer()?.id;
if (!customerId) return;
const tabId = this.tabId();
if (!customerId || !tabId) return;
const returnUrl = `/${this.tabId()}/reward/cart`;
const returnUrl = `/${tabId}/reward/cart`;
// Preserve context across intermediate navigations (auto-scoped to active tab)
await this.#navigationState.navigateWithPreservedContext(
['/', 'kunde', this.tabId(), 'customer', 'search', customerId],
{ returnUrl },
'select-customer',
);
// Preserve context across intermediate navigations (scoped to active tab)
this.#tabService.patchTabMetadata(tabId, {
'select-customer': { returnUrl },
});
this.#router.navigate(['/', 'kunde', tabId, 'customer', 'search', customerId]);
}
payer = computed(() => {

View File

@@ -60,6 +60,11 @@ export class CompleteOrderButtonComponent {
return calculateTotalLoyaltyPoints(cart?.items);
});
hasItemsInCart = computed(() => {
const cart = this.#shoppingCartResource.value();
return cart && cart?.items?.length > 0;
});
hasInsufficientPoints = computed(() => {
return this.primaryBonusCardPoints() < this.totalPointsRequired();
});
@@ -104,6 +109,7 @@ export class CompleteOrderButtonComponent {
this.isBusy() ||
this.isCompleted() ||
this.isLoading() ||
!this.hasItemsInCart() ||
this.hasInsufficientPoints()
);
});

View File

@@ -8,7 +8,7 @@
@if (isHorizontal()) {
<checkout-destination-info
[underline]="true"
class="cursor-pointer max-w-[14.25rem] grow-0 shrink-0"
class="cursor-pointer max-w-[14.25rem] min-w-[14.25rem] grow-0 shrink-0"
(click)="updatePurchaseOption()"
[shoppingCartItem]="itm"
></checkout-destination-info>
@@ -29,7 +29,7 @@
@if (!isHorizontal()) {
<checkout-destination-info
[underline]="true"
class="cursor-pointer mt-4 w-[14.25rem] grow items-end"
class="cursor-pointer mt-4 w-[14.25rem] min-w-[14.25rem] grow items-end"
(click)="updatePurchaseOption()"
[shoppingCartItem]="itm"
></checkout-destination-info>

View File

@@ -2,9 +2,11 @@
<div class="flex flex-col gap-2 mb-4 text-center">
<h1 class="isa-text-subtitle-1-regular text-isa-black">Prämienausgabe</h1>
<p class="isa-text-body-2-regular text-isa-secondary-900">
Kontrolliere Sie Lieferart und Versand um die Prämienausgabe abzuschließen.
<br />
Sie können Prämien unter folgendem Link zurück in den Warenkorb legen:
Kontrollieren Sie Lieferart und Versand um die Prämienausgabe abzuschließen.
@if (hasItemsInCart()) {
<br />
Sie können Prämien unter folgendem Link zurück in den Warenkorb legen:
}
</p>
<lib-reward-selection-trigger></lib-reward-selection-trigger>
</div>
@@ -26,7 +28,19 @@
</div>
}
<checkout-billing-and-shipping-address-card></checkout-billing-and-shipping-address-card>
<checkout-reward-shopping-cart-items></checkout-reward-shopping-cart-items>
@if (hasItemsInCart()) {
<checkout-reward-shopping-cart-items></checkout-reward-shopping-cart-items>
} @else {
<ui-empty-state
class="w-full self-center"
[appearance]="EmptyStateAppearance.NoArticles"
title="Leere Liste"
description="Es befinden sich keine Artikel auf der Liste."
>
</ui-empty-state>
}
<checkout-complete-order-button
class="fixed bottom-6 right-6"
></checkout-complete-order-button>

View File

@@ -20,6 +20,7 @@ import { CompleteOrderButtonComponent } from './complete-order-button/complete-o
import { RewardSelectionTriggerComponent } from '@isa/checkout/shared/reward-selection-dialog';
import { NgIconComponent, provideIcons } from '@ng-icons/core';
import { isaOtherInfo } from '@isa/icons';
import { EmptyStateComponent, EmptyStateAppearance } from '@isa/ui/empty-state';
@Component({
selector: 'checkout-reward-shopping-cart',
@@ -34,10 +35,12 @@ import { isaOtherInfo } from '@isa/icons';
CompleteOrderButtonComponent,
RewardSelectionTriggerComponent,
NgIconComponent,
EmptyStateComponent,
],
providers: [provideIcons({ isaOtherInfo })],
})
export class RewardShoppingCartComponent {
EmptyStateAppearance = EmptyStateAppearance;
#shoppingCartResource = inject(SelectedRewardShoppingCartResource).resource;
#primaryCustomerCardResource = inject(PrimaryCustomerCardResource);
@@ -55,6 +58,11 @@ export class RewardShoppingCartComponent {
return this.primaryBonusCardPoints() < this.totalPointsRequired();
});
hasItemsInCart = computed(() => {
const cart = this.#shoppingCartResource.value();
return cart && cart?.items?.length > 0;
});
constructor() {
this.#shoppingCartResource.reload();
}

View File

@@ -4,6 +4,7 @@ import { RewardShoppingCartComponent } from './reward-shopping-cart.component';
export const routes: Routes = [
{
path: '',
title: 'Prämienshop - Warenkorb',
component: RewardShoppingCartComponent,
},
];

View File

@@ -0,0 +1,460 @@
# @isa/checkout/feature/select-branch-dropdown
Branch selection dropdown components for the checkout domain, enabling users to select a branch for their checkout session.
## Overview
The Select Branch Dropdown feature library provides two UI components for branch selection in the checkout flow:
- **SelectedBranchDropdownComponent** - High-level component that integrates with `CheckoutMetadataService` for automatic persistence
- **BranchDropdownComponent** - Low-level component with `ControlValueAccessor` support for custom form integration
## Table of Contents
- [Features](#features)
- [Quick Start](#quick-start)
- [Component API Reference](#component-api-reference)
- [Usage Examples](#usage-examples)
- [State Management](#state-management)
- [Dependencies](#dependencies)
- [Testing](#testing)
- [Best Practices](#best-practices)
## Features
- **Branch Selection** - Dropdown interface for selecting a branch
- **Metadata Integration** - Persists selected branch in checkout metadata via `CheckoutMetadataService.setSelectedBranch()`
- **Standalone Component** - Modern Angular standalone architecture
- **Responsive Design** - Works across tablet and desktop layouts
- **E2E Testing Support** - Includes data-what and data-which attributes
## Quick Start
### 1. Import the Component
```typescript
import { SelectedBranchDropdownComponent } from '@isa/checkout/feature/select-branch-dropdown';
@Component({
selector: 'app-checkout',
imports: [SelectedBranchDropdownComponent],
template: `
<checkout-selected-branch-dropdown [tabId]="tabId()" />
`
})
export class CheckoutComponent {
tabId = injectTabId();
}
```
### 2. Using the Low-Level Component with Forms
```typescript
import { BranchDropdownComponent } from '@isa/checkout/feature/select-branch-dropdown';
@Component({
selector: 'app-branch-form',
imports: [BranchDropdownComponent, ReactiveFormsModule],
template: `
<checkout-branch-dropdown formControlName="branch" />
`
})
export class BranchFormComponent {}
```
## Component API Reference
### SelectedBranchDropdownComponent
High-level branch selection component with automatic `CheckoutMetadataService` integration.
**Selector:** `checkout-selected-branch-dropdown`
**Inputs:**
| Input | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `tabId` | `number` | Yes | - | The tab ID for metadata storage |
| `appearance` | `DropdownAppearance` | No | `AccentOutline` | Visual style of the dropdown |
**Example:**
```html
<checkout-selected-branch-dropdown
[tabId]="tabId()"
[appearance]="DropdownAppearance.AccentOutline"
/>
```
---
### BranchDropdownComponent
Low-level branch dropdown with `ControlValueAccessor` support for reactive forms.
**Selector:** `checkout-branch-dropdown`
**Inputs:**
| Input | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `appearance` | `DropdownAppearance` | No | `AccentOutline` | Visual style of the dropdown |
**Outputs:**
| Output | Type | Description |
|--------|------|-------------|
| `selected` | `Branch \| null` | Two-way bindable selected branch (model) |
**Example:**
```html
<!-- With ngModel -->
<checkout-branch-dropdown [(selected)]="selectedBranch" />
<!-- With reactive forms -->
<checkout-branch-dropdown formControlName="branch" />
```
## Usage Examples
### Basic Usage in Checkout Flow
```typescript
import { Component } from '@angular/core';
import { SelectedBranchDropdownComponent } from '@isa/checkout/feature/select-branch-dropdown';
import { injectTabId } from '@isa/core/tabs';
@Component({
selector: 'app-checkout-header',
imports: [SelectedBranchDropdownComponent],
template: `
<div class="checkout-header">
<h1>Checkout</h1>
<checkout-selected-branch-dropdown [tabId]="tabId()" />
</div>
`
})
export class CheckoutHeaderComponent {
tabId = injectTabId();
}
```
### Using BranchDropdownComponent with Reactive Forms
```typescript
import { Component, inject } from '@angular/core';
import { FormBuilder, ReactiveFormsModule } from '@angular/forms';
import { BranchDropdownComponent } from '@isa/checkout/feature/select-branch-dropdown';
@Component({
selector: 'app-branch-form-example',
imports: [BranchDropdownComponent, ReactiveFormsModule],
template: `
<form [formGroup]="form">
<checkout-branch-dropdown formControlName="branch" />
</form>
`
})
export class BranchFormExampleComponent {
#fb = inject(FormBuilder);
form = this.#fb.group({
branch: [null]
});
}
```
### Reading Selected Branch from Metadata
```typescript
import { Component, inject, computed } from '@angular/core';
import { CheckoutMetadataService } from '@isa/checkout/data-access';
import { injectTabId } from '@isa/core/tabs';
@Component({
selector: 'app-branch-reader-example',
template: `
@if (selectedBranch(); as branch) {
<p>Selected Branch: {{ branch.name }}</p>
} @else {
<p>No branch selected</p>
}
`
})
export class BranchReaderExampleComponent {
#checkoutMetadata = inject(CheckoutMetadataService);
tabId = injectTabId();
selectedBranch = computed(() => {
return this.#checkoutMetadata.getSelectedBranch(this.tabId()!);
});
}
```
## State Management
### CheckoutMetadataService Integration
The component integrates with `CheckoutMetadataService` from `@isa/checkout/data-access`:
```typescript
// Set selected branch (stores the full Branch object)
this.#checkoutMetadata.setSelectedBranch(tabId, branch);
// Get selected branch (returns Branch | undefined)
const branch = this.#checkoutMetadata.getSelectedBranch(tabId);
// Clear selected branch
this.#checkoutMetadata.setSelectedBranch(tabId, undefined);
```
**State Persistence:**
- Branch selection persists in tab metadata
- Available across all checkout components in the same tab
- Cleared when tab is closed or session ends
## Dependencies
### Required Libraries
#### Angular Core
- `@angular/core` - Angular framework
- `@angular/forms` - Form controls and ControlValueAccessor
#### ISA Feature Libraries
- `@isa/checkout/data-access` - CheckoutMetadataService, BranchesResource, Branch type
- `@isa/core/logging` - Logger utility
#### ISA UI Libraries
- `@isa/ui/input-controls` - DropdownButtonComponent, DropdownFilterComponent, DropdownOptionComponent
### Path Alias
Import from: `@isa/checkout/feature/select-branch-dropdown`
```typescript
// Import components
import {
SelectedBranchDropdownComponent,
BranchDropdownComponent
} from '@isa/checkout/feature/select-branch-dropdown';
```
## Testing
The library uses **Vitest** with **Angular Testing Utilities** for testing.
### Running Tests
```bash
# Run tests for this library
npx nx test checkout-feature-select-branch-dropdown --skip-nx-cache
# Run tests with coverage
npx nx test checkout-feature-select-branch-dropdown --coverage.enabled=true --skip-nx-cache
# Run tests in watch mode
npx nx test checkout-feature-select-branch-dropdown --watch
```
### Test Configuration
- **Framework:** Vitest
- **Test Runner:** @nx/vite:test
- **Coverage:** v8 provider with Cobertura reports
- **JUnit XML:** `testresults/junit-checkout-feature-select-branch-dropdown.xml`
- **Coverage XML:** `coverage/libs/checkout/feature/select-branch-dropdown/cobertura-coverage.xml`
### Testing Recommendations
#### Component Testing
```typescript
import { TestBed } from '@angular/core/testing';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { SelectedBranchDropdownComponent } from './selected-branch-dropdown.component';
import { CheckoutMetadataService, BranchesResource } from '@isa/checkout/data-access';
import { signal } from '@angular/core';
describe('SelectedBranchDropdownComponent', () => {
beforeEach(() => {
TestBed.configureTestingModule({
imports: [SelectedBranchDropdownComponent],
providers: [
{
provide: CheckoutMetadataService,
useValue: {
setSelectedBranch: vi.fn(),
getSelectedBranch: vi.fn(() => null)
}
},
{
provide: BranchesResource,
useValue: {
resource: {
hasValue: () => true,
value: () => []
}
}
}
]
});
});
it('should create', () => {
const fixture = TestBed.createComponent(SelectedBranchDropdownComponent);
fixture.componentRef.setInput('tabId', 1);
fixture.detectChanges();
expect(fixture.componentInstance).toBeTruthy();
});
});
```
### E2E Attribute Requirements
All interactive elements should include `data-what` and `data-which` attributes:
```html
<!-- Dropdown -->
<select
data-what="branch-dropdown"
data-which="branch-selection">
</select>
<!-- Option -->
<option
data-what="branch-option"
[attr.data-which]="branch.id">
</option>
```
## Best Practices
### 1. Always Use Tab Context
Ensure tab context is available when using the component:
```typescript
// Good
const tabId = this.#tabService.activatedTabId();
if (tabId) {
this.#checkoutMetadata.setSelectedBranch(tabId, branch);
}
// Bad (missing tab context validation)
this.#checkoutMetadata.setSelectedBranch(null, branch);
```
### 2. Clear Branch on Checkout Completion
Always clear branch selection when checkout is complete:
```typescript
// Good
completeCheckout() {
// ... checkout logic
this.#checkoutMetadata.setSelectedBranch(tabId, undefined);
}
// Bad (leaves stale branch)
completeCheckout() {
// ... checkout logic
// Forgot to clear branch!
}
```
### 3. Use Computed Signals for Derived State
Leverage Angular signals for reactive values:
```typescript
// Good
selectedBranch = computed(() => {
return this.#checkoutMetadata.getSelectedBranch(this.tabId()!);
});
// Bad (manual tracking)
selectedBranch: Branch | undefined;
ngOnInit() {
effect(() => {
this.selectedBranch = this.#checkoutMetadata.getSelectedBranch(this.tabId()!);
});
}
```
### 4. Follow OnPush Change Detection
Always use OnPush change detection for performance:
```typescript
// Good
@Component({
selector: 'checkout-select-branch-dropdown',
changeDetection: ChangeDetectionStrategy.OnPush,
// ...
})
// Bad (default change detection)
@Component({
selector: 'checkout-select-branch-dropdown',
// Missing changeDetection
})
```
### 5. Add E2E Attributes
Always include data-what and data-which attributes:
```typescript
// Good
<select
data-what="branch-dropdown"
data-which="branch-selection"
[(ngModel)]="selectedBranch">
</select>
// Bad (no E2E attributes)
<select [(ngModel)]="selectedBranch">
</select>
```
## Architecture Notes
### Component Structure
```
SelectedBranchDropdownComponent (high-level, with metadata integration)
├── Uses BranchDropdownComponent internally
├── CheckoutMetadataService integration
├── Tab context via tabId input
└── Automatic branch persistence
BranchDropdownComponent (low-level, form-compatible)
├── ControlValueAccessor implementation
├── BranchesResource for loading options
├── DropdownButtonComponent from @isa/ui/input-controls
└── Filter support via DropdownFilterComponent
```
### Data Flow
```
SelectedBranchDropdownComponent:
1. Component receives tabId input
2. Reads current branch from CheckoutMetadataService.getSelectedBranch(tabId)
3. User selects branch from dropdown
4. setSelectedBranch(tabId, branch) called
5. Other checkout components react to metadata change
BranchDropdownComponent:
1. BranchesResource loads available branches
2. User selects branch from dropdown
3. selected model emits new value
4. ControlValueAccessor notifies form (if used with forms)
```
## License
Internal ISA Frontend library - not for external distribution.

View File

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

View File

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

View File

@@ -0,0 +1,2 @@
export * from './lib/branch-dropdown.component';
export * from './lib/selected-branch-dropdown.component';

View File

@@ -0,0 +1,3 @@
:host {
display: block;
}

View File

@@ -0,0 +1,27 @@
<ui-dropdown
class="max-w-64"
[value]="selected()"
[appearance]="appearance()"
label="Filiale auswählen"
[disabled]="disabled()"
[equals]="equals"
(valueChange)="onSelectionChange($event)"
data-what="branch-dropdown"
data-which="select-branch"
aria-label="Select branch"
>
<ui-dropdown-filter placeholder="Filiale suchen..."></ui-dropdown-filter>
@for (branch of options(); track branch.id) {
<ui-dropdown-option
[value]="branch"
[attr.data-what]="'branch-option'"
[attr.data-which]="branch.id"
>
{{ branch.key }} - {{ branch.name }}
</ui-dropdown-option>
} @empty {
<ui-dropdown-option [value]="null" [disabled]="true">
Keine Filialen verfügbar
</ui-dropdown-option>
}
</ui-dropdown>

View File

@@ -0,0 +1,84 @@
import {
Component,
forwardRef,
inject,
input,
linkedSignal,
model,
signal,
} from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
import {
DropdownButtonComponent,
DropdownAppearance,
DropdownFilterComponent,
DropdownOptionComponent,
} from '@isa/ui/input-controls';
import { Branch, BranchesResource } from '@isa/checkout/data-access';
import { logger } from '@isa/core/logging';
@Component({
selector: 'checkout-branch-dropdown',
templateUrl: 'branch-dropdown.component.html',
styleUrls: ['branch-dropdown.component.css'],
imports: [
DropdownButtonComponent,
DropdownFilterComponent,
DropdownOptionComponent,
],
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => BranchDropdownComponent),
multi: true,
},
],
})
export class BranchDropdownComponent implements ControlValueAccessor {
#logger = logger({ component: 'BranchDropdownComponent' });
#branchesResource = inject(BranchesResource).resource;
readonly DropdownAppearance = DropdownAppearance;
readonly appearance = input<DropdownAppearance>(
DropdownAppearance.AccentOutline,
);
readonly selected = model<Branch | null>(null);
readonly disabled = signal(false);
readonly options = linkedSignal<Branch[]>(() =>
this.#branchesResource.hasValue() ? this.#branchesResource.value() : [],
);
readonly equals = (a: Branch | null, b: Branch | null) => a?.id === b?.id;
#onChange?: (value: Branch | null) => void;
#onTouched?: () => void;
writeValue(value: Branch | null): void {
this.#logger.debug('writeValue', () => ({ branchId: value?.id }));
this.selected.set(value);
}
registerOnChange(fn: (value: Branch | null) => void): void {
this.#onChange = fn;
}
registerOnTouched(fn: () => void): void {
this.#onTouched = fn;
}
setDisabledState(isDisabled: boolean): void {
this.#logger.debug('setDisabledState', () => ({ isDisabled }));
this.disabled.set(isDisabled);
}
onSelectionChange(branch: Branch | undefined): void {
const value = branch ?? null;
this.#logger.debug('Selection changed', () => ({ branchId: value?.id }));
this.selected.set(value);
this.#onChange?.(value);
this.#onTouched?.();
}
}

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