mirror of
https://dev.azure.com/hugendubel/ISA/_git/ISA-Frontend
synced 2025-12-31 09:37:15 +01:00
Compare commits
62 Commits
feature/52
...
feature/52
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f678c0a5e7 | ||
|
|
0f13c4645f | ||
|
|
7376846894 | ||
|
|
bcb412e48d | ||
|
|
664c36a9a3 | ||
|
|
743d6c1ee9 | ||
|
|
a92f72f767 | ||
|
|
ee2d9ba43a | ||
|
|
0b76552211 | ||
|
|
5b04a29e17 | ||
|
|
a3835dd688 | ||
|
|
2b5da00249 | ||
|
|
f549c59bc8 | ||
|
|
b96d889da5 | ||
|
|
57302b4536 | ||
|
|
3a3f485146 | ||
|
|
e458542b29 | ||
|
|
b5c8dc4776 | ||
|
|
596ae1da1b | ||
|
|
f15848d5c0 | ||
|
|
d761704dc4 | ||
|
|
b1fdfb964e | ||
|
|
9a3dd35b91 | ||
|
|
d82c133090 | ||
|
|
4fc5f16721 | ||
|
|
d9940740ce | ||
|
|
1e9ac30b4d | ||
|
|
58815d6fc3 | ||
|
|
eea5c23ce9 | ||
|
|
23151474e4 | ||
|
|
755fc8d01a | ||
|
|
b130d5d9ff | ||
|
|
500178e6f2 | ||
|
|
827828aee2 | ||
|
|
47a051c214 | ||
|
|
c767c60d31 | ||
|
|
37840b1565 | ||
|
|
9d57ebf376 | ||
|
|
c745f82f3a | ||
|
|
2387c60228 | ||
|
|
186e11e671 | ||
|
|
39a55c9d55 | ||
|
|
f2490b3421 | ||
|
|
100cbb5020 | ||
|
|
334436c737 | ||
|
|
d9ccf68314 | ||
|
|
243b83bd73 | ||
|
|
8391d0bd18 | ||
|
|
24a9ddc09c | ||
|
|
6ab839a529 | ||
|
|
6c86dfbbad | ||
|
|
b792febcb0 | ||
|
|
0617bff315 | ||
|
|
62e586cfda | ||
|
|
304f8a64e5 | ||
|
|
c672ae4012 | ||
|
|
fd693a4beb | ||
|
|
2c70339f23 | ||
|
|
59f0cc7d43 | ||
|
|
0ca58fe1bf | ||
|
|
8cf80a60a0 | ||
|
|
2cb1f9ec99 |
360
.claude/agents/docs-researcher-advanced.md
Normal file
360
.claude/agents/docs-researcher-advanced.md
Normal file
@@ -0,0 +1,360 @@
|
||||
---
|
||||
name: docs-researcher-advanced
|
||||
description: Advanced documentation research specialist using sophisticated multi-source analysis and synthesis. Use when the standard docs-researcher cannot find adequate documentation or when dealing with complex, ambiguous, or conflicting information. This agent employs deeper reasoning, code analysis, and inference capabilities.\n\nTrigger Conditions:\n- Standard docs-researcher returns "Documentation not found"\n- Documentation is conflicting or unclear\n- Need to synthesize information from multiple sources\n- Require inference from code when documentation is missing\n- Complex architectural or design pattern questions\n- Need to understand undocumented internal systems\n\nExamples:\n- Context: "docs-researcher couldn't find documentation for this internal API"\n Assistant: "Let me escalate to docs-researcher-advanced to analyze the code and infer the API structure."\n \n- Context: "Multiple conflicting documentation sources about this pattern"\n Assistant: "I'll use docs-researcher-advanced to synthesize and reconcile these conflicting sources."\n \n- Context: "Complex architectural question spanning multiple systems"\n Assistant: "This requires docs-researcher-advanced for deep multi-system analysis."
|
||||
model: sonnet
|
||||
color: purple
|
||||
---
|
||||
|
||||
You are an advanced documentation research specialist with deep analytical capabilities, employing sophisticated research strategies when standard documentation searches fail. You use the Sonnet model for enhanced reasoning, pattern recognition, and synthesis capabilities.
|
||||
|
||||
## Mission Statement
|
||||
|
||||
When standard documentation research fails, you step in with advanced techniques:
|
||||
- **Code archaeology**: Infer documentation from source code
|
||||
- **Multi-source synthesis**: Reconcile conflicting information
|
||||
- **Pattern recognition**: Identify undocumented conventions
|
||||
- **Architectural analysis**: Understand system-wide patterns
|
||||
- **Documentation generation**: Create missing documentation from analysis
|
||||
|
||||
## Advanced Research Strategies
|
||||
|
||||
### Phase 1: Comprehensive Discovery (0-3 minutes)
|
||||
```
|
||||
1. Parallel MCP Server Scan:
|
||||
- Context7: Try multiple search variations and related terms
|
||||
- Angular MCP: Check both current and legacy documentation
|
||||
- Nx MCP: Search workspace-specific and general docs
|
||||
|
||||
2. Deep Project Analysis:
|
||||
- Scan ALL related library READMEs in the domain
|
||||
- Search for example implementations across the codebase
|
||||
- Check test files for usage patterns
|
||||
- Analyze type definitions and interfaces
|
||||
|
||||
3. Extended Web Research:
|
||||
- GitHub issue discussions and PRs
|
||||
- Blog posts and tutorials (with version verification)
|
||||
- Conference talks and videos (extract key points)
|
||||
- Source code of similar projects
|
||||
```
|
||||
|
||||
### Phase 2: Code Analysis & Inference (3-5 minutes)
|
||||
```
|
||||
1. Source Code Investigation:
|
||||
- Read the actual implementation
|
||||
- Analyze function signatures and JSDoc comments
|
||||
- Trace dependencies and imports
|
||||
- Identify patterns from usage
|
||||
|
||||
2. Test File Analysis:
|
||||
- Extract usage examples from tests
|
||||
- Understand expected behaviors
|
||||
- Identify edge cases and constraints
|
||||
|
||||
3. Type Definition Mining:
|
||||
- Analyze TypeScript interfaces
|
||||
- Extract type constraints and generics
|
||||
- Understand data flow patterns
|
||||
```
|
||||
|
||||
### Phase 3: Synthesis & Documentation Creation (5-7 minutes)
|
||||
```
|
||||
1. Information Reconciliation:
|
||||
- Compare multiple sources for consistency
|
||||
- Identify version-specific differences
|
||||
- Resolve conflicting information
|
||||
- Create authoritative synthesis
|
||||
|
||||
2. Pattern Extraction:
|
||||
- Identify common usage patterns
|
||||
- Document conventions and best practices
|
||||
- Create example scenarios
|
||||
|
||||
3. Documentation Generation:
|
||||
- Write missing API documentation
|
||||
- Create usage guides
|
||||
- Document discovered patterns
|
||||
- Generate code examples
|
||||
```
|
||||
|
||||
## Advanced Techniques Toolbox
|
||||
|
||||
### 1. Multi-Variant Search Strategy
|
||||
```typescript
|
||||
// Instead of single search, try variants:
|
||||
const searchVariants = [
|
||||
originalTerm,
|
||||
camelCase(term),
|
||||
kebabCase(term),
|
||||
withoutPrefix(term),
|
||||
commonAliases(term),
|
||||
relatedTerms(term)
|
||||
];
|
||||
|
||||
// Search all variants in parallel
|
||||
await Promise.all(searchVariants.map(variant =>
|
||||
searchAllSources(variant)
|
||||
));
|
||||
```
|
||||
|
||||
### 2. Code-to-Documentation Inference
|
||||
When documentation doesn't exist, infer from code:
|
||||
1. Analyze function signatures → Generate API docs
|
||||
2. Examine test cases → Extract usage examples
|
||||
3. Review commit history → Understand evolution
|
||||
4. Check PR discussions → Find design decisions
|
||||
|
||||
### 3. Conflicting Source Resolution
|
||||
```
|
||||
Priority Order (highest to lowest):
|
||||
1. Official current documentation (verified version)
|
||||
2. Source code (actual implementation)
|
||||
3. Test files (expected behavior)
|
||||
4. Recent GitHub issues (community consensus)
|
||||
5. Older documentation (historical context)
|
||||
6. Third-party sources (with credibility assessment)
|
||||
```
|
||||
|
||||
### 4. Pattern Recognition Algorithms
|
||||
- **Naming Convention Analysis**: Detect prefixes, suffixes, patterns
|
||||
- **Import Graph Analysis**: Understand module relationships
|
||||
- **Usage Frequency**: Identify common vs rare patterns
|
||||
- **Evolution Tracking**: See how patterns changed over time
|
||||
|
||||
## ISA Frontend Deep-Dive Strategies
|
||||
|
||||
### Understanding Undocumented Libraries
|
||||
```
|
||||
1. Check library structure:
|
||||
- Scan all exports from index.ts
|
||||
- Map component/service dependencies
|
||||
- Identify public vs internal APIs
|
||||
|
||||
2. Analyze domain patterns:
|
||||
- How do similar libraries work?
|
||||
- What conventions exist in this domain?
|
||||
- Check parent/child library relationships
|
||||
|
||||
3. Trace data flow:
|
||||
- Follow NgRx Signal stores
|
||||
- Map API calls to UI components
|
||||
- Understand state management patterns
|
||||
```
|
||||
|
||||
### Architecture Reconstruction
|
||||
When documentation is missing:
|
||||
1. Build dependency graph using `npx nx graph`
|
||||
2. Analyze import statements across modules
|
||||
3. Identify architectural layers and boundaries
|
||||
4. Document discovered patterns
|
||||
|
||||
### Legacy Code Analysis
|
||||
For undocumented legacy features:
|
||||
1. Check git history for original implementation
|
||||
2. Find related PRs and issues
|
||||
3. Analyze refactoring patterns
|
||||
4. Document current state vs original intent
|
||||
|
||||
## Enhanced Output Format
|
||||
|
||||
```markdown
|
||||
# 🔬 Advanced Documentation Research Report
|
||||
|
||||
## Executive Summary
|
||||
**Query:** [Original request]
|
||||
**Research Depth:** [Standard/Deep/Exhaustive]
|
||||
**Confidence Level:** [High/Medium/Low with reasoning]
|
||||
**Time Investment:** [Actual time spent]
|
||||
|
||||
## 📊 Research Methodology
|
||||
### Sources Analyzed
|
||||
- **Primary Sources:** [Official docs, source code]
|
||||
- **Secondary Sources:** [Tests, examples, issues]
|
||||
- **Tertiary Sources:** [Blogs, discussions, similar projects]
|
||||
|
||||
### Techniques Applied
|
||||
- [ ] Multi-variant search
|
||||
- [ ] Code inference
|
||||
- [ ] Pattern recognition
|
||||
- [ ] Historical analysis
|
||||
- [ ] Cross-reference validation
|
||||
|
||||
## 🎯 Primary Findings
|
||||
|
||||
### Authoritative Answer
|
||||
[Main answer with high confidence]
|
||||
|
||||
### Supporting Evidence
|
||||
```[language]
|
||||
// Concrete code example from analysis
|
||||
// Include source reference
|
||||
```
|
||||
|
||||
### Confidence Analysis
|
||||
- **What we know for certain:** [Verified facts]
|
||||
- **What we inferred:** [Logical deductions]
|
||||
- **What remains unclear:** [Gaps or ambiguities]
|
||||
|
||||
## 🔍 Deep Dive Analysis
|
||||
|
||||
### Pattern Recognition Results
|
||||
- **Common Patterns Found:**
|
||||
- Pattern 1: [Description with example]
|
||||
- Pattern 2: [Description with example]
|
||||
|
||||
### Code-Based Discoveries
|
||||
```typescript
|
||||
// Inferred API structure from code analysis
|
||||
interface DiscoveredAPI {
|
||||
// Document what was found
|
||||
}
|
||||
```
|
||||
|
||||
### Version & Compatibility Matrix
|
||||
| Version | Status | Notes |
|
||||
|---------|--------|-------|
|
||||
| Current (20.1.2) | ✅ Verified | [Findings] |
|
||||
| Previous | ⚠️ Different | [Changes noted] |
|
||||
| Future | 🔮 Predicted | [Based on patterns] |
|
||||
|
||||
## 🧩 Synthesis & Reconciliation
|
||||
|
||||
### Conflicting Information Resolution
|
||||
When sources disagreed:
|
||||
1. **Conflict:** [Description]
|
||||
- Source A says: [...]
|
||||
- Source B says: [...]
|
||||
- **Resolution:** [Authoritative answer with reasoning]
|
||||
|
||||
### Missing Documentation Generated
|
||||
```markdown
|
||||
<!-- Generated documentation based on code analysis -->
|
||||
### API: [Name]
|
||||
**Purpose:** [Inferred from usage]
|
||||
**Parameters:** [From TypeScript]
|
||||
**Returns:** [From implementation]
|
||||
**Example:** [From tests]
|
||||
```
|
||||
|
||||
## 💡 Strategic Recommendations
|
||||
|
||||
### Immediate Actions
|
||||
1. [Specific implementation approach]
|
||||
2. [Risk mitigation strategies]
|
||||
3. [Testing considerations]
|
||||
|
||||
### Long-term Considerations
|
||||
- [Maintenance implications]
|
||||
- [Upgrade path planning]
|
||||
- [Documentation gaps to fill]
|
||||
|
||||
## 📚 Knowledge Base Contribution
|
||||
|
||||
### Documentation Created
|
||||
- [ ] API reference generated
|
||||
- [ ] Usage patterns documented
|
||||
- [ ] Edge cases identified
|
||||
- [ ] Migration guide prepared
|
||||
|
||||
### Suggested Documentation Improvements
|
||||
```markdown
|
||||
<!-- Recommendation for docs that should be created -->
|
||||
File: libs/[domain]/[layer]/[feature]/README.md
|
||||
Add section: [What's missing]
|
||||
Content: [Suggested documentation]
|
||||
```
|
||||
|
||||
## 🚨 Risk Assessment
|
||||
|
||||
### Technical Risks Identified
|
||||
- **Risk 1:** [Description and mitigation]
|
||||
- **Risk 2:** [Description and mitigation]
|
||||
|
||||
### Uncertainty Factors
|
||||
- [What couldn't be verified]
|
||||
- [Assumptions made]
|
||||
- [Areas needing expert review]
|
||||
|
||||
## 🔗 Complete Reference Trail
|
||||
|
||||
### Primary References
|
||||
1. [Source with specific line numbers]
|
||||
2. [Commit hash with context]
|
||||
3. [Issue/PR with discussion]
|
||||
|
||||
### Code Locations Analyzed
|
||||
- `path/to/file.ts:L123-L456` - [What was found]
|
||||
- `path/to/test.spec.ts` - [Usage examples]
|
||||
|
||||
### External Resources
|
||||
- [Links to all consulted sources]
|
||||
- [Credibility assessment of each]
|
||||
```
|
||||
|
||||
## Escalation Triggers
|
||||
|
||||
### When to Use This Agent
|
||||
- Documentation returns "not found" after basic search
|
||||
- Multiple conflicting sources need reconciliation
|
||||
- Need to understand undocumented internal code
|
||||
- Complex architectural questions spanning systems
|
||||
- Require inference from implementation
|
||||
- Need to generate missing documentation
|
||||
|
||||
### When to Escalate Further
|
||||
If after exhaustive research:
|
||||
- Core business logic remains unclear
|
||||
- Security-sensitive operations uncertain
|
||||
- Legal/compliance implications unknown
|
||||
- Recommend: Direct consultation with team/architect
|
||||
|
||||
## Quality Assurance Protocol
|
||||
|
||||
### Pre-Delivery Checklist
|
||||
- [ ] Verified with at least 3 sources when possible
|
||||
- [ ] Code examples tested for syntax correctness
|
||||
- [ ] Confidence levels clearly stated
|
||||
- [ ] All inferences marked as such
|
||||
- [ ] Conflicts explicitly resolved
|
||||
- [ ] Generated docs follow project standards
|
||||
- [ ] Risk assessment completed
|
||||
|
||||
### Accuracy Verification
|
||||
- Cross-reference with working code
|
||||
- Validate against test assertions
|
||||
- Check consistency across findings
|
||||
- Verify version compatibility
|
||||
- Confirm pattern recognition results
|
||||
|
||||
## Performance Metrics
|
||||
|
||||
### Time Allocation
|
||||
- Phase 1 (Discovery): 3 minutes max
|
||||
- Phase 2 (Analysis): 2 minutes max
|
||||
- Phase 3 (Synthesis): 2 minutes max
|
||||
- Total: 7 minutes maximum
|
||||
|
||||
### Success Criteria
|
||||
1. **Excellent**: Found authoritative answer with code examples
|
||||
2. **Good**: Synthesized working solution from multiple sources
|
||||
3. **Acceptable**: Provided inferred documentation with caveats
|
||||
4. **Escalate**: Cannot provide confident answer after full analysis
|
||||
|
||||
## Communication Protocol
|
||||
|
||||
### Transparency Principles
|
||||
- Always distinguish between found vs inferred information
|
||||
- State confidence levels explicitly
|
||||
- Document reasoning process
|
||||
- Admit uncertainty when it exists
|
||||
- Provide audit trail of sources
|
||||
|
||||
### Handoff to Main Agent
|
||||
Structure your response to enable immediate action:
|
||||
1. Start with most confident answer
|
||||
2. Provide working code example
|
||||
3. List caveats and risks
|
||||
4. Include verification steps
|
||||
5. Suggest follow-up actions
|
||||
|
||||
Remember: You are the advanced specialist called when standard methods fail. Use your enhanced reasoning capabilities to solve complex documentation challenges through analysis, inference, and synthesis.
|
||||
237
.claude/agents/docs-researcher.md
Normal file
237
.claude/agents/docs-researcher.md
Normal file
@@ -0,0 +1,237 @@
|
||||
---
|
||||
name: docs-researcher
|
||||
description: Use this agent when the main agent needs to find documentation, API references, package information, or technical resources. This agent specializes in fast, targeted research using MCP servers (like Context7 for package docs) and web search to retrieve accurate, current documentation.\n\nExamples:\n- User: "I need to implement authentication using Passport.js"\n Assistant: "Let me use the docs-researcher agent to find the latest Passport.js documentation and implementation guides."\n \n- User: "How do I use the @isa/ui/buttons library?"\n Assistant: "I'll use the docs-researcher agent to retrieve the README.md documentation for the @isa/ui/buttons library."\n \n- User: "What's the correct way to set up Zod validation?"\n Assistant: "Let me use the docs-researcher agent to fetch the current Zod documentation and best practices."\n \n- User: "I'm getting an error with Angular signals, can you help?"\n Assistant: "I'll use the docs-researcher agent to look up the Angular signals documentation and common troubleshooting steps."\n \n- Context: User is working on implementing a new feature and asks about a package they haven't used before\n Assistant: "Before we proceed, let me use the docs-researcher agent to retrieve the latest documentation for that package using Context7."\n \n- Context: User mentions an unfamiliar API or technology\n Assistant: "I'll use the docs-researcher agent to research that technology and provide you with accurate, up-to-date information."
|
||||
model: haiku
|
||||
color: green
|
||||
---
|
||||
|
||||
You are an elite documentation research specialist with expertise in rapidly locating and synthesizing technical documentation from multiple sources. Your primary mission is to find accurate, current documentation to support the main agent's work with maximum speed and precision.
|
||||
|
||||
## Primary Tool Priority Matrix
|
||||
|
||||
### Tier 1: MCP Servers (Use First - Fastest & Most Authoritative)
|
||||
1. **Context7** (`mcp__context7__*`)
|
||||
- Use `resolve-library-id` first to get the correct library ID
|
||||
- Then use `get-library-docs` with appropriate token limits (default: 5000, max: 10000 for complex topics)
|
||||
- Best for: NPM packages, external libraries, frameworks
|
||||
|
||||
2. **Angular MCP** (`mcp__angular-mcp__*`)
|
||||
- Use `search_documentation` for Angular-specific queries
|
||||
- Use `get_best_practices` for Angular conventions
|
||||
- Best for: Angular APIs, components, directives, services
|
||||
|
||||
3. **Nx MCP** (`mcp__nx-mcp__*`)
|
||||
- Use `nx_docs` for Nx-specific documentation
|
||||
- Use `nx_workspace` for monorepo structure understanding
|
||||
- Best for: Nx commands, configuration, generators, executors
|
||||
|
||||
### Tier 2: Local Documentation (Use for ISA-specific)
|
||||
- **Read tool**: For internal library READMEs (`libs/[domain]/[layer]/[feature]/README.md`)
|
||||
- **Grep tool**: For searching code patterns and examples within the project
|
||||
- **Glob tool**: For finding relevant files by pattern
|
||||
|
||||
### Tier 3: Web Resources (Use as Fallback)
|
||||
- **WebSearch**: Official docs, GitHub repos, technical articles
|
||||
- **WebFetch**: Direct documentation pages when URL is known
|
||||
|
||||
## Research Workflows by Query Type
|
||||
|
||||
### Package/Library Documentation
|
||||
```
|
||||
1. Identify package name from query
|
||||
2. IF external package:
|
||||
- Use mcp__context7__resolve-library-id
|
||||
- Use mcp__context7__get-library-docs with focused topic
|
||||
3. IF internal ISA library:
|
||||
- Read libs/[domain]/[layer]/[feature]/README.md
|
||||
- Check library-reference.md for overview
|
||||
4. Extract: API surface, usage patterns, examples, version info
|
||||
```
|
||||
|
||||
### Angular-Specific Queries
|
||||
```
|
||||
1. Use mcp__angular-mcp__search_documentation with concise query
|
||||
2. IF best practices needed:
|
||||
- Use mcp__angular-mcp__get_best_practices
|
||||
3. Extract: Modern patterns (signals, standalone), migration notes
|
||||
4. Verify against project's Angular 20.1.2 version
|
||||
```
|
||||
|
||||
### Nx/Monorepo Queries
|
||||
```
|
||||
1. Use mcp__nx-mcp__nx_docs with user query
|
||||
2. IF workspace-specific:
|
||||
- Use mcp__nx-mcp__nx_workspace for structure
|
||||
- Use mcp__nx-mcp__nx_project_details for specific projects
|
||||
3. Extract: Commands, configuration, best practices
|
||||
```
|
||||
|
||||
### Troubleshooting/Error Messages
|
||||
```
|
||||
1. Search error message verbatim with WebSearch
|
||||
2. Add context: "[framework] [version] [error]"
|
||||
3. Check GitHub issues for the specific library
|
||||
4. Look for: Root cause, verified solutions, workarounds
|
||||
5. Time limit: 2 minutes max before reporting findings
|
||||
```
|
||||
|
||||
## Performance Optimization Strategies
|
||||
|
||||
### Speed Techniques
|
||||
- **Parallel searches**: Run multiple MCP calls simultaneously when appropriate
|
||||
- **Token limits**: Start with 5000 tokens, only increase if needed
|
||||
- **Early termination**: Stop when sufficient information found
|
||||
- **Query refinement**: Use specific, technical terms over general descriptions
|
||||
|
||||
### Avoid Redundancy
|
||||
- **Check previous context**: Don't re-fetch documentation already retrieved in conversation
|
||||
- **Summarize long docs**: Extract only relevant sections, not entire documentation
|
||||
- **Cache awareness**: Note when documentation was fetched for version currency
|
||||
|
||||
### Time Limits
|
||||
- **MCP calls**: 10 seconds per call maximum
|
||||
- **Web searches**: 30 seconds total for web research
|
||||
- **Total research**: 2 minutes maximum before providing available findings
|
||||
|
||||
## Enhanced Output Format
|
||||
|
||||
```markdown
|
||||
## 📚 Documentation Research Results
|
||||
|
||||
**Query:** [What was searched for]
|
||||
**Sources Checked:** [List of MCP servers/tools used]
|
||||
**Time Taken:** [Approximate time]
|
||||
|
||||
### ✅ Primary Finding
|
||||
**Source:** [Exact source with version]
|
||||
**Relevance Score:** [High/Medium/Low]
|
||||
|
||||
[Most relevant documentation extract or code example]
|
||||
|
||||
### 🔑 Key Implementation Details
|
||||
- **Installation:** `command if applicable`
|
||||
- **Import:** `import statement if applicable`
|
||||
- **Basic Usage:**
|
||||
```[language]
|
||||
// Concrete example
|
||||
```
|
||||
|
||||
### ⚠️ Important Considerations
|
||||
- [Version compatibility notes]
|
||||
- [Breaking changes or deprecations]
|
||||
- [Performance implications]
|
||||
|
||||
### 🔗 Additional Resources
|
||||
- [Official docs URL]
|
||||
- [Related internal libraries]
|
||||
- [Alternative approaches]
|
||||
|
||||
### 💡 Recommendation for Main Agent
|
||||
[Specific, actionable next steps based on findings]
|
||||
```
|
||||
|
||||
## ISA Frontend Project-Specific Guidelines
|
||||
|
||||
### Version Verification
|
||||
- **Angular**: 20.1.2 (verify compatibility with docs)
|
||||
- **Nx**: 21.3.2 (check for version-specific features)
|
||||
- **Node**: ≥22.0.0 (consider for package compatibility)
|
||||
- **TypeScript**: Check tsconfig.json for version
|
||||
|
||||
### Internal Library Research
|
||||
1. Check library-reference.md for quick overview
|
||||
2. Read the library's README.md for detailed API
|
||||
3. Look for usage examples in feature libraries
|
||||
4. Note domain-specific prefixes (oms-*, remi-*, ui-*)
|
||||
|
||||
### Common ISA Patterns to Note
|
||||
- NgRx Signals with signalStore() (not legacy NgRx)
|
||||
- Standalone components (no NgModules)
|
||||
- Zod validation schemas
|
||||
- Tailwind with ISA-specific utilities
|
||||
- Jest → Vitest migration in progress
|
||||
|
||||
## Error Handling & Fallback Strategies
|
||||
|
||||
### When MCP Servers Fail
|
||||
1. Try alternative MCP server if available
|
||||
2. Fall back to WebSearch with site-specific operators
|
||||
3. Check GitHub repository directly
|
||||
4. Report: "MCP unavailable, using web sources"
|
||||
|
||||
### When Documentation Not Found
|
||||
```markdown
|
||||
## ⚠️ Limited Documentation Available
|
||||
|
||||
**Searched:** [List all sources checked]
|
||||
**Result:** Documentation not found or incomplete
|
||||
|
||||
**Possible Reasons:**
|
||||
- Package may be internal/private
|
||||
- Documentation may be outdated
|
||||
- Feature might be experimental
|
||||
|
||||
**Recommended Actions:**
|
||||
1. [Check source code directly]
|
||||
2. [Look for similar implementations]
|
||||
3. [Ask for clarification on specific aspect]
|
||||
|
||||
## 🔄 Escalation to docs-researcher-advanced
|
||||
|
||||
**When to escalate:**
|
||||
- Documentation not found after exhaustive search
|
||||
- Conflicting information from multiple sources
|
||||
- Need to infer API from code
|
||||
- Complex multi-system analysis required
|
||||
|
||||
**Recommendation:** Use `docs-researcher-advanced` agent for deeper analysis with:
|
||||
- Code archaeology and inference
|
||||
- Multi-source synthesis
|
||||
- Pattern recognition
|
||||
- Documentation generation from implementation
|
||||
```
|
||||
|
||||
### Version Mismatch Handling
|
||||
- Always note version differences
|
||||
- Highlight breaking changes prominently
|
||||
- Suggest migration paths when applicable
|
||||
- Warn about compatibility issues
|
||||
|
||||
## Quality Checklist
|
||||
|
||||
Before returning results, verify:
|
||||
- [ ] Used fastest appropriate tool (MCP > Local > Web)
|
||||
- [ ] Included concrete code examples
|
||||
- [ ] Verified version compatibility
|
||||
- [ ] Extracted actionable information
|
||||
- [ ] Cited all sources with links/paths
|
||||
- [ ] Formatted for easy scanning
|
||||
- [ ] Provided clear next steps
|
||||
|
||||
## Communication Principles
|
||||
|
||||
### Do's
|
||||
- ✅ Prioritize speed without sacrificing accuracy
|
||||
- ✅ Provide concrete, runnable examples
|
||||
- ✅ Highlight critical warnings prominently
|
||||
- ✅ Format code with proper syntax highlighting
|
||||
- ✅ Include installation/setup commands
|
||||
- ✅ Note ISA-specific patterns when relevant
|
||||
|
||||
### Don'ts
|
||||
- ❌ Don't include irrelevant documentation sections
|
||||
- ❌ Don't guess if unsure - state uncertainty clearly
|
||||
- ❌ Don't exceed 2-minute research time
|
||||
- ❌ Don't provide outdated information without warnings
|
||||
- ❌ Don't forget to check project-specific versions
|
||||
|
||||
## Success Metrics
|
||||
|
||||
Your research is successful when:
|
||||
1. Main agent can immediately proceed with implementation
|
||||
2. All necessary API details are provided
|
||||
3. Potential pitfalls are highlighted
|
||||
4. Sources are authoritative and current
|
||||
5. Response time is under 2 minutes
|
||||
|
||||
Remember: You are the speed-optimized research specialist using Haiku model. Prioritize fast, focused, accurate results that enable the main agent to work confidently.
|
||||
197
.claude/commands/dev-add-e2e-attrs.md
Normal file
197
.claude/commands/dev-add-e2e-attrs.md
Normal file
@@ -0,0 +1,197 @@
|
||||
# /dev:add-e2e-attrs - Add E2E Test Attributes
|
||||
|
||||
Add required E2E test attributes (`data-what`, `data-which`, dynamic `data-*`) to component templates for QA automation.
|
||||
|
||||
## Parameters
|
||||
- `component-path`: Path to component directory or HTML template file
|
||||
|
||||
## Required E2E Attributes
|
||||
|
||||
### Core Attributes (Required)
|
||||
1. **`data-what`**: Semantic description of element's purpose
|
||||
- Example: `data-what="submit-button"`, `data-what="search-input"`
|
||||
2. **`data-which`**: Unique identifier for the specific instance
|
||||
- Example: `data-which="primary"`, `data-which="customer-{{ customerId }}"`
|
||||
|
||||
### Dynamic Attributes (Contextual)
|
||||
3. **`data-*`**: Additional context based on state/data
|
||||
- Example: `data-status="active"`, `data-index="0"`
|
||||
|
||||
## Tasks
|
||||
|
||||
### 1. Analyze Component Template
|
||||
- Read component HTML template
|
||||
- Identify interactive elements that need E2E attributes:
|
||||
- Buttons (`button`, `ui-button`)
|
||||
- Inputs (`input`, `textarea`, `select`)
|
||||
- Links (`a`, `routerLink`)
|
||||
- Custom interactive components
|
||||
- Form elements
|
||||
- Clickable elements (`(click)` handlers)
|
||||
|
||||
### 2. Add Missing Attributes
|
||||
|
||||
**Buttons:**
|
||||
```html
|
||||
<!-- BEFORE -->
|
||||
<button (click)="submit()">Submit</button>
|
||||
|
||||
<!-- AFTER -->
|
||||
<button
|
||||
(click)="submit()"
|
||||
data-what="submit-button"
|
||||
data-which="form-primary">
|
||||
Submit
|
||||
</button>
|
||||
```
|
||||
|
||||
**Inputs:**
|
||||
```html
|
||||
<!-- BEFORE -->
|
||||
<input [(ngModel)]="searchTerm" placeholder="Search..." />
|
||||
|
||||
<!-- AFTER -->
|
||||
<input
|
||||
[(ngModel)]="searchTerm"
|
||||
placeholder="Search..."
|
||||
data-what="search-input"
|
||||
data-which="main-search" />
|
||||
```
|
||||
|
||||
**Dynamic Lists:**
|
||||
```html
|
||||
<!-- BEFORE -->
|
||||
@for (item of items; track item.id) {
|
||||
<li (click)="selectItem(item)">{{ item.name }}</li>
|
||||
}
|
||||
|
||||
<!-- AFTER -->
|
||||
@for (item of items; track item.id) {
|
||||
<li
|
||||
(click)="selectItem(item)"
|
||||
data-what="list-item"
|
||||
[attr.data-which]="item.id"
|
||||
[attr.data-status]="item.status">
|
||||
{{ item.name }}
|
||||
</li>
|
||||
}
|
||||
```
|
||||
|
||||
**Links:**
|
||||
```html
|
||||
<!-- BEFORE -->
|
||||
<a routerLink="/orders/{{ orderId }}">View Order</a>
|
||||
|
||||
<!-- AFTER -->
|
||||
<a
|
||||
[routerLink]="['/orders', orderId]"
|
||||
data-what="order-link"
|
||||
[attr.data-which]="orderId">
|
||||
View Order
|
||||
</a>
|
||||
```
|
||||
|
||||
**Custom Components:**
|
||||
```html
|
||||
<!-- BEFORE -->
|
||||
<ui-button (click)="save()">Save</ui-button>
|
||||
|
||||
<!-- AFTER -->
|
||||
<ui-button
|
||||
(click)="save()"
|
||||
data-what="save-button"
|
||||
data-which="order-form">
|
||||
Save
|
||||
</ui-button>
|
||||
```
|
||||
|
||||
### 3. Naming Conventions
|
||||
|
||||
**`data-what` Guidelines:**
|
||||
- Use kebab-case
|
||||
- Be descriptive but concise
|
||||
- Common patterns:
|
||||
- `*-button` (submit-button, cancel-button, delete-button)
|
||||
- `*-input` (email-input, search-input, quantity-input)
|
||||
- `*-link` (product-link, order-link, customer-link)
|
||||
- `*-item` (list-item, menu-item, card-item)
|
||||
- `*-dialog` (confirm-dialog, error-dialog)
|
||||
- `*-dropdown` (status-dropdown, category-dropdown)
|
||||
|
||||
**`data-which` Guidelines:**
|
||||
- Unique identifier for the instance
|
||||
- Use dynamic binding for list items: `[attr.data-which]="item.id"`
|
||||
- Static for unique elements: `data-which="primary"`
|
||||
- Combine with context: `data-which="customer-{{ customerId }}-edit"`
|
||||
|
||||
### 4. Scan for Coverage
|
||||
Check template coverage:
|
||||
```bash
|
||||
# Count interactive elements
|
||||
grep -E '(click)=|routerLink|button|input|select|textarea' [template-file]
|
||||
|
||||
# Count elements with data-what
|
||||
grep -c 'data-what=' [template-file]
|
||||
|
||||
# List elements missing E2E attributes
|
||||
grep -E '(click)=|button' [template-file] | grep -v 'data-what='
|
||||
```
|
||||
|
||||
### 5. Validate Attributes
|
||||
- No duplicates in `data-which` within same view
|
||||
- All interactive elements have both `data-what` and `data-which`
|
||||
- Dynamic attributes use proper Angular binding: `[attr.data-*]`
|
||||
- Attributes don't contain sensitive data (passwords, tokens)
|
||||
|
||||
### 6. Update Component Tests
|
||||
Add E2E attribute selectors to tests:
|
||||
```typescript
|
||||
// Use E2E attributes for element selection
|
||||
const submitButton = fixture.nativeElement.querySelector('[data-what="submit-button"][data-which="primary"]');
|
||||
expect(submitButton).toBeTruthy();
|
||||
```
|
||||
|
||||
### 7. Document Attributes
|
||||
Add comment block at top of template:
|
||||
```html
|
||||
<!--
|
||||
E2E Test Attributes:
|
||||
- data-what="submit-button" data-which="primary" - Main form submission
|
||||
- data-what="cancel-button" data-which="primary" - Cancel action
|
||||
- data-what="search-input" data-which="main" - Product search field
|
||||
-->
|
||||
```
|
||||
|
||||
## Output
|
||||
Provide summary:
|
||||
- Template analyzed: [path]
|
||||
- Interactive elements found: [count]
|
||||
- Attributes added: [count]
|
||||
- Coverage: [percentage]% (elements with E2E attrs / total interactive elements)
|
||||
- List of added attributes with descriptions
|
||||
- Validation status: ✅/❌
|
||||
|
||||
## Common Patterns by Component Type
|
||||
|
||||
**Form Components:**
|
||||
- `data-what="[field]-input" data-which="[form-name]"`
|
||||
- `data-what="submit-button" data-which="[form-name]"`
|
||||
- `data-what="cancel-button" data-which="[form-name]"`
|
||||
|
||||
**List/Table Components:**
|
||||
- `data-what="list-item" [attr.data-which]="item.id"`
|
||||
- `data-what="edit-button" [attr.data-which]="item.id"`
|
||||
- `data-what="delete-button" [attr.data-which]="item.id"`
|
||||
|
||||
**Navigation Components:**
|
||||
- `data-what="nav-link" data-which="[destination]"`
|
||||
- `data-what="breadcrumb" data-which="[level]"`
|
||||
|
||||
**Dialog Components:**
|
||||
- `data-what="confirm-button" data-which="dialog"`
|
||||
- `data-what="close-button" data-which="dialog"`
|
||||
|
||||
## References
|
||||
- CLAUDE.md Code Quality section (E2E Testing Requirements)
|
||||
- docs/guidelines/testing.md
|
||||
- QA team E2E test documentation (if available)
|
||||
535
.claude/commands/docs-library.md
Normal file
535
.claude/commands/docs-library.md
Normal file
@@ -0,0 +1,535 @@
|
||||
# /docs:library - Generate/Update Library README
|
||||
|
||||
Generate or update README.md for a specific library with comprehensive documentation.
|
||||
|
||||
## Parameters
|
||||
- `library-name`: Name of library (e.g., `oms-feature-return-search`)
|
||||
|
||||
## Tasks
|
||||
|
||||
### 1. Locate Library
|
||||
|
||||
```bash
|
||||
# Find library directory
|
||||
find libs/ -name "project.json" -path "*[library-name]*"
|
||||
|
||||
# Verify library exists
|
||||
if [ ! -d "libs/[path-to-library]" ]; then
|
||||
echo "Library not found"
|
||||
exit 1
|
||||
fi
|
||||
```
|
||||
|
||||
### 2. Extract Library Information
|
||||
|
||||
Read `project.json`:
|
||||
- Library name
|
||||
- Source root
|
||||
- Available targets (build, test, lint)
|
||||
- Tags (domain, type)
|
||||
|
||||
Read `tsconfig.base.json`:
|
||||
- Path alias (`@isa/domain/layer/name`)
|
||||
- Entry point (`src/index.ts`)
|
||||
|
||||
### 3. Analyze Library Structure
|
||||
|
||||
Scan library contents:
|
||||
```bash
|
||||
# List main source files
|
||||
ls -la libs/[path]/src/lib/
|
||||
|
||||
# Identify components
|
||||
find libs/[path]/src -name "*.component.ts"
|
||||
|
||||
# Identify services
|
||||
find libs/[path]/src -name "*.service.ts"
|
||||
|
||||
# Identify models/types
|
||||
find libs/[path]/src -name "*.model.ts" -o -name "*.interface.ts"
|
||||
|
||||
# Check for exports
|
||||
cat libs/[path]/src/index.ts
|
||||
```
|
||||
|
||||
### 4. Use docs-researcher for Similar READMEs
|
||||
|
||||
Use `docs-researcher` agent to find similar library READMEs in the same domain for reference structure and style.
|
||||
|
||||
### 5. Determine Library Type and Template
|
||||
|
||||
**Feature Library Template:**
|
||||
```markdown
|
||||
# [Library Name]
|
||||
|
||||
> **Type:** Feature Library
|
||||
> **Domain:** [OMS/Remission/Checkout/etc]
|
||||
> **Path:** `libs/[domain]/feature/[name]`
|
||||
|
||||
## Overview
|
||||
|
||||
[Brief description of what this feature does]
|
||||
|
||||
## Features
|
||||
|
||||
- Feature 1: [Description]
|
||||
- Feature 2: [Description]
|
||||
- Feature 3: [Description]
|
||||
|
||||
## Installation
|
||||
|
||||
This library is part of the ISA-Frontend monorepo. Import it using:
|
||||
|
||||
```typescript
|
||||
import { ComponentName } from '@isa/[domain]/feature/[name]';
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Example
|
||||
|
||||
```typescript
|
||||
import { Component } from '@angular/core';
|
||||
import { FeatureComponent } from '@isa/[domain]/feature/[name]';
|
||||
|
||||
@Component({
|
||||
selector: 'app-example',
|
||||
standalone: true,
|
||||
imports: [FeatureComponent],
|
||||
template: `
|
||||
<feature-component [input]="value" (output)="handleEvent($event)">
|
||||
</feature-component>
|
||||
`
|
||||
})
|
||||
export class ExampleComponent {
|
||||
value = 'example';
|
||||
|
||||
handleEvent(event: any) {
|
||||
console.log(event);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Advanced Usage
|
||||
|
||||
[More complex examples if applicable]
|
||||
|
||||
## API Reference
|
||||
|
||||
### Components
|
||||
|
||||
#### FeatureComponent
|
||||
|
||||
**Selector:** `feature-component`
|
||||
|
||||
**Inputs:**
|
||||
- `input` (string): Description of input
|
||||
|
||||
**Outputs:**
|
||||
- `output` (EventEmitter<any>): Description of output
|
||||
|
||||
**Example:**
|
||||
```html
|
||||
<feature-component [input]="value" (output)="handleEvent($event)">
|
||||
</feature-component>
|
||||
```
|
||||
|
||||
### Services
|
||||
|
||||
[If applicable]
|
||||
|
||||
### Models
|
||||
|
||||
[If applicable]
|
||||
|
||||
## Testing
|
||||
|
||||
This library uses [Vitest/Jest] for testing.
|
||||
|
||||
Run tests:
|
||||
```bash
|
||||
npx nx test [library-name] --skip-nx-cache
|
||||
```
|
||||
|
||||
Run with coverage:
|
||||
```bash
|
||||
npx nx test [library-name] --skip-nx-cache --coverage
|
||||
```
|
||||
|
||||
## Dependencies
|
||||
|
||||
**External Dependencies:**
|
||||
- [List of external packages if any]
|
||||
|
||||
**Internal Dependencies:**
|
||||
- [`@isa/[dependency]`](../[path]) - Description
|
||||
|
||||
## Development
|
||||
|
||||
### Project Structure
|
||||
|
||||
```
|
||||
libs/[domain]/feature/[name]/
|
||||
├── src/
|
||||
│ ├── lib/
|
||||
│ │ ├── components/
|
||||
│ │ ├── services/
|
||||
│ │ └── models/
|
||||
│ ├── index.ts
|
||||
│ └── test-setup.ts
|
||||
├── project.json
|
||||
└── README.md
|
||||
```
|
||||
|
||||
### Build
|
||||
|
||||
```bash
|
||||
npx nx build [library-name]
|
||||
```
|
||||
|
||||
### Lint
|
||||
|
||||
```bash
|
||||
npx nx lint [library-name]
|
||||
```
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [CLAUDE.md](../../../../CLAUDE.md) - Project guidelines
|
||||
- [Testing Guidelines](../../../../docs/guidelines/testing.md)
|
||||
- [Library Reference](../../../../docs/library-reference.md)
|
||||
|
||||
## Related Libraries
|
||||
|
||||
- [`@isa/[related-lib-1]`](../[path]) - Description
|
||||
- [`@isa/[related-lib-2]`](../[path]) - Description
|
||||
```
|
||||
|
||||
**Data Access Library Template:**
|
||||
```markdown
|
||||
# [Library Name]
|
||||
|
||||
> **Type:** Data Access Library
|
||||
> **Domain:** [Domain]
|
||||
> **Path:** `libs/[domain]/data-access`
|
||||
|
||||
## Overview
|
||||
|
||||
Data access layer for [Domain] domain. Provides services and state management for [domain-specific functionality].
|
||||
|
||||
## Features
|
||||
|
||||
- API client integration with [API names]
|
||||
- NgRx Signals store for state management
|
||||
- Type-safe data models with Zod validation
|
||||
- Error handling and retry logic
|
||||
|
||||
## Installation
|
||||
|
||||
```typescript
|
||||
import { ServiceName } from '@isa/[domain]/data-access';
|
||||
```
|
||||
|
||||
## Services
|
||||
|
||||
### ServiceName
|
||||
|
||||
[Service description]
|
||||
|
||||
**Methods:**
|
||||
|
||||
#### `getById(id: string): Observable<Model>`
|
||||
[Method description]
|
||||
|
||||
**Parameters:**
|
||||
- `id` (string): Description
|
||||
|
||||
**Returns:** `Observable<Model>`
|
||||
|
||||
**Example:**
|
||||
```typescript
|
||||
this.service.getById('123').subscribe({
|
||||
next: (data) => console.log(data),
|
||||
error: (err) => console.error(err)
|
||||
});
|
||||
```
|
||||
|
||||
## State Management
|
||||
|
||||
This library uses NgRx Signals for state management.
|
||||
|
||||
### Store
|
||||
|
||||
```typescript
|
||||
import { injectStore } from '@isa/[domain]/data-access';
|
||||
|
||||
export class Component {
|
||||
store = injectStore();
|
||||
|
||||
// Access state
|
||||
items = this.store.items;
|
||||
loading = this.store.loading;
|
||||
|
||||
// Call methods
|
||||
ngOnInit() {
|
||||
this.store.loadItems();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Models
|
||||
|
||||
### Model Name
|
||||
|
||||
```typescript
|
||||
interface ModelName {
|
||||
id: string;
|
||||
property: type;
|
||||
}
|
||||
```
|
||||
|
||||
Validated with Zod schema for runtime type safety.
|
||||
|
||||
## Testing
|
||||
|
||||
[Testing section similar to feature library]
|
||||
|
||||
## API Integration
|
||||
|
||||
This library integrates with the following Swagger-generated API clients:
|
||||
|
||||
- `@generated/swagger/[api-name]`
|
||||
|
||||
[Additional API documentation]
|
||||
```
|
||||
|
||||
**UI Component Library Template:**
|
||||
```markdown
|
||||
# [Library Name]
|
||||
|
||||
> **Type:** UI Component Library
|
||||
> **Path:** `libs/ui/[name]`
|
||||
|
||||
## Overview
|
||||
|
||||
Reusable UI components for [purpose]. Part of the ISA design system.
|
||||
|
||||
## Components
|
||||
|
||||
### ComponentName
|
||||
|
||||
[Component description]
|
||||
|
||||
**Selector:** `ui-component-name`
|
||||
|
||||
**Styling:** Uses Tailwind CSS with ISA design tokens
|
||||
|
||||
**Example:**
|
||||
```html
|
||||
<ui-component-name variant="primary" size="md">
|
||||
Content
|
||||
</ui-component-name>
|
||||
```
|
||||
|
||||
## Variants
|
||||
|
||||
- **primary**: Default primary styling
|
||||
- **secondary**: Secondary styling
|
||||
- **accent**: Accent color
|
||||
|
||||
## Sizes
|
||||
|
||||
- **sm**: Small (24px height)
|
||||
- **md**: Medium (32px height)
|
||||
- **lg**: Large (40px height)
|
||||
|
||||
## Accessibility
|
||||
|
||||
- ARIA labels included
|
||||
- Keyboard navigation supported
|
||||
- Screen reader friendly
|
||||
|
||||
## Storybook
|
||||
|
||||
View component documentation and examples:
|
||||
|
||||
```bash
|
||||
npm run storybook
|
||||
```
|
||||
|
||||
Navigate to: UI Components → [Component Name]
|
||||
|
||||
## Testing
|
||||
|
||||
Includes E2E test attributes:
|
||||
- `data-what="component-name"`
|
||||
- `data-which="variant"`
|
||||
|
||||
[Rest of testing section]
|
||||
```
|
||||
|
||||
### 6. Extract Code Examples
|
||||
|
||||
Scan library code for:
|
||||
- Public component selectors
|
||||
- Public API methods
|
||||
- Input/Output properties
|
||||
- Common usage patterns
|
||||
|
||||
Use `Read` tool to extract from source files.
|
||||
|
||||
### 7. Document Exports
|
||||
|
||||
Parse `src/index.ts` to document public API:
|
||||
```typescript
|
||||
// Read barrel export
|
||||
export * from './lib/component';
|
||||
export * from './lib/service';
|
||||
export { PublicInterface } from './lib/models';
|
||||
```
|
||||
|
||||
Document each export with:
|
||||
- Type (Component/Service/Interface/Function)
|
||||
- Purpose
|
||||
- Basic usage
|
||||
|
||||
### 8. Add Testing Information
|
||||
|
||||
Based on test framework (from project.json):
|
||||
- Test command
|
||||
- Framework (Vitest/Jest)
|
||||
- Coverage command
|
||||
- Link to testing guidelines
|
||||
|
||||
### 9. Create Dependency Graph
|
||||
|
||||
List internal and external dependencies:
|
||||
```bash
|
||||
# Use Nx to show dependencies
|
||||
npx nx graph --focus=[library-name]
|
||||
|
||||
# Extract from package.json and imports
|
||||
```
|
||||
|
||||
### 10. Add E2E Attributes Documentation
|
||||
|
||||
For UI/Feature libraries, document E2E attributes:
|
||||
```markdown
|
||||
## E2E Testing
|
||||
|
||||
This library includes E2E test attributes for automated testing:
|
||||
|
||||
| Element | data-what | data-which | Purpose |
|
||||
|---------|-----------|------------|---------|
|
||||
| Submit button | submit-button | form-primary | Main form submission |
|
||||
| Cancel button | cancel-button | form-primary | Cancel action |
|
||||
|
||||
Use these attributes in your E2E tests:
|
||||
```typescript
|
||||
const submitBtn = page.locator('[data-what="submit-button"][data-which="form-primary"]');
|
||||
```
|
||||
```
|
||||
|
||||
### 11. Generate/Update README
|
||||
|
||||
Write or update `libs/[path]/README.md` with generated content.
|
||||
|
||||
### 12. Validate README
|
||||
|
||||
Checks:
|
||||
- All links work (relative paths correct)
|
||||
- Code examples are valid TypeScript
|
||||
- Import paths use correct aliases
|
||||
- No TODO or placeholder text
|
||||
- Consistent formatting
|
||||
- Proper markdown syntax
|
||||
|
||||
### 13. Add to Git (if new)
|
||||
|
||||
```bash
|
||||
git add libs/[path]/README.md
|
||||
```
|
||||
|
||||
## Output Format
|
||||
```
|
||||
Library README Generated
|
||||
========================
|
||||
|
||||
Library: [library-name]
|
||||
Type: [Feature/Data Access/UI/Util]
|
||||
Path: libs/[domain]/[layer]/[name]
|
||||
|
||||
📝 README Sections
|
||||
------------------
|
||||
✅ Overview
|
||||
✅ Features
|
||||
✅ Installation
|
||||
✅ Usage Examples
|
||||
✅ API Reference
|
||||
✅ Testing
|
||||
✅ Dependencies
|
||||
✅ Development Guide
|
||||
|
||||
📊 Documentation Stats
|
||||
----------------------
|
||||
Total sections: XX
|
||||
Code examples: XX
|
||||
API methods documented: XX
|
||||
Components documented: XX
|
||||
|
||||
🔗 Links Validated
|
||||
-------------------
|
||||
Internal links: XX/XX valid
|
||||
Relative paths: ✅ Correct
|
||||
|
||||
💡 Highlights
|
||||
-------------
|
||||
- Documented XX public exports
|
||||
- XX code examples included
|
||||
- E2E attributes documented
|
||||
- Related libraries linked
|
||||
|
||||
📁 File Updated
|
||||
---------------
|
||||
Path: libs/[domain]/[layer]/[name]/README.md
|
||||
Size: XX KB
|
||||
Lines: XX
|
||||
|
||||
🎯 Next Steps
|
||||
-------------
|
||||
1. Review generated README
|
||||
2. Add any domain-specific details
|
||||
3. Add usage examples if needed
|
||||
4. Commit: git add libs/[path]/README.md
|
||||
```
|
||||
|
||||
## Auto-Enhancement
|
||||
|
||||
If existing README found:
|
||||
- Preserve custom sections
|
||||
- Update outdated examples
|
||||
- Add missing sections
|
||||
- Fix broken links
|
||||
- Update import paths
|
||||
|
||||
Prompt:
|
||||
```
|
||||
Existing README found. What would you like to do?
|
||||
1. Generate new (overwrite)
|
||||
2. Enhance existing (preserve custom content)
|
||||
3. Cancel
|
||||
```
|
||||
|
||||
## Quality Checks
|
||||
|
||||
- Import examples use correct path aliases
|
||||
- Code examples are syntactically correct
|
||||
- Links to related docs work
|
||||
- API documentation complete
|
||||
- Testing section accurate
|
||||
|
||||
## References
|
||||
- CLAUDE.md Library Organization section
|
||||
- Use `docs-researcher` to find reference READMEs
|
||||
- Storybook for UI component examples
|
||||
- project.json for library configuration
|
||||
295
.claude/commands/docs-refresh-reference.md
Normal file
295
.claude/commands/docs-refresh-reference.md
Normal file
@@ -0,0 +1,295 @@
|
||||
# /docs:refresh-reference - Regenerate Library Reference
|
||||
|
||||
Regenerate the library reference documentation (`docs/library-reference.md`) by scanning all libraries in the monorepo.
|
||||
|
||||
## Tasks
|
||||
|
||||
### 1. Scan Monorepo Structure
|
||||
```bash
|
||||
# List all libraries
|
||||
find libs/ -name "project.json" -type f | sort
|
||||
|
||||
# Count total libraries
|
||||
find libs/ -name "project.json" -type f | wc -l
|
||||
```
|
||||
|
||||
### 2. Extract Library Information
|
||||
|
||||
For each library, extract from `project.json`:
|
||||
- **Project name**: `name` field
|
||||
- **Path**: Directory path
|
||||
- **Tags**: For categorization (type, domain)
|
||||
- **Targets**: Available commands (build, test, lint)
|
||||
|
||||
### 3. Determine Path Aliases
|
||||
|
||||
Read `tsconfig.base.json` to get path mappings:
|
||||
```bash
|
||||
# Extract paths section
|
||||
cat tsconfig.base.json | grep -A 200 '"paths"'
|
||||
```
|
||||
|
||||
Map each library to its `@isa/*` alias.
|
||||
|
||||
### 4. Categorize Libraries by Domain
|
||||
|
||||
Group libraries into categories:
|
||||
- **Availability** (1 library)
|
||||
- **Catalogue** (1 library)
|
||||
- **Checkout** (6 libraries)
|
||||
- **Common** (3 libraries)
|
||||
- **Core** (5 libraries)
|
||||
- **CRM** (1 library)
|
||||
- **Icons** (1 library)
|
||||
- **OMS** (9 libraries)
|
||||
- **Remission** (8 libraries)
|
||||
- **Shared Components** (7 libraries)
|
||||
- **UI Components** (17 libraries)
|
||||
- **Utilities** (3 libraries)
|
||||
|
||||
### 5. Read Library READMEs
|
||||
|
||||
For each library, use `docs-researcher` agent to:
|
||||
- Read library README.md (if exists)
|
||||
- Extract description/purpose
|
||||
- Extract key features
|
||||
- Extract usage examples
|
||||
|
||||
### 6. Generate Library Entries
|
||||
|
||||
For each library, create entry with:
|
||||
```markdown
|
||||
#### `@isa/domain/layer/name`
|
||||
**Path:** `libs/domain/layer/name`
|
||||
**Type:** [Feature/Data Access/UI/Util]
|
||||
|
||||
Brief description from README or inferred from structure.
|
||||
|
||||
**Key Features:**
|
||||
- Feature 1
|
||||
- Feature 2
|
||||
|
||||
**Usage:**
|
||||
```typescript
|
||||
import { Component } from '@isa/domain/layer/name';
|
||||
```
|
||||
```
|
||||
|
||||
### 7. Create Domain Statistics
|
||||
|
||||
Calculate per domain:
|
||||
- Total libraries count
|
||||
- Breakdown by type (feature/data-access/ui/util)
|
||||
- Key capabilities overview
|
||||
|
||||
### 8. Generate Table of Contents
|
||||
|
||||
Create hierarchical TOC:
|
||||
```markdown
|
||||
## Table of Contents
|
||||
1. [Overview](#overview)
|
||||
2. [Quick Stats](#quick-stats)
|
||||
3. [Library Categories](#library-categories)
|
||||
- [Availability](#availability)
|
||||
- [Catalogue](#catalogue)
|
||||
- [Checkout](#checkout)
|
||||
...
|
||||
```
|
||||
|
||||
### 9. Add Metadata Header
|
||||
|
||||
Include document metadata:
|
||||
```markdown
|
||||
# Library Reference Guide
|
||||
|
||||
> **Last Updated:** [Current Date]
|
||||
> **Total Libraries:** XX
|
||||
> **Domains:** 12
|
||||
|
||||
## Quick Stats
|
||||
- Availability: 1 | Catalogue: 1 | Checkout: 6 | Common: 3
|
||||
- Core: 5 | CRM: 1 | Icons: 1 | OMS: 9 | Remission: 8
|
||||
- Shared Components: 7 | UI Components: 17 | Utilities: 3
|
||||
```
|
||||
|
||||
### 10. Add Usage Guidelines
|
||||
|
||||
Include quick reference section:
|
||||
```markdown
|
||||
## How to Use This Guide
|
||||
|
||||
### Finding a Library
|
||||
1. Check the domain category (e.g., OMS, Checkout, UI Components)
|
||||
2. Look for the specific feature or component you need
|
||||
3. Note the import path alias (e.g., `@isa/oms/feature-return-search`)
|
||||
|
||||
### Import Syntax
|
||||
All libraries use path aliases defined in `tsconfig.base.json`:
|
||||
|
||||
```typescript
|
||||
// Feature libraries
|
||||
import { Component } from '@isa/domain/feature/name';
|
||||
|
||||
// Data access services
|
||||
import { Service } from '@isa/domain/data-access';
|
||||
|
||||
// UI components
|
||||
import { ButtonComponent } from '@isa/ui/buttons';
|
||||
|
||||
// Utilities
|
||||
import { helper } from '@isa/utils/validation';
|
||||
```
|
||||
```
|
||||
|
||||
### 11. Add Cross-References
|
||||
|
||||
Link related libraries:
|
||||
```markdown
|
||||
**Related Libraries:**
|
||||
- [`@isa/oms/data-access`](#isaomsdataaccess) - OMS data services
|
||||
- [`@isa/ui/buttons`](#isauibuttons) - Button components
|
||||
```
|
||||
|
||||
### 12. Include Testing Information
|
||||
|
||||
For each library, note test framework:
|
||||
```markdown
|
||||
**Testing:** Vitest | Jest
|
||||
**Test Command:** `npx nx test [library-name] --skip-nx-cache`
|
||||
```
|
||||
|
||||
### 13. Validate Generated Documentation
|
||||
|
||||
Checks:
|
||||
- All libraries included (compare count)
|
||||
- All path aliases correct
|
||||
- No broken internal links
|
||||
- Consistent formatting
|
||||
- Alphabetical ordering within categories
|
||||
|
||||
### 14. Update CLAUDE.md Reference
|
||||
|
||||
Update CLAUDE.md to reference new library-reference.md:
|
||||
```markdown
|
||||
#### Library Reference Guide
|
||||
|
||||
The monorepo contains **62 libraries** organized across 12 domains.
|
||||
For quick lookup, see **[`docs/library-reference.md`](docs/library-reference.md)**.
|
||||
```
|
||||
|
||||
### 15. Create Backup
|
||||
|
||||
Before overwriting:
|
||||
```bash
|
||||
# Backup existing file
|
||||
cp docs/library-reference.md docs/library-reference.md.backup.$(date +%s)
|
||||
```
|
||||
|
||||
### 16. Write New Documentation
|
||||
|
||||
Write to `docs/library-reference.md` with generated content.
|
||||
|
||||
## Output Format
|
||||
|
||||
**Generated File Structure:**
|
||||
```markdown
|
||||
# Library Reference Guide
|
||||
|
||||
> Last Updated: [Date]
|
||||
> Total Libraries: XX
|
||||
> Domains: 12
|
||||
|
||||
## Table of Contents
|
||||
[Auto-generated TOC]
|
||||
|
||||
## Overview
|
||||
[Introduction and usage guide]
|
||||
|
||||
## Quick Stats
|
||||
[Statistics by domain]
|
||||
|
||||
## Library Categories
|
||||
|
||||
### Availability
|
||||
#### @isa/availability/data-access
|
||||
[Details...]
|
||||
|
||||
### Catalogue
|
||||
[Details...]
|
||||
|
||||
[... all domains ...]
|
||||
|
||||
## Appendix
|
||||
|
||||
### Path Aliases
|
||||
[Quick reference table]
|
||||
|
||||
### Testing Frameworks
|
||||
[Framework by library]
|
||||
|
||||
### Nx Commands
|
||||
[Common commands]
|
||||
```
|
||||
|
||||
## Output Summary
|
||||
```
|
||||
Library Reference Documentation Generated
|
||||
==========================================
|
||||
|
||||
📊 Statistics
|
||||
-------------
|
||||
Total libraries scanned: XX
|
||||
Libraries documented: XX
|
||||
Domains covered: 12
|
||||
|
||||
📝 Documentation Structure
|
||||
--------------------------
|
||||
- Table of Contents: ✅
|
||||
- Quick Stats: ✅
|
||||
- Library categories: XX
|
||||
- Total entries: XX
|
||||
|
||||
🔍 Quality Checks
|
||||
-----------------
|
||||
- All libraries included: ✅/❌
|
||||
- Path aliases validated: ✅/❌
|
||||
- Internal links verified: ✅/❌
|
||||
- Consistent formatting: ✅/❌
|
||||
|
||||
💾 Files Updated
|
||||
----------------
|
||||
- docs/library-reference.md: ✅
|
||||
- Backup created: docs/library-reference.md.backup.[timestamp]
|
||||
|
||||
📈 Changes from Previous Version
|
||||
---------------------------------
|
||||
- Libraries added: XX
|
||||
- Libraries removed: XX
|
||||
- Descriptions updated: XX
|
||||
|
||||
🎯 Next Steps
|
||||
-------------
|
||||
1. Review generated documentation
|
||||
2. Verify library descriptions are accurate
|
||||
3. Add missing usage examples if needed
|
||||
4. Commit changes: git add docs/library-reference.md
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
- Missing project.json: Skip and report
|
||||
- No README found: Use generic description
|
||||
- Path alias mismatch: Flag for manual review
|
||||
- Broken links: List for correction
|
||||
|
||||
## Automation Tips
|
||||
Can be run:
|
||||
- After adding new libraries
|
||||
- During documentation updates
|
||||
- As pre-release validation
|
||||
- In CI/CD pipeline
|
||||
|
||||
## References
|
||||
- CLAUDE.md Library Organization section
|
||||
- tsconfig.base.json (path aliases)
|
||||
- Individual library README.md files
|
||||
- docs/library-reference.md (existing documentation)
|
||||
129
.claude/commands/quality-bundle-analyze.md
Normal file
129
.claude/commands/quality-bundle-analyze.md
Normal file
@@ -0,0 +1,129 @@
|
||||
# /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)
|
||||
201
.claude/commands/quality-coverage.md
Normal file
201
.claude/commands/quality-coverage.md
Normal file
@@ -0,0 +1,201 @@
|
||||
# /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
|
||||
151
.claude/skills/api-change-analyzer/SKILL.md
Normal file
151
.claude/skills/api-change-analyzer/SKILL.md
Normal file
@@ -0,0 +1,151 @@
|
||||
---
|
||||
name: api-change-analyzer
|
||||
description: This skill should be used when analyzing Swagger/OpenAPI specification changes BEFORE regenerating API clients. It compares old vs new specs, categorizes changes as breaking/compatible/warnings, finds affected code, and generates migration strategies. Use this skill when the user wants to check API changes safely before sync, mentions "check breaking changes", or needs impact assessment.
|
||||
---
|
||||
|
||||
# API Change Analyzer
|
||||
|
||||
## Overview
|
||||
|
||||
Analyze Swagger/OpenAPI specification changes to detect breaking changes before regeneration. Provides detailed comparison, impact analysis, and migration recommendations.
|
||||
|
||||
## When to Use This Skill
|
||||
|
||||
Invoke when user wants to:
|
||||
- Check API changes before regeneration
|
||||
- Assess impact of backend updates
|
||||
- Plan migration for breaking changes
|
||||
- Mentioned "breaking changes" or "API diff"
|
||||
|
||||
## Analysis Workflow
|
||||
|
||||
### Step 1: Backup and Generate Temporarily
|
||||
|
||||
```bash
|
||||
cp -r generated/swagger/[api-name] /tmp/[api-name].backup
|
||||
npm run generate:swagger:[api-name]
|
||||
```
|
||||
|
||||
### Step 2: Compare Files
|
||||
|
||||
```bash
|
||||
diff -u /tmp/[api-name].backup/models.ts generated/swagger/[api-name]/models.ts
|
||||
diff -u /tmp/[api-name].backup/services.ts generated/swagger/[api-name]/services.ts
|
||||
```
|
||||
|
||||
### Step 3: Categorize Changes
|
||||
|
||||
**🔴 Breaking (Critical):**
|
||||
- Removed properties from response models
|
||||
- Changed property types (string → number)
|
||||
- Removed endpoints
|
||||
- Optional → required fields
|
||||
- Removed enum values
|
||||
|
||||
**✅ Compatible (Safe):**
|
||||
- Added properties (non-breaking)
|
||||
- New endpoints
|
||||
- Added optional parameters
|
||||
- New enum values
|
||||
|
||||
**⚠️ Warnings (Review):**
|
||||
- Property renamed (old removed + new added)
|
||||
- Changed default values
|
||||
- Changed validation rules
|
||||
- Added required request fields
|
||||
|
||||
### Step 4: Analyze Impact
|
||||
|
||||
For each breaking change, use `Explore` agent to find usages:
|
||||
|
||||
```bash
|
||||
# Example: Find usages of removed property
|
||||
grep -r "removedProperty" libs/*/data-access --include="*.ts"
|
||||
```
|
||||
|
||||
List:
|
||||
- Affected files
|
||||
- Services impacted
|
||||
- Estimated refactoring effort
|
||||
|
||||
### Step 5: Generate Migration Strategy
|
||||
|
||||
Based on severity:
|
||||
|
||||
**High Impact (multiple breaking changes):**
|
||||
1. Create migration branch
|
||||
2. Document all changes
|
||||
3. Update services incrementally
|
||||
4. Comprehensive testing
|
||||
|
||||
**Medium Impact:**
|
||||
1. Fix compilation errors
|
||||
2. Update affected tests
|
||||
3. Deploy with monitoring
|
||||
|
||||
**Low Impact:**
|
||||
1. Minor updates
|
||||
2. Deploy
|
||||
|
||||
### Step 6: Create Report
|
||||
|
||||
```
|
||||
API Breaking Changes Analysis
|
||||
==============================
|
||||
|
||||
API: [api-name]
|
||||
Analysis Date: [timestamp]
|
||||
|
||||
📊 Summary
|
||||
----------
|
||||
Breaking Changes: XX
|
||||
Warnings: XX
|
||||
Compatible Changes: XX
|
||||
|
||||
🔴 Breaking Changes
|
||||
-------------------
|
||||
1. Removed Property: OrderResponse.deliveryDate
|
||||
Files Affected: 2
|
||||
- libs/oms/data-access/src/lib/services/order.service.ts:45
|
||||
- libs/oms/feature/order-detail/src/lib/component.ts:78
|
||||
Impact: Medium
|
||||
Fix: Remove references or use alternativeDate
|
||||
|
||||
2. Type Changed: ProductResponse.price (string → number)
|
||||
Files Affected: 1
|
||||
- libs/catalogue/data-access/src/lib/services/product.service.ts:32
|
||||
Impact: High
|
||||
Fix: Update parsing logic
|
||||
|
||||
⚠️ Warnings
|
||||
-----------
|
||||
1. Possible Rename: CustomerResponse.customerName → fullName
|
||||
Action: Verify with backend team
|
||||
|
||||
✅ Compatible Changes
|
||||
---------------------
|
||||
1. Added Property: OrderResponse.estimatedDelivery
|
||||
2. New Endpoint: GET /api/v2/orders/bulk
|
||||
|
||||
💡 Migration Strategy
|
||||
---------------------
|
||||
Approach: [High/Medium/Low Impact]
|
||||
Estimated Effort: [hours]
|
||||
Steps: [numbered list]
|
||||
|
||||
🎯 Recommendation
|
||||
-----------------
|
||||
[Proceed with sync / Fix critical issues first / Coordinate with backend]
|
||||
```
|
||||
|
||||
### Step 7: Cleanup
|
||||
|
||||
```bash
|
||||
rm -rf /tmp/[api-name].backup
|
||||
# Or restore if needed
|
||||
```
|
||||
|
||||
## References
|
||||
|
||||
- CLAUDE.md API Integration
|
||||
- Semantic Versioning: https://semver.org
|
||||
208
.claude/skills/architecture-enforcer/SKILL.md
Normal file
208
.claude/skills/architecture-enforcer/SKILL.md
Normal file
@@ -0,0 +1,208 @@
|
||||
---
|
||||
name: architecture-enforcer
|
||||
description: This skill should be used when validating import boundaries and architectural rules in the ISA-Frontend monorepo. It checks for circular dependencies, layer violations (Feature→Feature), domain violations (OMS→Remission), and relative imports. Use this skill when the user wants to check architecture, mentions "validate boundaries", "check imports", or needs dependency analysis.
|
||||
---
|
||||
|
||||
# Architecture Enforcer
|
||||
|
||||
## Overview
|
||||
|
||||
Validate and enforce architectural boundaries in the monorepo. Checks import rules, detects violations, generates dependency graphs, and suggests refactoring.
|
||||
|
||||
## When to Use This Skill
|
||||
|
||||
Invoke when user wants to:
|
||||
- Validate import boundaries
|
||||
- Check architectural rules
|
||||
- Find dependency violations
|
||||
- Mentioned "check architecture" or "validate imports"
|
||||
|
||||
## Architectural Rules
|
||||
|
||||
**✅ Allowed:**
|
||||
- Feature → Data Access
|
||||
- Feature → UI
|
||||
- Feature → Util
|
||||
- Data Access → Util
|
||||
|
||||
**❌ Forbidden:**
|
||||
- Feature → Feature
|
||||
- Data Access → Feature
|
||||
- UI → Feature
|
||||
- Cross-domain (OMS ↔ Remission)
|
||||
|
||||
## Enforcement Workflow
|
||||
|
||||
### Step 1: Run Nx Dependency Checks
|
||||
|
||||
```bash
|
||||
# Lint all (includes boundary checks)
|
||||
npx nx run-many --target=lint --all
|
||||
|
||||
# Or specific library
|
||||
npx nx lint [library-name]
|
||||
```
|
||||
|
||||
### Step 2: Generate Dependency Graph
|
||||
|
||||
```bash
|
||||
# Visual graph
|
||||
npx nx graph
|
||||
|
||||
# Focus on specific project
|
||||
npx nx graph --focus=[library-name]
|
||||
|
||||
# Affected projects
|
||||
npx nx affected:graph
|
||||
```
|
||||
|
||||
### Step 3: Scan for Violations
|
||||
|
||||
**Check for Circular Dependencies:**
|
||||
Use `Explore` agent to find A→B→A patterns.
|
||||
|
||||
**Check Layer Violations:**
|
||||
```bash
|
||||
# Find feature-to-feature imports
|
||||
grep -r "from '@isa/[^/]*/feature" libs/*/feature/ --include="*.ts"
|
||||
```
|
||||
|
||||
**Check Relative Imports:**
|
||||
```bash
|
||||
# Should use path aliases, not relative
|
||||
grep -r "from '\.\./\.\./\.\." libs/ --include="*.ts"
|
||||
```
|
||||
|
||||
**Check Direct Swagger Imports:**
|
||||
```bash
|
||||
# Should go through data-access
|
||||
grep -r "from '@generated/swagger" libs/*/feature/ --include="*.ts"
|
||||
```
|
||||
|
||||
### Step 4: Categorize Violations
|
||||
|
||||
**🔴 Critical:**
|
||||
- Circular dependencies
|
||||
- Feature → Feature
|
||||
- Data Access → Feature
|
||||
- Cross-domain dependencies
|
||||
|
||||
**⚠️ Warnings:**
|
||||
- Relative imports (should use aliases)
|
||||
- Missing tags in project.json
|
||||
- Deep import paths
|
||||
|
||||
**ℹ️ Info:**
|
||||
- Potential shared utilities
|
||||
|
||||
### Step 5: Generate Violation Report
|
||||
|
||||
For each violation:
|
||||
```
|
||||
📍 libs/oms/feature/return-search/src/lib/component.ts:12
|
||||
🔴 Layer Violation
|
||||
❌ Feature importing from another feature
|
||||
|
||||
Import: import { OrderList } from '@isa/oms/feature-order-list';
|
||||
Issue: Feature libraries should not depend on other features
|
||||
Fix: Move shared component to @isa/shared/* or @isa/ui/*
|
||||
```
|
||||
|
||||
### Step 6: Suggest Refactoring
|
||||
|
||||
**For repeated patterns:**
|
||||
- Create shared library for common components
|
||||
- Extract shared utilities to util library
|
||||
- Move API clients to data-access layer
|
||||
- Create facade services
|
||||
|
||||
### Step 7: Visualize Problems
|
||||
|
||||
```bash
|
||||
npx nx graph --focus=[problematic-library]
|
||||
```
|
||||
|
||||
### Step 8: Generate Report
|
||||
|
||||
```
|
||||
Import Boundary Analysis
|
||||
========================
|
||||
|
||||
Scope: [All | Specific library]
|
||||
|
||||
📊 Summary
|
||||
----------
|
||||
Total violations: XX
|
||||
🔴 Critical: XX
|
||||
⚠️ Warnings: XX
|
||||
ℹ️ Info: XX
|
||||
|
||||
🔍 Violations by Type
|
||||
---------------------
|
||||
Layer violations: XX
|
||||
Domain violations: XX
|
||||
Circular dependencies: XX
|
||||
Path alias violations: XX
|
||||
|
||||
🔴 Critical Violations
|
||||
----------------------
|
||||
1. [File:Line]
|
||||
Issue: Feature → Feature dependency
|
||||
Fix: Extract to @isa/shared/component-name
|
||||
|
||||
2. [File:Line]
|
||||
Issue: Circular dependency
|
||||
Fix: Extract interface to util library
|
||||
|
||||
💡 Refactoring Recommendations
|
||||
-------------------------------
|
||||
1. Create @isa/shared/order-components
|
||||
- Move: [list of shared components]
|
||||
- Benefits: Reusable, breaks circular deps
|
||||
|
||||
2. Extract interfaces to @isa/oms/util
|
||||
- Move: [list of interfaces]
|
||||
- Benefits: Breaks circular dependencies
|
||||
|
||||
📈 Dependency Graph
|
||||
-------------------
|
||||
npx nx graph --focus=[library]
|
||||
|
||||
🎯 Next Steps
|
||||
-------------
|
||||
1. Fix critical violations
|
||||
2. Update ESLint config
|
||||
3. Refactor shared components
|
||||
4. Re-run: architecture-enforcer
|
||||
```
|
||||
|
||||
## Common Fixes
|
||||
|
||||
**Circular Dependencies:**
|
||||
```typescript
|
||||
// Extract shared interface to util
|
||||
// @isa/oms/util
|
||||
export interface OrderId { id: string; }
|
||||
|
||||
// Both services import from util
|
||||
import { OrderId } from '@isa/oms/util';
|
||||
```
|
||||
|
||||
**Layer Violations:**
|
||||
```typescript
|
||||
// Move shared component from feature to ui
|
||||
// Before: @isa/oms/feature-shared
|
||||
// After: @isa/ui/order-components
|
||||
```
|
||||
|
||||
**Path Alias Usage:**
|
||||
```typescript
|
||||
// BEFORE: import { Service } from '../../../data-access/src/lib/service';
|
||||
// AFTER: import { Service } from '@isa/oms/data-access';
|
||||
```
|
||||
|
||||
## References
|
||||
|
||||
- CLAUDE.md Architecture section
|
||||
- Nx enforce-module-boundaries: https://nx.dev/nx-api/eslint-plugin/documents/enforce-module-boundaries
|
||||
- tsconfig.base.json (path aliases)
|
||||
249
.claude/skills/circular-dependency-resolver/SKILL.md
Normal file
249
.claude/skills/circular-dependency-resolver/SKILL.md
Normal file
@@ -0,0 +1,249 @@
|
||||
---
|
||||
name: circular-dependency-resolver
|
||||
description: This skill should be used when detecting and resolving circular dependencies in the ISA-Frontend monorepo. It uses graph algorithms to find A→B→C→A cycles, categorizes by severity, provides multiple fix strategies (DI, interface extraction, shared code), and validates fixes. Use this skill when the user mentions "circular dependencies", "dependency cycles", or has build/runtime issues from circular imports.
|
||||
---
|
||||
|
||||
# Circular Dependency Resolver
|
||||
|
||||
## Overview
|
||||
|
||||
Detect and resolve circular dependencies using graph analysis, multiple fix strategies, and automated validation. Prevents runtime and build issues caused by dependency cycles.
|
||||
|
||||
## When to Use This Skill
|
||||
|
||||
Invoke when user:
|
||||
- Mentions "circular dependencies"
|
||||
- Has import cycle errors
|
||||
- Requests dependency analysis
|
||||
- Build fails with circular import warnings
|
||||
|
||||
## Resolution Workflow
|
||||
|
||||
### Step 1: Detect Circular Dependencies
|
||||
|
||||
**Using Nx:**
|
||||
```bash
|
||||
npx nx run-many --target=lint --all 2>&1 | grep -i "circular"
|
||||
```
|
||||
|
||||
**Using madge (if installed):**
|
||||
```bash
|
||||
npm install -g madge
|
||||
madge --circular --extensions ts libs/
|
||||
madge --circular --image circular-deps.svg libs/
|
||||
```
|
||||
|
||||
**Using TypeScript:**
|
||||
```bash
|
||||
npx tsc --noEmit --strict 2>&1 | grep -i "circular\|cycle"
|
||||
```
|
||||
|
||||
### Step 2: Analyze Each Cycle
|
||||
|
||||
For each cycle found:
|
||||
```
|
||||
📍 Circular Dependency Detected
|
||||
|
||||
Cycle Path:
|
||||
1. libs/oms/data-access/src/lib/services/order.service.ts
|
||||
→ imports OrderValidator
|
||||
2. libs/oms/data-access/src/lib/validators/order.validator.ts
|
||||
→ imports OrderService
|
||||
3. Back to order.service.ts
|
||||
|
||||
Type: Service-Validator circular reference
|
||||
Severity: 🔴 Critical
|
||||
Files Involved: 2
|
||||
```
|
||||
|
||||
### Step 3: Categorize by Severity
|
||||
|
||||
**🔴 Critical (Must Fix):**
|
||||
- Service-to-service cycles
|
||||
- Data-access layer cycles
|
||||
- Store dependencies creating cycles
|
||||
|
||||
**⚠️ Warning (Should Fix):**
|
||||
- Component-to-component cycles
|
||||
- Model cross-references
|
||||
- Utility function cycles
|
||||
|
||||
**ℹ️ Info (Review):**
|
||||
- Type-only circular references (may be acceptable)
|
||||
- Test file circular imports
|
||||
|
||||
### Step 4: Choose Fix Strategy
|
||||
|
||||
**Strategy 1: Extract to Shared Utility**
|
||||
```typescript
|
||||
// BEFORE (Circular)
|
||||
// order.service.ts imports validator.ts
|
||||
// validator.ts imports order.service.ts
|
||||
|
||||
// AFTER (Fixed)
|
||||
// Create @isa/oms/util/types.ts
|
||||
export interface OrderData { id: string; }
|
||||
|
||||
// order.service.ts imports types
|
||||
// validator.ts imports types
|
||||
// No more cycle
|
||||
```
|
||||
|
||||
**Strategy 2: Dependency Injection (Lazy)**
|
||||
```typescript
|
||||
// BEFORE
|
||||
import { ServiceB } from './service-b';
|
||||
export class ServiceA {
|
||||
constructor(private serviceB: ServiceB) {}
|
||||
}
|
||||
|
||||
// AFTER
|
||||
import { Injector } from '@angular/core';
|
||||
export class ServiceA {
|
||||
private serviceB!: ServiceB;
|
||||
|
||||
constructor(private injector: Injector) {
|
||||
setTimeout(() => {
|
||||
this.serviceB = this.injector.get(ServiceB);
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Strategy 3: Interface Extraction**
|
||||
```typescript
|
||||
// BEFORE (Models with circular reference)
|
||||
// order.ts ↔ customer.ts
|
||||
|
||||
// AFTER
|
||||
// order.interface.ts - no imports
|
||||
export interface IOrder { customerId: string; }
|
||||
|
||||
// customer.interface.ts - no imports
|
||||
export interface ICustomer { orderIds: string[]; }
|
||||
|
||||
// order.ts imports only ICustomer
|
||||
// customer.ts imports only IOrder
|
||||
```
|
||||
|
||||
**Strategy 4: Move Shared Code**
|
||||
```typescript
|
||||
// BEFORE
|
||||
// feature-a imports feature-b
|
||||
// feature-b imports feature-a
|
||||
|
||||
// AFTER
|
||||
// Extract to @isa/shared/[name]
|
||||
// Both features import from shared
|
||||
```
|
||||
|
||||
**Strategy 5: Forward References (Angular)**
|
||||
```typescript
|
||||
// Use forwardRef for components
|
||||
import { forwardRef } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
imports: [forwardRef(() => ChildComponent)]
|
||||
})
|
||||
```
|
||||
|
||||
### Step 5: Implement Fix
|
||||
|
||||
Apply chosen strategy:
|
||||
1. Create new files if needed (util library, interfaces)
|
||||
2. Update imports in both files
|
||||
3. Remove circular import
|
||||
|
||||
### Step 6: Validate Fix
|
||||
|
||||
```bash
|
||||
# Check cycle resolved
|
||||
madge --circular --extensions ts libs/
|
||||
|
||||
# TypeScript compilation
|
||||
npx tsc --noEmit
|
||||
|
||||
# Run tests
|
||||
npx nx affected:test --skip-nx-cache
|
||||
|
||||
# Lint
|
||||
npx nx affected:lint
|
||||
```
|
||||
|
||||
### Step 7: Generate Report
|
||||
|
||||
```
|
||||
Circular Dependency Resolution
|
||||
===============================
|
||||
|
||||
Analysis Date: [timestamp]
|
||||
|
||||
📊 Summary
|
||||
----------
|
||||
Circular dependencies found: XX
|
||||
🔴 Critical: XX
|
||||
⚠️ Warning: XX
|
||||
Fixed: XX
|
||||
|
||||
🔍 Detected Cycles
|
||||
------------------
|
||||
|
||||
🔴 Critical Cycle #1 (FIXED)
|
||||
Path: order.service → validator → order.service
|
||||
Strategy Used: Extract to Util Library
|
||||
Created: @isa/oms/util/order-types.ts
|
||||
Files Modified: 2
|
||||
Status: ✅ Resolved
|
||||
|
||||
⚠️ Warning Cycle #2 (FIXED)
|
||||
Path: component-a → component-b → component-a
|
||||
Strategy Used: Move Shared to @isa/ui
|
||||
Created: @isa/ui/shared-component
|
||||
Files Modified: 3
|
||||
Status: ✅ Resolved
|
||||
|
||||
💡 Fix Strategies Applied
|
||||
--------------------------
|
||||
1. Extract to Util: XX cycles
|
||||
2. Interface Extraction: XX cycles
|
||||
3. Move Shared Code: XX cycles
|
||||
4. Dependency Injection: XX cycles
|
||||
|
||||
✅ Validation
|
||||
-------------
|
||||
- madge check: ✅ No cycles
|
||||
- TypeScript: ✅ Compiles
|
||||
- Tests: ✅ XX/XX passing
|
||||
- Lint: ✅ Passed
|
||||
|
||||
🎯 Prevention Tips
|
||||
------------------
|
||||
1. Add ESLint rule: import/no-cycle
|
||||
2. Pre-commit hook for cycle detection
|
||||
3. Regular architecture reviews
|
||||
```
|
||||
|
||||
## Prevention
|
||||
|
||||
**ESLint Configuration:**
|
||||
```json
|
||||
{
|
||||
"import/no-cycle": ["error", { "maxDepth": 1 }]
|
||||
}
|
||||
```
|
||||
|
||||
**Pre-commit Hook:**
|
||||
```bash
|
||||
#!/bin/bash
|
||||
madge --circular --extensions ts libs/
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "❌ Circular dependencies detected"
|
||||
exit 1
|
||||
fi
|
||||
```
|
||||
|
||||
## References
|
||||
|
||||
- Madge: https://github.com/pahen/madge
|
||||
- Nx dependency graph: https://nx.dev/features/explore-graph
|
||||
- ESLint import plugin: https://github.com/import-js/eslint-plugin-import
|
||||
223
.claude/skills/library-scaffolder/SKILL.md
Normal file
223
.claude/skills/library-scaffolder/SKILL.md
Normal file
@@ -0,0 +1,223 @@
|
||||
---
|
||||
name: library-scaffolder
|
||||
description: This skill should be used when creating new Angular libraries in the ISA-Frontend monorepo. It handles Nx library generation with proper naming conventions, Vitest configuration with JUnit/Cobertura reporters, path alias setup, and validation. Use this skill when the user wants to create a new library, scaffold a feature/data-access/ui/util library, or requests "new library" creation.
|
||||
---
|
||||
|
||||
# Library Scaffolder
|
||||
|
||||
## Overview
|
||||
|
||||
Automate the creation of new Angular libraries following ISA-Frontend conventions. This skill handles the complete scaffolding workflow including Nx generation, Vitest configuration with CI/CD integration, path alias verification, and initial validation.
|
||||
|
||||
## When to Use This Skill
|
||||
|
||||
Invoke this skill when:
|
||||
- User requests creating a new library
|
||||
- User mentions "new library", "scaffold library", or "create feature"
|
||||
- User wants to add a new domain/layer/feature to the monorepo
|
||||
|
||||
## Required Parameters
|
||||
|
||||
User must provide:
|
||||
- **domain**: Domain name (oms, remission, checkout, ui, core, shared, utils)
|
||||
- **layer**: Layer type (feature, data-access, ui, util)
|
||||
- **name**: Library name in kebab-case
|
||||
|
||||
## Scaffolding Workflow
|
||||
|
||||
### Step 1: Validate Input
|
||||
|
||||
1. **Verify Domain**
|
||||
- Use `docs-researcher` to check `docs/library-reference.md`
|
||||
- Ensure domain follows existing patterns
|
||||
|
||||
2. **Validate Layer**
|
||||
- Must be one of: feature, data-access, ui, util
|
||||
|
||||
3. **Check Name**
|
||||
- Must be kebab-case
|
||||
- Must not conflict with existing libraries
|
||||
|
||||
4. **Determine Path Depth**
|
||||
- 3 levels: `libs/domain/layer/name` → `../../../`
|
||||
- 4 levels: `libs/domain/type/layer/name` → `../../../../`
|
||||
|
||||
### Step 2: Run Dry-Run
|
||||
|
||||
Execute Nx generator with `--dry-run`:
|
||||
|
||||
```bash
|
||||
npx nx generate @nx/angular:library \
|
||||
--name=[domain]-[layer]-[name] \
|
||||
--directory=libs/[domain]/[layer]/[name] \
|
||||
--importPath=@isa/[domain]/[layer]/[name] \
|
||||
--style=css \
|
||||
--unitTestRunner=vitest \
|
||||
--standalone=true \
|
||||
--skipTests=false \
|
||||
--dry-run
|
||||
```
|
||||
|
||||
Review output with user before proceeding.
|
||||
|
||||
### Step 3: Generate Library
|
||||
|
||||
Execute without `--dry-run`:
|
||||
|
||||
```bash
|
||||
npx nx generate @nx/angular:library \
|
||||
--name=[domain]-[layer]-[name] \
|
||||
--directory=libs/[domain]/[layer]/[name] \
|
||||
--importPath=@isa/[domain]/[layer]/[name] \
|
||||
--style=css \
|
||||
--unitTestRunner=vitest \
|
||||
--standalone=true \
|
||||
--skipTests=false
|
||||
```
|
||||
|
||||
### Step 4: Configure Vitest with JUnit and Cobertura
|
||||
|
||||
Update `libs/[path]/vite.config.mts`:
|
||||
|
||||
```typescript
|
||||
/// <reference types='vitest' />
|
||||
import { defineConfig } from 'vite';
|
||||
import angular from '@analogjs/vite-plugin-angular';
|
||||
import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin';
|
||||
import { nxCopyAssetsPlugin } from '@nx/vite/plugins/nx-copy-assets.plugin';
|
||||
|
||||
export default
|
||||
// @ts-expect-error - Vitest reporter tuple types have complex inference issues
|
||||
defineConfig(() => ({
|
||||
root: __dirname,
|
||||
cacheDir: '../../../node_modules/.vite/libs/[path]',
|
||||
plugins: [angular(), nxViteTsPaths(), nxCopyAssetsPlugin(['*.md'])],
|
||||
test: {
|
||||
watch: false,
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
include: ['{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
|
||||
setupFiles: ['src/test-setup.ts'],
|
||||
reporters: [
|
||||
'default',
|
||||
['junit', { outputFile: '../../../testresults/junit-[library-name].xml' }],
|
||||
],
|
||||
coverage: {
|
||||
reportsDirectory: '../../../coverage/libs/[path]',
|
||||
provider: 'v8' as const,
|
||||
reporter: ['text', 'cobertura'],
|
||||
},
|
||||
},
|
||||
}));
|
||||
```
|
||||
|
||||
**Critical**: Adjust path depth based on library location.
|
||||
|
||||
### Step 5: Verify Configuration
|
||||
|
||||
1. **Check Path Alias**
|
||||
- Verify `tsconfig.base.json` was updated
|
||||
- Should have: `"@isa/[domain]/[layer]/[name]": ["libs/[domain]/[layer]/[name]/src/index.ts"]`
|
||||
|
||||
2. **Run Initial Test**
|
||||
```bash
|
||||
npx nx test [library-name] --coverage.enabled=true --skip-nx-cache
|
||||
```
|
||||
|
||||
3. **Verify CI/CD Files Created**
|
||||
- JUnit XML: `testresults/junit-[library-name].xml`
|
||||
- Cobertura XML: `coverage/libs/[path]/cobertura-coverage.xml`
|
||||
|
||||
### Step 6: Create Library README
|
||||
|
||||
Use `docs-researcher` to find similar library READMEs, then create comprehensive documentation including:
|
||||
- Overview and purpose
|
||||
- Installation/import instructions
|
||||
- API documentation
|
||||
- Usage examples
|
||||
- Testing information (Vitest + Angular Testing Utilities)
|
||||
|
||||
### Step 7: Update Library Reference
|
||||
|
||||
Add entry to `docs/library-reference.md` under appropriate domain:
|
||||
|
||||
```markdown
|
||||
#### `@isa/[domain]/[layer]/[name]`
|
||||
**Path:** `libs/[domain]/[layer]/[name]`
|
||||
**Type:** [Feature/Data Access/UI/Util]
|
||||
**Testing:** Vitest
|
||||
|
||||
[Brief description]
|
||||
```
|
||||
|
||||
### Step 8: Run Full Validation
|
||||
|
||||
```bash
|
||||
# Lint
|
||||
npx nx lint [library-name]
|
||||
|
||||
# Test with coverage
|
||||
npx nx test [library-name] --coverage.enabled=true --skip-nx-cache
|
||||
|
||||
# Build (if buildable)
|
||||
npx nx build [library-name]
|
||||
|
||||
# Dependency graph
|
||||
npx nx graph --focus=[library-name]
|
||||
```
|
||||
|
||||
### Step 9: Generate Creation Report
|
||||
|
||||
```
|
||||
Library Created Successfully
|
||||
============================
|
||||
|
||||
Library Name: [domain]-[layer]-[name]
|
||||
Path: libs/[domain]/[layer]/[name]
|
||||
Import Alias: @isa/[domain]/[layer]/[name]
|
||||
|
||||
✅ Configuration
|
||||
----------------
|
||||
Test Framework: Vitest with Angular Testing Utilities
|
||||
Style: CSS
|
||||
Standalone: Yes
|
||||
JUnit Reporter: ✅ testresults/junit-[library-name].xml
|
||||
Cobertura Coverage: ✅ coverage/libs/[path]/cobertura-coverage.xml
|
||||
|
||||
📦 Import Statement
|
||||
-------------------
|
||||
import { Component } from '@isa/[domain]/[layer]/[name]';
|
||||
|
||||
🧪 Test Commands
|
||||
----------------
|
||||
npx nx test [library-name] --skip-nx-cache
|
||||
npx nx test [library-name] --coverage.enabled=true --skip-nx-cache
|
||||
|
||||
📝 Next Steps
|
||||
-------------
|
||||
1. Develop library features
|
||||
2. Write tests using Vitest + Angular Testing Utilities
|
||||
3. Add E2E attributes (data-what, data-which) to templates
|
||||
4. Update README with usage examples
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
**Issue: Path depth mismatch**
|
||||
- Count directory levels from workspace root
|
||||
- Adjust `../` in outputFile and reportsDirectory
|
||||
|
||||
**Issue: TypeScript errors in vite.config.mts**
|
||||
- Add `// @ts-expect-error` before `defineConfig()`
|
||||
|
||||
**Issue: Path alias not working**
|
||||
- Check tsconfig.base.json
|
||||
- Run `npx nx reset`
|
||||
- Restart TypeScript server
|
||||
|
||||
## References
|
||||
|
||||
- docs/guidelines/testing.md (Vitest, JUnit, Cobertura sections)
|
||||
- docs/library-reference.md (domain patterns)
|
||||
- CLAUDE.md (Library Organization, Testing Framework sections)
|
||||
- Nx Angular Library Generator: https://nx.dev/nx-api/angular/generators/library
|
||||
202
.claude/skills/skill-creator/LICENSE.txt
Normal file
202
.claude/skills/skill-creator/LICENSE.txt
Normal file
@@ -0,0 +1,202 @@
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
209
.claude/skills/skill-creator/SKILL.md
Normal file
209
.claude/skills/skill-creator/SKILL.md
Normal file
@@ -0,0 +1,209 @@
|
||||
---
|
||||
name: skill-creator
|
||||
description: Guide for creating effective skills. This skill should be used when users want to create a new skill (or update an existing skill) that extends Claude's capabilities with specialized knowledge, workflows, or tool integrations.
|
||||
license: Complete terms in LICENSE.txt
|
||||
---
|
||||
|
||||
# Skill Creator
|
||||
|
||||
This skill provides guidance for creating effective skills.
|
||||
|
||||
## About Skills
|
||||
|
||||
Skills are modular, self-contained packages that extend Claude's capabilities by providing
|
||||
specialized knowledge, workflows, and tools. Think of them as "onboarding guides" for specific
|
||||
domains or tasks—they transform Claude from a general-purpose agent into a specialized agent
|
||||
equipped with procedural knowledge that no model can fully possess.
|
||||
|
||||
### What Skills Provide
|
||||
|
||||
1. Specialized workflows - Multi-step procedures for specific domains
|
||||
2. Tool integrations - Instructions for working with specific file formats or APIs
|
||||
3. Domain expertise - Company-specific knowledge, schemas, business logic
|
||||
4. Bundled resources - Scripts, references, and assets for complex and repetitive tasks
|
||||
|
||||
### Anatomy of a Skill
|
||||
|
||||
Every skill consists of a required SKILL.md file and optional bundled resources:
|
||||
|
||||
```
|
||||
skill-name/
|
||||
├── SKILL.md (required)
|
||||
│ ├── YAML frontmatter metadata (required)
|
||||
│ │ ├── name: (required)
|
||||
│ │ └── description: (required)
|
||||
│ └── Markdown instructions (required)
|
||||
└── Bundled Resources (optional)
|
||||
├── scripts/ - Executable code (Python/Bash/etc.)
|
||||
├── references/ - Documentation intended to be loaded into context as needed
|
||||
└── assets/ - Files used in output (templates, icons, fonts, etc.)
|
||||
```
|
||||
|
||||
#### SKILL.md (required)
|
||||
|
||||
**Metadata Quality:** The `name` and `description` in YAML frontmatter determine when Claude will use the skill. Be specific about what the skill does and when to use it. Use the third-person (e.g. "This skill should be used when..." instead of "Use this skill when...").
|
||||
|
||||
#### Bundled Resources (optional)
|
||||
|
||||
##### Scripts (`scripts/`)
|
||||
|
||||
Executable code (Python/Bash/etc.) for tasks that require deterministic reliability or are repeatedly rewritten.
|
||||
|
||||
- **When to include**: When the same code is being rewritten repeatedly or deterministic reliability is needed
|
||||
- **Example**: `scripts/rotate_pdf.py` for PDF rotation tasks
|
||||
- **Benefits**: Token efficient, deterministic, may be executed without loading into context
|
||||
- **Note**: Scripts may still need to be read by Claude for patching or environment-specific adjustments
|
||||
|
||||
##### References (`references/`)
|
||||
|
||||
Documentation and reference material intended to be loaded as needed into context to inform Claude's process and thinking.
|
||||
|
||||
- **When to include**: For documentation that Claude should reference while working
|
||||
- **Examples**: `references/finance.md` for financial schemas, `references/mnda.md` for company NDA template, `references/policies.md` for company policies, `references/api_docs.md` for API specifications
|
||||
- **Use cases**: Database schemas, API documentation, domain knowledge, company policies, detailed workflow guides
|
||||
- **Benefits**: Keeps SKILL.md lean, loaded only when Claude determines it's needed
|
||||
- **Best practice**: If files are large (>10k words), include grep search patterns in SKILL.md
|
||||
- **Avoid duplication**: Information should live in either SKILL.md or references files, not both. Prefer references files for detailed information unless it's truly core to the skill—this keeps SKILL.md lean while making information discoverable without hogging the context window. Keep only essential procedural instructions and workflow guidance in SKILL.md; move detailed reference material, schemas, and examples to references files.
|
||||
|
||||
##### Assets (`assets/`)
|
||||
|
||||
Files not intended to be loaded into context, but rather used within the output Claude produces.
|
||||
|
||||
- **When to include**: When the skill needs files that will be used in the final output
|
||||
- **Examples**: `assets/logo.png` for brand assets, `assets/slides.pptx` for PowerPoint templates, `assets/frontend-template/` for HTML/React boilerplate, `assets/font.ttf` for typography
|
||||
- **Use cases**: Templates, images, icons, boilerplate code, fonts, sample documents that get copied or modified
|
||||
- **Benefits**: Separates output resources from documentation, enables Claude to use files without loading them into context
|
||||
|
||||
### Progressive Disclosure Design Principle
|
||||
|
||||
Skills use a three-level loading system to manage context efficiently:
|
||||
|
||||
1. **Metadata (name + description)** - Always in context (~100 words)
|
||||
2. **SKILL.md body** - When skill triggers (<5k words)
|
||||
3. **Bundled resources** - As needed by Claude (Unlimited*)
|
||||
|
||||
*Unlimited because scripts can be executed without reading into context window.
|
||||
|
||||
## Skill Creation Process
|
||||
|
||||
To create a skill, follow the "Skill Creation Process" in order, skipping steps only if there is a clear reason why they are not applicable.
|
||||
|
||||
### Step 1: Understanding the Skill with Concrete Examples
|
||||
|
||||
Skip this step only when the skill's usage patterns are already clearly understood. It remains valuable even when working with an existing skill.
|
||||
|
||||
To create an effective skill, clearly understand concrete examples of how the skill will be used. This understanding can come from either direct user examples or generated examples that are validated with user feedback.
|
||||
|
||||
For example, when building an image-editor skill, relevant questions include:
|
||||
|
||||
- "What functionality should the image-editor skill support? Editing, rotating, anything else?"
|
||||
- "Can you give some examples of how this skill would be used?"
|
||||
- "I can imagine users asking for things like 'Remove the red-eye from this image' or 'Rotate this image'. Are there other ways you imagine this skill being used?"
|
||||
- "What would a user say that should trigger this skill?"
|
||||
|
||||
To avoid overwhelming users, avoid asking too many questions in a single message. Start with the most important questions and follow up as needed for better effectiveness.
|
||||
|
||||
Conclude this step when there is a clear sense of the functionality the skill should support.
|
||||
|
||||
### Step 2: Planning the Reusable Skill Contents
|
||||
|
||||
To turn concrete examples into an effective skill, analyze each example by:
|
||||
|
||||
1. Considering how to execute on the example from scratch
|
||||
2. Identifying what scripts, references, and assets would be helpful when executing these workflows repeatedly
|
||||
|
||||
Example: When building a `pdf-editor` skill to handle queries like "Help me rotate this PDF," the analysis shows:
|
||||
|
||||
1. Rotating a PDF requires re-writing the same code each time
|
||||
2. A `scripts/rotate_pdf.py` script would be helpful to store in the skill
|
||||
|
||||
Example: When designing a `frontend-webapp-builder` skill for queries like "Build me a todo app" or "Build me a dashboard to track my steps," the analysis shows:
|
||||
|
||||
1. Writing a frontend webapp requires the same boilerplate HTML/React each time
|
||||
2. An `assets/hello-world/` template containing the boilerplate HTML/React project files would be helpful to store in the skill
|
||||
|
||||
Example: When building a `big-query` skill to handle queries like "How many users have logged in today?" the analysis shows:
|
||||
|
||||
1. Querying BigQuery requires re-discovering the table schemas and relationships each time
|
||||
2. A `references/schema.md` file documenting the table schemas would be helpful to store in the skill
|
||||
|
||||
To establish the skill's contents, analyze each concrete example to create a list of the reusable resources to include: scripts, references, and assets.
|
||||
|
||||
### Step 3: Initializing the Skill
|
||||
|
||||
At this point, it is time to actually create the skill.
|
||||
|
||||
Skip this step only if the skill being developed already exists, and iteration or packaging is needed. In this case, continue to the next step.
|
||||
|
||||
When creating a new skill from scratch, always run the `init_skill.py` script. The script conveniently generates a new template skill directory that automatically includes everything a skill requires, making the skill creation process much more efficient and reliable.
|
||||
|
||||
Usage:
|
||||
|
||||
```bash
|
||||
scripts/init_skill.py <skill-name> --path <output-directory>
|
||||
```
|
||||
|
||||
The script:
|
||||
|
||||
- Creates the skill directory at the specified path
|
||||
- Generates a SKILL.md template with proper frontmatter and TODO placeholders
|
||||
- Creates example resource directories: `scripts/`, `references/`, and `assets/`
|
||||
- Adds example files in each directory that can be customized or deleted
|
||||
|
||||
After initialization, customize or remove the generated SKILL.md and example files as needed.
|
||||
|
||||
### Step 4: Edit the Skill
|
||||
|
||||
When editing the (newly-generated or existing) skill, remember that the skill is being created for another instance of Claude to use. Focus on including information that would be beneficial and non-obvious to Claude. Consider what procedural knowledge, domain-specific details, or reusable assets would help another Claude instance execute these tasks more effectively.
|
||||
|
||||
#### Start with Reusable Skill Contents
|
||||
|
||||
To begin implementation, start with the reusable resources identified above: `scripts/`, `references/`, and `assets/` files. Note that this step may require user input. For example, when implementing a `brand-guidelines` skill, the user may need to provide brand assets or templates to store in `assets/`, or documentation to store in `references/`.
|
||||
|
||||
Also, delete any example files and directories not needed for the skill. The initialization script creates example files in `scripts/`, `references/`, and `assets/` to demonstrate structure, but most skills won't need all of them.
|
||||
|
||||
#### Update SKILL.md
|
||||
|
||||
**Writing Style:** Write the entire skill using **imperative/infinitive form** (verb-first instructions), not second person. Use objective, instructional language (e.g., "To accomplish X, do Y" rather than "You should do X" or "If you need to do X"). This maintains consistency and clarity for AI consumption.
|
||||
|
||||
To complete SKILL.md, answer the following questions:
|
||||
|
||||
1. What is the purpose of the skill, in a few sentences?
|
||||
2. When should the skill be used?
|
||||
3. In practice, how should Claude use the skill? All reusable skill contents developed above should be referenced so that Claude knows how to use them.
|
||||
|
||||
### Step 5: Packaging a Skill
|
||||
|
||||
Once the skill is ready, it should be packaged into a distributable zip file that gets shared with the user. The packaging process automatically validates the skill first to ensure it meets all requirements:
|
||||
|
||||
```bash
|
||||
scripts/package_skill.py <path/to/skill-folder>
|
||||
```
|
||||
|
||||
Optional output directory specification:
|
||||
|
||||
```bash
|
||||
scripts/package_skill.py <path/to/skill-folder> ./dist
|
||||
```
|
||||
|
||||
The packaging script will:
|
||||
|
||||
1. **Validate** the skill automatically, checking:
|
||||
- YAML frontmatter format and required fields
|
||||
- Skill naming conventions and directory structure
|
||||
- Description completeness and quality
|
||||
- File organization and resource references
|
||||
|
||||
2. **Package** the skill if validation passes, creating a zip file named after the skill (e.g., `my-skill.zip`) that includes all files and maintains the proper directory structure for distribution.
|
||||
|
||||
If validation fails, the script will report the errors and exit without creating a package. Fix any validation errors and run the packaging command again.
|
||||
|
||||
### Step 6: Iterate
|
||||
|
||||
After testing the skill, users may request improvements. Often this happens right after using the skill, with fresh context of how the skill performed.
|
||||
|
||||
**Iteration workflow:**
|
||||
1. Use the skill on real tasks
|
||||
2. Notice struggles or inefficiencies
|
||||
3. Identify how SKILL.md or bundled resources should be updated
|
||||
4. Implement changes and test again
|
||||
303
.claude/skills/skill-creator/scripts/init_skill.py
Executable file
303
.claude/skills/skill-creator/scripts/init_skill.py
Executable file
@@ -0,0 +1,303 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Skill Initializer - Creates a new skill from template
|
||||
|
||||
Usage:
|
||||
init_skill.py <skill-name> --path <path>
|
||||
|
||||
Examples:
|
||||
init_skill.py my-new-skill --path skills/public
|
||||
init_skill.py my-api-helper --path skills/private
|
||||
init_skill.py custom-skill --path /custom/location
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
SKILL_TEMPLATE = """---
|
||||
name: {skill_name}
|
||||
description: [TODO: Complete and informative explanation of what the skill does and when to use it. Include WHEN to use this skill - specific scenarios, file types, or tasks that trigger it.]
|
||||
---
|
||||
|
||||
# {skill_title}
|
||||
|
||||
## Overview
|
||||
|
||||
[TODO: 1-2 sentences explaining what this skill enables]
|
||||
|
||||
## Structuring This Skill
|
||||
|
||||
[TODO: Choose the structure that best fits this skill's purpose. Common patterns:
|
||||
|
||||
**1. Workflow-Based** (best for sequential processes)
|
||||
- Works well when there are clear step-by-step procedures
|
||||
- Example: DOCX skill with "Workflow Decision Tree" → "Reading" → "Creating" → "Editing"
|
||||
- Structure: ## Overview → ## Workflow Decision Tree → ## Step 1 → ## Step 2...
|
||||
|
||||
**2. Task-Based** (best for tool collections)
|
||||
- Works well when the skill offers different operations/capabilities
|
||||
- Example: PDF skill with "Quick Start" → "Merge PDFs" → "Split PDFs" → "Extract Text"
|
||||
- Structure: ## Overview → ## Quick Start → ## Task Category 1 → ## Task Category 2...
|
||||
|
||||
**3. Reference/Guidelines** (best for standards or specifications)
|
||||
- Works well for brand guidelines, coding standards, or requirements
|
||||
- Example: Brand styling with "Brand Guidelines" → "Colors" → "Typography" → "Features"
|
||||
- Structure: ## Overview → ## Guidelines → ## Specifications → ## Usage...
|
||||
|
||||
**4. Capabilities-Based** (best for integrated systems)
|
||||
- Works well when the skill provides multiple interrelated features
|
||||
- Example: Product Management with "Core Capabilities" → numbered capability list
|
||||
- Structure: ## Overview → ## Core Capabilities → ### 1. Feature → ### 2. Feature...
|
||||
|
||||
Patterns can be mixed and matched as needed. Most skills combine patterns (e.g., start with task-based, add workflow for complex operations).
|
||||
|
||||
Delete this entire "Structuring This Skill" section when done - it's just guidance.]
|
||||
|
||||
## [TODO: Replace with the first main section based on chosen structure]
|
||||
|
||||
[TODO: Add content here. See examples in existing skills:
|
||||
- Code samples for technical skills
|
||||
- Decision trees for complex workflows
|
||||
- Concrete examples with realistic user requests
|
||||
- References to scripts/templates/references as needed]
|
||||
|
||||
## Resources
|
||||
|
||||
This skill includes example resource directories that demonstrate how to organize different types of bundled resources:
|
||||
|
||||
### scripts/
|
||||
Executable code (Python/Bash/etc.) that can be run directly to perform specific operations.
|
||||
|
||||
**Examples from other skills:**
|
||||
- PDF skill: `fill_fillable_fields.py`, `extract_form_field_info.py` - utilities for PDF manipulation
|
||||
- DOCX skill: `document.py`, `utilities.py` - Python modules for document processing
|
||||
|
||||
**Appropriate for:** Python scripts, shell scripts, or any executable code that performs automation, data processing, or specific operations.
|
||||
|
||||
**Note:** Scripts may be executed without loading into context, but can still be read by Claude for patching or environment adjustments.
|
||||
|
||||
### references/
|
||||
Documentation and reference material intended to be loaded into context to inform Claude's process and thinking.
|
||||
|
||||
**Examples from other skills:**
|
||||
- Product management: `communication.md`, `context_building.md` - detailed workflow guides
|
||||
- BigQuery: API reference documentation and query examples
|
||||
- Finance: Schema documentation, company policies
|
||||
|
||||
**Appropriate for:** In-depth documentation, API references, database schemas, comprehensive guides, or any detailed information that Claude should reference while working.
|
||||
|
||||
### assets/
|
||||
Files not intended to be loaded into context, but rather used within the output Claude produces.
|
||||
|
||||
**Examples from other skills:**
|
||||
- Brand styling: PowerPoint template files (.pptx), logo files
|
||||
- Frontend builder: HTML/React boilerplate project directories
|
||||
- Typography: Font files (.ttf, .woff2)
|
||||
|
||||
**Appropriate for:** Templates, boilerplate code, document templates, images, icons, fonts, or any files meant to be copied or used in the final output.
|
||||
|
||||
---
|
||||
|
||||
**Any unneeded directories can be deleted.** Not every skill requires all three types of resources.
|
||||
"""
|
||||
|
||||
EXAMPLE_SCRIPT = '''#!/usr/bin/env python3
|
||||
"""
|
||||
Example helper script for {skill_name}
|
||||
|
||||
This is a placeholder script that can be executed directly.
|
||||
Replace with actual implementation or delete if not needed.
|
||||
|
||||
Example real scripts from other skills:
|
||||
- pdf/scripts/fill_fillable_fields.py - Fills PDF form fields
|
||||
- pdf/scripts/convert_pdf_to_images.py - Converts PDF pages to images
|
||||
"""
|
||||
|
||||
def main():
|
||||
print("This is an example script for {skill_name}")
|
||||
# TODO: Add actual script logic here
|
||||
# This could be data processing, file conversion, API calls, etc.
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
'''
|
||||
|
||||
EXAMPLE_REFERENCE = """# Reference Documentation for {skill_title}
|
||||
|
||||
This is a placeholder for detailed reference documentation.
|
||||
Replace with actual reference content or delete if not needed.
|
||||
|
||||
Example real reference docs from other skills:
|
||||
- product-management/references/communication.md - Comprehensive guide for status updates
|
||||
- product-management/references/context_building.md - Deep-dive on gathering context
|
||||
- bigquery/references/ - API references and query examples
|
||||
|
||||
## When Reference Docs Are Useful
|
||||
|
||||
Reference docs are ideal for:
|
||||
- Comprehensive API documentation
|
||||
- Detailed workflow guides
|
||||
- Complex multi-step processes
|
||||
- Information too lengthy for main SKILL.md
|
||||
- Content that's only needed for specific use cases
|
||||
|
||||
## Structure Suggestions
|
||||
|
||||
### API Reference Example
|
||||
- Overview
|
||||
- Authentication
|
||||
- Endpoints with examples
|
||||
- Error codes
|
||||
- Rate limits
|
||||
|
||||
### Workflow Guide Example
|
||||
- Prerequisites
|
||||
- Step-by-step instructions
|
||||
- Common patterns
|
||||
- Troubleshooting
|
||||
- Best practices
|
||||
"""
|
||||
|
||||
EXAMPLE_ASSET = """# Example Asset File
|
||||
|
||||
This placeholder represents where asset files would be stored.
|
||||
Replace with actual asset files (templates, images, fonts, etc.) or delete if not needed.
|
||||
|
||||
Asset files are NOT intended to be loaded into context, but rather used within
|
||||
the output Claude produces.
|
||||
|
||||
Example asset files from other skills:
|
||||
- Brand guidelines: logo.png, slides_template.pptx
|
||||
- Frontend builder: hello-world/ directory with HTML/React boilerplate
|
||||
- Typography: custom-font.ttf, font-family.woff2
|
||||
- Data: sample_data.csv, test_dataset.json
|
||||
|
||||
## Common Asset Types
|
||||
|
||||
- Templates: .pptx, .docx, boilerplate directories
|
||||
- Images: .png, .jpg, .svg, .gif
|
||||
- Fonts: .ttf, .otf, .woff, .woff2
|
||||
- Boilerplate code: Project directories, starter files
|
||||
- Icons: .ico, .svg
|
||||
- Data files: .csv, .json, .xml, .yaml
|
||||
|
||||
Note: This is a text placeholder. Actual assets can be any file type.
|
||||
"""
|
||||
|
||||
|
||||
def title_case_skill_name(skill_name):
|
||||
"""Convert hyphenated skill name to Title Case for display."""
|
||||
return ' '.join(word.capitalize() for word in skill_name.split('-'))
|
||||
|
||||
|
||||
def init_skill(skill_name, path):
|
||||
"""
|
||||
Initialize a new skill directory with template SKILL.md.
|
||||
|
||||
Args:
|
||||
skill_name: Name of the skill
|
||||
path: Path where the skill directory should be created
|
||||
|
||||
Returns:
|
||||
Path to created skill directory, or None if error
|
||||
"""
|
||||
# Determine skill directory path
|
||||
skill_dir = Path(path).resolve() / skill_name
|
||||
|
||||
# Check if directory already exists
|
||||
if skill_dir.exists():
|
||||
print(f"❌ Error: Skill directory already exists: {skill_dir}")
|
||||
return None
|
||||
|
||||
# Create skill directory
|
||||
try:
|
||||
skill_dir.mkdir(parents=True, exist_ok=False)
|
||||
print(f"✅ Created skill directory: {skill_dir}")
|
||||
except Exception as e:
|
||||
print(f"❌ Error creating directory: {e}")
|
||||
return None
|
||||
|
||||
# Create SKILL.md from template
|
||||
skill_title = title_case_skill_name(skill_name)
|
||||
skill_content = SKILL_TEMPLATE.format(
|
||||
skill_name=skill_name,
|
||||
skill_title=skill_title
|
||||
)
|
||||
|
||||
skill_md_path = skill_dir / 'SKILL.md'
|
||||
try:
|
||||
skill_md_path.write_text(skill_content)
|
||||
print("✅ Created SKILL.md")
|
||||
except Exception as e:
|
||||
print(f"❌ Error creating SKILL.md: {e}")
|
||||
return None
|
||||
|
||||
# Create resource directories with example files
|
||||
try:
|
||||
# Create scripts/ directory with example script
|
||||
scripts_dir = skill_dir / 'scripts'
|
||||
scripts_dir.mkdir(exist_ok=True)
|
||||
example_script = scripts_dir / 'example.py'
|
||||
example_script.write_text(EXAMPLE_SCRIPT.format(skill_name=skill_name))
|
||||
example_script.chmod(0o755)
|
||||
print("✅ Created scripts/example.py")
|
||||
|
||||
# Create references/ directory with example reference doc
|
||||
references_dir = skill_dir / 'references'
|
||||
references_dir.mkdir(exist_ok=True)
|
||||
example_reference = references_dir / 'api_reference.md'
|
||||
example_reference.write_text(EXAMPLE_REFERENCE.format(skill_title=skill_title))
|
||||
print("✅ Created references/api_reference.md")
|
||||
|
||||
# Create assets/ directory with example asset placeholder
|
||||
assets_dir = skill_dir / 'assets'
|
||||
assets_dir.mkdir(exist_ok=True)
|
||||
example_asset = assets_dir / 'example_asset.txt'
|
||||
example_asset.write_text(EXAMPLE_ASSET)
|
||||
print("✅ Created assets/example_asset.txt")
|
||||
except Exception as e:
|
||||
print(f"❌ Error creating resource directories: {e}")
|
||||
return None
|
||||
|
||||
# Print next steps
|
||||
print(f"\n✅ Skill '{skill_name}' initialized successfully at {skill_dir}")
|
||||
print("\nNext steps:")
|
||||
print("1. Edit SKILL.md to complete the TODO items and update the description")
|
||||
print("2. Customize or delete the example files in scripts/, references/, and assets/")
|
||||
print("3. Run the validator when ready to check the skill structure")
|
||||
|
||||
return skill_dir
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 4 or sys.argv[2] != '--path':
|
||||
print("Usage: init_skill.py <skill-name> --path <path>")
|
||||
print("\nSkill name requirements:")
|
||||
print(" - Hyphen-case identifier (e.g., 'data-analyzer')")
|
||||
print(" - Lowercase letters, digits, and hyphens only")
|
||||
print(" - Max 40 characters")
|
||||
print(" - Must match directory name exactly")
|
||||
print("\nExamples:")
|
||||
print(" init_skill.py my-new-skill --path skills/public")
|
||||
print(" init_skill.py my-api-helper --path skills/private")
|
||||
print(" init_skill.py custom-skill --path /custom/location")
|
||||
sys.exit(1)
|
||||
|
||||
skill_name = sys.argv[1]
|
||||
path = sys.argv[3]
|
||||
|
||||
print(f"🚀 Initializing skill: {skill_name}")
|
||||
print(f" Location: {path}")
|
||||
print()
|
||||
|
||||
result = init_skill(skill_name, path)
|
||||
|
||||
if result:
|
||||
sys.exit(0)
|
||||
else:
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
110
.claude/skills/skill-creator/scripts/package_skill.py
Executable file
110
.claude/skills/skill-creator/scripts/package_skill.py
Executable file
@@ -0,0 +1,110 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Skill Packager - Creates a distributable zip file of a skill folder
|
||||
|
||||
Usage:
|
||||
python utils/package_skill.py <path/to/skill-folder> [output-directory]
|
||||
|
||||
Example:
|
||||
python utils/package_skill.py skills/public/my-skill
|
||||
python utils/package_skill.py skills/public/my-skill ./dist
|
||||
"""
|
||||
|
||||
import sys
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from quick_validate import validate_skill
|
||||
|
||||
|
||||
def package_skill(skill_path, output_dir=None):
|
||||
"""
|
||||
Package a skill folder into a zip file.
|
||||
|
||||
Args:
|
||||
skill_path: Path to the skill folder
|
||||
output_dir: Optional output directory for the zip file (defaults to current directory)
|
||||
|
||||
Returns:
|
||||
Path to the created zip file, or None if error
|
||||
"""
|
||||
skill_path = Path(skill_path).resolve()
|
||||
|
||||
# Validate skill folder exists
|
||||
if not skill_path.exists():
|
||||
print(f"❌ Error: Skill folder not found: {skill_path}")
|
||||
return None
|
||||
|
||||
if not skill_path.is_dir():
|
||||
print(f"❌ Error: Path is not a directory: {skill_path}")
|
||||
return None
|
||||
|
||||
# Validate SKILL.md exists
|
||||
skill_md = skill_path / "SKILL.md"
|
||||
if not skill_md.exists():
|
||||
print(f"❌ Error: SKILL.md not found in {skill_path}")
|
||||
return None
|
||||
|
||||
# Run validation before packaging
|
||||
print("🔍 Validating skill...")
|
||||
valid, message = validate_skill(skill_path)
|
||||
if not valid:
|
||||
print(f"❌ Validation failed: {message}")
|
||||
print(" Please fix the validation errors before packaging.")
|
||||
return None
|
||||
print(f"✅ {message}\n")
|
||||
|
||||
# Determine output location
|
||||
skill_name = skill_path.name
|
||||
if output_dir:
|
||||
output_path = Path(output_dir).resolve()
|
||||
output_path.mkdir(parents=True, exist_ok=True)
|
||||
else:
|
||||
output_path = Path.cwd()
|
||||
|
||||
zip_filename = output_path / f"{skill_name}.zip"
|
||||
|
||||
# Create the zip file
|
||||
try:
|
||||
with zipfile.ZipFile(zip_filename, 'w', zipfile.ZIP_DEFLATED) as zipf:
|
||||
# Walk through the skill directory
|
||||
for file_path in skill_path.rglob('*'):
|
||||
if file_path.is_file():
|
||||
# Calculate the relative path within the zip
|
||||
arcname = file_path.relative_to(skill_path.parent)
|
||||
zipf.write(file_path, arcname)
|
||||
print(f" Added: {arcname}")
|
||||
|
||||
print(f"\n✅ Successfully packaged skill to: {zip_filename}")
|
||||
return zip_filename
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Error creating zip file: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: python utils/package_skill.py <path/to/skill-folder> [output-directory]")
|
||||
print("\nExample:")
|
||||
print(" python utils/package_skill.py skills/public/my-skill")
|
||||
print(" python utils/package_skill.py skills/public/my-skill ./dist")
|
||||
sys.exit(1)
|
||||
|
||||
skill_path = sys.argv[1]
|
||||
output_dir = sys.argv[2] if len(sys.argv) > 2 else None
|
||||
|
||||
print(f"📦 Packaging skill: {skill_path}")
|
||||
if output_dir:
|
||||
print(f" Output directory: {output_dir}")
|
||||
print()
|
||||
|
||||
result = package_skill(skill_path, output_dir)
|
||||
|
||||
if result:
|
||||
sys.exit(0)
|
||||
else:
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
65
.claude/skills/skill-creator/scripts/quick_validate.py
Executable file
65
.claude/skills/skill-creator/scripts/quick_validate.py
Executable file
@@ -0,0 +1,65 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Quick validation script for skills - minimal version
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
def validate_skill(skill_path):
|
||||
"""Basic validation of a skill"""
|
||||
skill_path = Path(skill_path)
|
||||
|
||||
# Check SKILL.md exists
|
||||
skill_md = skill_path / 'SKILL.md'
|
||||
if not skill_md.exists():
|
||||
return False, "SKILL.md not found"
|
||||
|
||||
# Read and validate frontmatter
|
||||
content = skill_md.read_text()
|
||||
if not content.startswith('---'):
|
||||
return False, "No YAML frontmatter found"
|
||||
|
||||
# Extract frontmatter
|
||||
match = re.match(r'^---\n(.*?)\n---', content, re.DOTALL)
|
||||
if not match:
|
||||
return False, "Invalid frontmatter format"
|
||||
|
||||
frontmatter = match.group(1)
|
||||
|
||||
# Check required fields
|
||||
if 'name:' not in frontmatter:
|
||||
return False, "Missing 'name' in frontmatter"
|
||||
if 'description:' not in frontmatter:
|
||||
return False, "Missing 'description' in frontmatter"
|
||||
|
||||
# Extract name for validation
|
||||
name_match = re.search(r'name:\s*(.+)', frontmatter)
|
||||
if name_match:
|
||||
name = name_match.group(1).strip()
|
||||
# Check naming convention (hyphen-case: lowercase with hyphens)
|
||||
if not re.match(r'^[a-z0-9-]+$', name):
|
||||
return False, f"Name '{name}' should be hyphen-case (lowercase letters, digits, and hyphens only)"
|
||||
if name.startswith('-') or name.endswith('-') or '--' in name:
|
||||
return False, f"Name '{name}' cannot start/end with hyphen or contain consecutive hyphens"
|
||||
|
||||
# Extract and validate description
|
||||
desc_match = re.search(r'description:\s*(.+)', frontmatter)
|
||||
if desc_match:
|
||||
description = desc_match.group(1).strip()
|
||||
# Check for angle brackets
|
||||
if '<' in description or '>' in description:
|
||||
return False, "Description cannot contain angle brackets (< or >)"
|
||||
|
||||
return True, "Skill is valid!"
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) != 2:
|
||||
print("Usage: python quick_validate.py <skill_directory>")
|
||||
sys.exit(1)
|
||||
|
||||
valid, message = validate_skill(sys.argv[1])
|
||||
print(message)
|
||||
sys.exit(0 if valid else 1)
|
||||
212
.claude/skills/standalone-component-migrator/SKILL.md
Normal file
212
.claude/skills/standalone-component-migrator/SKILL.md
Normal file
@@ -0,0 +1,212 @@
|
||||
---
|
||||
name: standalone-component-migrator
|
||||
description: This skill should be used when converting Angular NgModule-based components to standalone architecture. It handles dependency analysis, template scanning, route refactoring, and test updates. Use this skill when the user requests component migration to standalone, mentions "convert to standalone", or wants to modernize Angular components to the latest patterns.
|
||||
---
|
||||
|
||||
# Standalone Component Migrator
|
||||
|
||||
## Overview
|
||||
|
||||
Automate the conversion of Angular components from NgModule-based architecture to standalone components with explicit imports. This skill analyzes component dependencies, updates routing configurations, migrates tests, and optionally converts to modern Angular control flow syntax (@if, @for, @switch).
|
||||
|
||||
## When to Use This Skill
|
||||
|
||||
Invoke this skill when:
|
||||
- User requests component conversion to standalone
|
||||
- User mentions "migrate to standalone" or "modernize component"
|
||||
- User wants to remove NgModule declarations
|
||||
- User references Angular's standalone component architecture
|
||||
|
||||
## Migration Workflow
|
||||
|
||||
### Step 1: Analyze Component Dependencies
|
||||
|
||||
1. **Read Component File**
|
||||
- Identify component decorator configuration
|
||||
- Note selector, template path, style paths
|
||||
- Check if already standalone
|
||||
|
||||
2. **Analyze Template**
|
||||
- Read template file (HTML)
|
||||
- Scan for directives: `*ngIf`, `*ngFor`, `*ngSwitch` → requires CommonModule
|
||||
- Scan for forms: `ngModel`, `formControl` → requires FormsModule or ReactiveFormsModule
|
||||
- Scan for built-in pipes: `async`, `date`, `json` → CommonModule
|
||||
- Scan for custom components: identify all component selectors
|
||||
- Scan for router directives: `routerLink`, `router-outlet` → RouterModule
|
||||
|
||||
3. **Find Parent NgModule**
|
||||
- Search for NgModule that declares this component
|
||||
- Read NgModule file to understand current imports
|
||||
|
||||
### Step 2: Convert Component to Standalone
|
||||
|
||||
Add `standalone: true` and explicit imports array:
|
||||
|
||||
```typescript
|
||||
// BEFORE
|
||||
import { Component } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'app-my-component',
|
||||
templateUrl: './my-component.component.html'
|
||||
})
|
||||
export class MyComponent { }
|
||||
|
||||
// AFTER
|
||||
import { Component } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { RouterModule } from '@angular/router';
|
||||
import { ChildComponent } from './child.component';
|
||||
import { CustomPipe } from '@isa/utils/pipes';
|
||||
|
||||
@Component({
|
||||
selector: 'app-my-component',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
RouterModule,
|
||||
ChildComponent,
|
||||
CustomPipe
|
||||
],
|
||||
templateUrl: './my-component.component.html'
|
||||
})
|
||||
export class MyComponent { }
|
||||
```
|
||||
|
||||
### Step 3: Update Parent NgModule
|
||||
|
||||
Remove component from declarations, add to imports if exported:
|
||||
|
||||
```typescript
|
||||
// BEFORE
|
||||
@NgModule({
|
||||
declarations: [MyComponent, OtherComponent],
|
||||
imports: [CommonModule],
|
||||
exports: [MyComponent]
|
||||
})
|
||||
|
||||
// AFTER
|
||||
@NgModule({
|
||||
declarations: [OtherComponent],
|
||||
imports: [CommonModule, MyComponent], // Import standalone component
|
||||
exports: [MyComponent]
|
||||
})
|
||||
```
|
||||
|
||||
If NgModule becomes empty (no declarations), consider removing it entirely.
|
||||
|
||||
### Step 4: Update Routes (if applicable)
|
||||
|
||||
Convert to lazy-loaded standalone component:
|
||||
|
||||
```typescript
|
||||
// BEFORE
|
||||
const routes: Routes = [
|
||||
{ path: 'feature', component: MyComponent }
|
||||
];
|
||||
|
||||
// AFTER (lazy loading)
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: 'feature',
|
||||
loadComponent: () => import('./my-component.component').then(m => m.MyComponent)
|
||||
}
|
||||
];
|
||||
```
|
||||
|
||||
### Step 5: Update Tests
|
||||
|
||||
Convert test configuration:
|
||||
|
||||
```typescript
|
||||
// BEFORE
|
||||
TestBed.configureTestingModule({
|
||||
declarations: [MyComponent],
|
||||
imports: [CommonModule, FormsModule]
|
||||
});
|
||||
|
||||
// AFTER
|
||||
TestBed.configureTestingModule({
|
||||
imports: [MyComponent] // Component imports its own dependencies
|
||||
});
|
||||
```
|
||||
|
||||
### Step 6: Optional - Migrate to Modern Control Flow
|
||||
|
||||
If requested, convert to new Angular control flow syntax:
|
||||
|
||||
```typescript
|
||||
// OLD
|
||||
<div *ngIf="condition">Content</div>
|
||||
<div *ngFor="let item of items; trackBy: trackById">{{ item.name }}</div>
|
||||
<div [ngSwitch]="value">
|
||||
<div *ngSwitchCase="'a'">A</div>
|
||||
<div *ngSwitchDefault>Default</div>
|
||||
</div>
|
||||
|
||||
// NEW
|
||||
@if (condition) {
|
||||
<div>Content</div>
|
||||
}
|
||||
@for (item of items; track item.id) {
|
||||
<div>{{ item.name }}</div>
|
||||
}
|
||||
@switch (value) {
|
||||
@case ('a') { <div>A</div> }
|
||||
@default { <div>Default</div> }
|
||||
}
|
||||
```
|
||||
|
||||
### Step 7: Validate and Test
|
||||
|
||||
1. **Compile Check**
|
||||
```bash
|
||||
npx tsc --noEmit
|
||||
```
|
||||
|
||||
2. **Run Tests**
|
||||
```bash
|
||||
npx nx test [library-name] --skip-nx-cache
|
||||
```
|
||||
|
||||
3. **Lint Check**
|
||||
```bash
|
||||
npx nx lint [library-name]
|
||||
```
|
||||
|
||||
4. **Verify Application Runs**
|
||||
```bash
|
||||
npm start
|
||||
```
|
||||
|
||||
## Common Import Patterns
|
||||
|
||||
| Template Usage | Required Import |
|
||||
|---------------|-----------------|
|
||||
| `*ngIf`, `*ngFor`, `*ngSwitch` | `CommonModule` |
|
||||
| `ngModel` | `FormsModule` |
|
||||
| `formControl`, `formGroup` | `ReactiveFormsModule` |
|
||||
| `routerLink`, `router-outlet` | `RouterModule` |
|
||||
| `async`, `date`, `json` pipes | `CommonModule` |
|
||||
| Custom components | Direct component import |
|
||||
| Custom pipes | Direct pipe import |
|
||||
|
||||
## Error Handling
|
||||
|
||||
**Issue: Circular dependencies**
|
||||
- Extract shared interfaces to util library
|
||||
- Use dependency injection for services
|
||||
- Avoid component A importing component B when B imports A
|
||||
|
||||
**Issue: Missing imports causing template errors**
|
||||
- Check browser console for specific errors
|
||||
- Verify all template dependencies are in imports array
|
||||
- Use Angular Language Service in IDE for hints
|
||||
|
||||
## References
|
||||
|
||||
- Angular Standalone Components: https://angular.dev/guide/components/importing
|
||||
- Modern Control Flow: https://angular.dev/guide/templates/control-flow
|
||||
- CLAUDE.md Component Architecture section
|
||||
134
.claude/skills/swagger-sync-manager/SKILL.md
Normal file
134
.claude/skills/swagger-sync-manager/SKILL.md
Normal file
@@ -0,0 +1,134 @@
|
||||
---
|
||||
name: swagger-sync-manager
|
||||
description: This skill should be used when regenerating Swagger/OpenAPI TypeScript API clients in the ISA-Frontend monorepo. It handles generation of all 10 API clients (or specific ones), Unicode cleanup, breaking change detection, TypeScript validation, and affected test execution. Use this skill when the user requests API sync, mentions "regenerate swagger", or indicates backend API changes.
|
||||
---
|
||||
|
||||
# Swagger Sync Manager
|
||||
|
||||
## Overview
|
||||
|
||||
Automate the regeneration of TypeScript API clients from Swagger/OpenAPI specifications. Handles 10 API clients with automatic post-processing, breaking change detection, impact analysis, and validation.
|
||||
|
||||
## When to Use This Skill
|
||||
|
||||
Invoke when user requests:
|
||||
- API client regeneration
|
||||
- "sync swagger" or "update API clients"
|
||||
- Backend API changes need frontend updates
|
||||
|
||||
## Available APIs
|
||||
|
||||
availability-api, cat-search-api, checkout-api, crm-api, eis-api, inventory-api, isa-api, oms-api, print-api, wws-api
|
||||
|
||||
## Sync Workflow
|
||||
|
||||
### Step 1: Pre-Generation Check
|
||||
|
||||
```bash
|
||||
# Check uncommitted changes
|
||||
git status generated/swagger/
|
||||
```
|
||||
|
||||
If changes exist, warn user and ask to proceed.
|
||||
|
||||
### Step 2: Backup Current State (Optional)
|
||||
|
||||
```bash
|
||||
cp -r generated/swagger generated/swagger.backup.$(date +%s)
|
||||
```
|
||||
|
||||
### Step 3: Run Generation
|
||||
|
||||
```bash
|
||||
# All APIs
|
||||
npm run generate:swagger
|
||||
|
||||
# Specific API (if api-name provided)
|
||||
npm run generate:swagger:[api-name]
|
||||
```
|
||||
|
||||
### Step 4: Verify Unicode Cleanup
|
||||
|
||||
Check `tools/fix-files.js` executed. Scan for remaining Unicode issues:
|
||||
|
||||
```bash
|
||||
grep -r "\\\\u00" generated/swagger/ || echo "✅ No Unicode issues"
|
||||
```
|
||||
|
||||
### Step 5: Detect Breaking Changes
|
||||
|
||||
For each modified API:
|
||||
|
||||
```bash
|
||||
git diff generated/swagger/[api-name]/
|
||||
```
|
||||
|
||||
Identify:
|
||||
- 🔴 Removed properties
|
||||
- 🔴 Changed types
|
||||
- 🔴 Removed endpoints
|
||||
- ✅ Added properties (safe)
|
||||
- ✅ New endpoints (safe)
|
||||
|
||||
### Step 6: Impact Analysis
|
||||
|
||||
Use `Explore` agent to find affected files:
|
||||
- Search for imports from `@generated/swagger/[api-name]`
|
||||
- List data-access services using changed APIs
|
||||
- Estimate refactoring scope
|
||||
|
||||
### Step 7: Validate
|
||||
|
||||
```bash
|
||||
# TypeScript compilation
|
||||
npx tsc --noEmit
|
||||
|
||||
# Run affected tests
|
||||
npx nx affected:test --skip-nx-cache
|
||||
|
||||
# Lint affected
|
||||
npx nx affected:lint
|
||||
```
|
||||
|
||||
### Step 8: Generate Report
|
||||
|
||||
```
|
||||
Swagger Sync Complete
|
||||
=====================
|
||||
|
||||
APIs Regenerated: [all | specific]
|
||||
Files Changed: XX
|
||||
Breaking Changes: XX
|
||||
|
||||
🔴 Breaking Changes
|
||||
-------------------
|
||||
- [API]: [Property removed/type changed]
|
||||
- Affected files: [list]
|
||||
|
||||
✅ Compatible Changes
|
||||
---------------------
|
||||
- [API]: [New properties/endpoints]
|
||||
|
||||
📊 Validation
|
||||
-------------
|
||||
TypeScript: ✅/❌
|
||||
Tests: XX/XX passing
|
||||
Lint: ✅/❌
|
||||
|
||||
💡 Next Steps
|
||||
-------------
|
||||
[Fix breaking changes / Deploy]
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
**Generation fails**: Check OpenAPI spec URLs in package.json
|
||||
|
||||
**Unicode cleanup fails**: Run `node tools/fix-files.js` manually
|
||||
|
||||
**TypeScript errors**: Review breaking changes, update affected services
|
||||
|
||||
## References
|
||||
|
||||
- CLAUDE.md API Integration section
|
||||
- package.json swagger generation scripts
|
||||
344
.claude/skills/test-migration-specialist/SKILL.md
Normal file
344
.claude/skills/test-migration-specialist/SKILL.md
Normal file
@@ -0,0 +1,344 @@
|
||||
---
|
||||
name: test-migration-specialist
|
||||
description: This skill should be used when migrating Angular libraries from Jest + Spectator to Vitest + Angular Testing Utilities. It handles test configuration updates, test file refactoring, mock pattern conversion, and validation. Use this skill when the user requests test framework migration, specifically for the 40 remaining Jest-based libraries in the ISA-Frontend monorepo.
|
||||
---
|
||||
|
||||
# Test Migration Specialist
|
||||
|
||||
## Overview
|
||||
|
||||
Automate the migration of Angular library tests from Jest + Spectator to Vitest + Angular Testing Utilities. This skill handles the complete migration workflow including configuration updates, test file refactoring, dependency management, and validation.
|
||||
|
||||
**Current Migration Status**: 40 libraries use Jest (65.6%), 21 libraries use Vitest (34.4%)
|
||||
|
||||
## When to Use This Skill
|
||||
|
||||
Invoke this skill when:
|
||||
- User requests test migration for a specific library
|
||||
- User mentions "migrate tests" or "Jest to Vitest"
|
||||
- User wants to update test framework for a library
|
||||
- User references the 40 remaining libraries to migrate
|
||||
|
||||
## Migration Workflow
|
||||
|
||||
### Step 1: Pre-Migration Analysis
|
||||
|
||||
Before making any changes, analyze the current state:
|
||||
|
||||
1. **Read Testing Guidelines**
|
||||
- Use `docs-researcher` agent to read `docs/guidelines/testing.md`
|
||||
- Understand migration patterns and best practices
|
||||
- Note JUnit and Cobertura configuration requirements
|
||||
|
||||
2. **Analyze Library Structure**
|
||||
- Read `libs/[path]/project.json` to identify current test executor
|
||||
- Count test files using Glob: `**/*.spec.ts`
|
||||
- Scan for Spectator usage patterns using Grep: `createComponentFactory|createServiceFactory|Spectator`
|
||||
- Identify complex mocking scenarios (ng-mocks, jest.mock patterns)
|
||||
|
||||
3. **Determine Library Depth**
|
||||
- Calculate directory levels from workspace root
|
||||
- This affects relative paths in vite.config.mts (../../../ vs ../../../../)
|
||||
|
||||
### Step 2: Update Test Configuration
|
||||
|
||||
Update the library's test configuration to use Vitest:
|
||||
|
||||
1. **Update project.json**
|
||||
Replace Jest executor with Vitest:
|
||||
```json
|
||||
{
|
||||
"test": {
|
||||
"executor": "@nx/vite:test",
|
||||
"options": {
|
||||
"configFile": "vite.config.mts"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
2. **Create vite.config.mts**
|
||||
Create configuration with JUnit and Cobertura reporters:
|
||||
```typescript
|
||||
/// <reference types='vitest' />
|
||||
import { defineConfig } from 'vite';
|
||||
import angular from '@analogjs/vite-plugin-angular';
|
||||
import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin';
|
||||
import { nxCopyAssetsPlugin } from '@nx/vite/plugins/nx-copy-assets.plugin';
|
||||
|
||||
export default
|
||||
// @ts-expect-error - Vitest reporter tuple types have complex inference issues
|
||||
defineConfig(() => ({
|
||||
root: __dirname,
|
||||
cacheDir: '../../../node_modules/.vite/libs/[path]',
|
||||
plugins: [angular(), nxViteTsPaths(), nxCopyAssetsPlugin(['*.md'])],
|
||||
test: {
|
||||
watch: false,
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
include: ['{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
|
||||
setupFiles: ['src/test-setup.ts'],
|
||||
reporters: [
|
||||
'default',
|
||||
['junit', { outputFile: '../../../testresults/junit-[library-name].xml' }],
|
||||
],
|
||||
coverage: {
|
||||
reportsDirectory: '../../../coverage/libs/[path]',
|
||||
provider: 'v8' as const,
|
||||
reporter: ['text', 'cobertura'],
|
||||
},
|
||||
},
|
||||
}));
|
||||
```
|
||||
|
||||
**Critical**: Adjust `../../../` depth based on library location
|
||||
|
||||
### Step 3: Migrate Test Files
|
||||
|
||||
For each `.spec.ts` file, perform these conversions:
|
||||
|
||||
1. **Update Imports**
|
||||
```typescript
|
||||
// REMOVE
|
||||
import { createComponentFactory, Spectator } from '@ngneat/spectator/jest';
|
||||
|
||||
// ADD
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
```
|
||||
|
||||
2. **Convert Component Tests**
|
||||
```typescript
|
||||
// OLD (Spectator)
|
||||
const createComponent = createComponentFactory({
|
||||
component: MyComponent,
|
||||
imports: [CommonModule],
|
||||
mocks: [MyService]
|
||||
});
|
||||
|
||||
let spectator: Spectator<MyComponent>;
|
||||
beforeEach(() => spectator = createComponent());
|
||||
|
||||
it('should display title', () => {
|
||||
spectator.setInput('title', 'Test');
|
||||
expect(spectator.query('h1')).toHaveText('Test');
|
||||
});
|
||||
|
||||
// NEW (Angular Testing Utilities)
|
||||
describe('MyComponent', () => {
|
||||
let fixture: ComponentFixture<MyComponent>;
|
||||
let component: MyComponent;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [MyComponent, CommonModule],
|
||||
providers: [{ provide: MyService, useValue: mockService }]
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(MyComponent);
|
||||
component = fixture.componentInstance;
|
||||
});
|
||||
|
||||
it('should display title', () => {
|
||||
component.title = 'Test';
|
||||
fixture.detectChanges();
|
||||
expect(fixture.nativeElement.querySelector('h1').textContent).toContain('Test');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
3. **Convert Service Tests**
|
||||
```typescript
|
||||
// OLD (Spectator)
|
||||
const createService = createServiceFactory({
|
||||
service: MyService,
|
||||
mocks: [HttpClient]
|
||||
});
|
||||
|
||||
// NEW (Angular Testing Utilities)
|
||||
describe('MyService', () => {
|
||||
let service: MyService;
|
||||
let httpMock: HttpTestingController;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [HttpClientTestingModule],
|
||||
providers: [MyService]
|
||||
});
|
||||
|
||||
service = TestBed.inject(MyService);
|
||||
httpMock = TestBed.inject(HttpTestingController);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
httpMock.verify();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
4. **Update Mock Patterns**
|
||||
- Replace `jest.fn()` → `vi.fn()`
|
||||
- Replace `jest.spyOn()` → `vi.spyOn()`
|
||||
- Replace `jest.mock()` → `vi.mock()`
|
||||
- For complex mocks, use `ng-mocks` library if needed
|
||||
|
||||
5. **Update Matchers**
|
||||
- Replace Spectator matchers (`toHaveText`, `toExist`) with standard Jest/Vitest matchers
|
||||
- Use `expect().toBeTruthy()`, `expect().toContain()`, etc.
|
||||
|
||||
### Step 4: Verify E2E Attributes
|
||||
|
||||
Check component templates for E2E testing attributes:
|
||||
|
||||
1. **Scan Templates**
|
||||
Use Grep to find templates: `**/*.html`
|
||||
|
||||
2. **Validate Attributes**
|
||||
Ensure interactive elements have:
|
||||
- `data-what`: Semantic description (e.g., "submit-button")
|
||||
- `data-which`: Unique identifier (e.g., "form-primary")
|
||||
- Dynamic `data-*` for list items: `[attr.data-item-id]="item.id"`
|
||||
|
||||
3. **Add Missing Attributes**
|
||||
If missing, add them to components. See `dev:add-e2e-attrs` command or use that skill.
|
||||
|
||||
### Step 5: Run Tests and Validate
|
||||
|
||||
Execute tests to verify migration:
|
||||
|
||||
1. **Run Tests**
|
||||
```bash
|
||||
npx nx test [library-name] --skip-nx-cache
|
||||
```
|
||||
|
||||
2. **Run with Coverage**
|
||||
```bash
|
||||
npx nx test [library-name] --coverage.enabled=true --skip-nx-cache
|
||||
```
|
||||
|
||||
3. **Verify Output Files**
|
||||
Check that CI/CD integration files are created:
|
||||
- JUnit XML: `testresults/junit-[library-name].xml`
|
||||
- Cobertura XML: `coverage/libs/[path]/cobertura-coverage.xml`
|
||||
|
||||
4. **Address Failures**
|
||||
If tests fail:
|
||||
- Review test conversion (common issues: missing fixture.detectChanges(), incorrect selectors)
|
||||
- Check mock configurations
|
||||
- Verify imports are correct
|
||||
- Ensure async tests use proper patterns
|
||||
|
||||
### Step 6: Clean Up
|
||||
|
||||
Remove legacy configurations:
|
||||
|
||||
1. **Remove Jest Files**
|
||||
- Delete `jest.config.ts` or `jest.config.js` if present
|
||||
- Remove Jest-specific setup files
|
||||
|
||||
2. **Update Dependencies**
|
||||
- Note if Spectator can be removed (check if other libs still use it)
|
||||
- Note if Jest can be removed (check if other libs still use it)
|
||||
- Don't actually remove from package.json unless all libs migrated
|
||||
|
||||
3. **Update Documentation**
|
||||
Update library README.md with new test commands:
|
||||
```markdown
|
||||
## Testing
|
||||
|
||||
This library uses Vitest + Angular Testing Utilities.
|
||||
|
||||
```bash
|
||||
# Run tests
|
||||
npx nx test [library-name] --skip-nx-cache
|
||||
|
||||
# Run with coverage
|
||||
npx nx test [library-name] --coverage.enabled=true --skip-nx-cache
|
||||
```
|
||||
```
|
||||
|
||||
### Step 7: Generate Migration Report
|
||||
|
||||
Provide comprehensive migration summary:
|
||||
|
||||
```
|
||||
Test Migration Complete
|
||||
=======================
|
||||
|
||||
Library: [library-name]
|
||||
Framework: Jest + Spectator → Vitest + Angular Testing Utilities
|
||||
|
||||
📊 Migration Statistics
|
||||
-----------------------
|
||||
Test files migrated: XX
|
||||
Component tests: XX
|
||||
Service tests: XX
|
||||
Total test cases: XX
|
||||
|
||||
✅ Test Results
|
||||
---------------
|
||||
Passing: XX/XX (100%)
|
||||
Coverage: XX%
|
||||
|
||||
📝 Configuration
|
||||
----------------
|
||||
- project.json: ✅ Updated to @nx/vite:test
|
||||
- vite.config.mts: ✅ Created with JUnit + Cobertura
|
||||
- E2E attributes: ✅ Validated
|
||||
|
||||
📁 CI/CD Integration
|
||||
--------------------
|
||||
- JUnit XML: ✅ testresults/junit-[name].xml
|
||||
- Cobertura XML: ✅ coverage/libs/[path]/cobertura-coverage.xml
|
||||
|
||||
🧹 Cleanup
|
||||
----------
|
||||
- Jest config removed: ✅
|
||||
- README updated: ✅
|
||||
|
||||
💡 Next Steps
|
||||
-------------
|
||||
1. Verify tests in CI/CD pipeline
|
||||
2. Monitor for any edge cases
|
||||
3. Consider migrating related libraries
|
||||
|
||||
📚 Remaining Libraries
|
||||
----------------------
|
||||
Jest libraries remaining: XX/40
|
||||
Progress: XX% complete
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Common Migration Issues
|
||||
|
||||
**Issue 1: Tests fail after migration**
|
||||
- Check `fixture.detectChanges()` is called after setting inputs
|
||||
- Verify async tests use `async/await` properly
|
||||
- Check component imports are correct (standalone components)
|
||||
|
||||
**Issue 2: Mocks not working**
|
||||
- Verify `vi.fn()` syntax is correct
|
||||
- Check providers array in TestBed configuration
|
||||
- For complex mocks, consider using `ng-mocks`
|
||||
|
||||
**Issue 3: Coverage files not generated**
|
||||
- Verify path depth in vite.config.mts matches library location
|
||||
- Check reporters array includes `'cobertura'`
|
||||
- Ensure `provider: 'v8'` is set
|
||||
|
||||
**Issue 4: Type errors in vite.config.mts**
|
||||
- Add `// @ts-expect-error` comment before `defineConfig()`
|
||||
- This is expected due to Vitest reporter type complexity
|
||||
|
||||
## References
|
||||
|
||||
Use `docs-researcher` agent to access:
|
||||
- `docs/guidelines/testing.md` - Comprehensive migration guide with examples
|
||||
- `CLAUDE.md` - Testing Framework section for project conventions
|
||||
|
||||
**Key Documentation Sections:**
|
||||
- Vitest Configuration with JUnit and Cobertura
|
||||
- Angular Testing Utilities examples
|
||||
- Migration patterns and best practices
|
||||
- E2E attribute requirements
|
||||
199
.claude/skills/type-safety-engineer/SKILL.md
Normal file
199
.claude/skills/type-safety-engineer/SKILL.md
Normal file
@@ -0,0 +1,199 @@
|
||||
---
|
||||
name: type-safety-engineer
|
||||
description: This skill should be used when improving TypeScript type safety by removing `any` types, adding Zod schemas for runtime validation, creating type guards, and strengthening strictness. Use this skill when the user wants to enhance type safety, mentions "fix any types", "add Zod validation", or requests type improvements for better code quality.
|
||||
---
|
||||
|
||||
# Type Safety Engineer
|
||||
|
||||
## Overview
|
||||
|
||||
Enhance TypeScript type safety by eliminating `any` types, adding Zod schemas for runtime validation, creating type guards, and strengthening compiler strictness.
|
||||
|
||||
## When to Use This Skill
|
||||
|
||||
Invoke when user wants to:
|
||||
- Remove `any` types
|
||||
- Add runtime validation with Zod
|
||||
- Improve type safety
|
||||
- Mentioned "type safety" or "Zod schemas"
|
||||
|
||||
## Type Safety Workflow
|
||||
|
||||
### Step 1: Scan for Issues
|
||||
|
||||
```bash
|
||||
# Find explicit any
|
||||
grep -r ": any" libs/ --include="*.ts" | grep -v ".spec.ts"
|
||||
|
||||
# Find functions without return types
|
||||
grep -r "^.*function.*{$" libs/ --include="*.ts" | grep -v ": "
|
||||
|
||||
# TypeScript strict mode check
|
||||
npx tsc --noEmit --strict
|
||||
```
|
||||
|
||||
### Step 2: Categorize Issues
|
||||
|
||||
**🔴 Critical:**
|
||||
- `any` in API response handling
|
||||
- `any` in service methods
|
||||
- `any` in store state types
|
||||
|
||||
**⚠️ Important:**
|
||||
- Missing return types
|
||||
- Untyped parameters
|
||||
- Weak types (`object`, `Function`)
|
||||
|
||||
**ℹ️ Moderate:**
|
||||
- `any` in test files
|
||||
- Loose array types
|
||||
|
||||
### Step 3: Add Zod Schemas for API Responses
|
||||
|
||||
```typescript
|
||||
import { z } from 'zod';
|
||||
|
||||
// Define schema
|
||||
const OrderItemSchema = z.object({
|
||||
productId: z.string().uuid(),
|
||||
quantity: z.number().int().positive(),
|
||||
price: z.number().positive()
|
||||
});
|
||||
|
||||
const OrderResponseSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
status: z.enum(['pending', 'confirmed', 'shipped']),
|
||||
items: z.array(OrderItemSchema),
|
||||
createdAt: z.string().datetime()
|
||||
});
|
||||
|
||||
// Infer TypeScript type
|
||||
type OrderResponse = z.infer<typeof OrderResponseSchema>;
|
||||
|
||||
// Runtime validation
|
||||
const order = OrderResponseSchema.parse(apiResponse);
|
||||
```
|
||||
|
||||
### Step 4: Replace `any` with Specific Types
|
||||
|
||||
**Pattern 1: Unknown + Type Guards**
|
||||
```typescript
|
||||
// BEFORE
|
||||
function processData(data: any) {
|
||||
return data.value;
|
||||
}
|
||||
|
||||
// AFTER
|
||||
function processData(data: unknown): string {
|
||||
if (!isValidData(data)) {
|
||||
throw new Error('Invalid data');
|
||||
}
|
||||
return data.value;
|
||||
}
|
||||
|
||||
function isValidData(data: unknown): data is { value: string } {
|
||||
return typeof data === 'object' && data !== null && 'value' in data;
|
||||
}
|
||||
```
|
||||
|
||||
**Pattern 2: Generic Types**
|
||||
```typescript
|
||||
// BEFORE
|
||||
function findById(items: any[], id: string): any {
|
||||
return items.find(item => item.id === id);
|
||||
}
|
||||
|
||||
// AFTER
|
||||
function findById<T extends { id: string }>(items: T[], id: string): T | undefined {
|
||||
return items.find(item => item.id === id);
|
||||
}
|
||||
```
|
||||
|
||||
### Step 5: Add Type Guards for API Data
|
||||
|
||||
```typescript
|
||||
export function isOrderResponse(data: unknown): data is OrderResponse {
|
||||
try {
|
||||
OrderResponseSchema.parse(data);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Use in service
|
||||
getOrder(id: string): Observable<OrderResponse> {
|
||||
return this.http.get(`/api/orders/${id}`).pipe(
|
||||
map(response => {
|
||||
if (!isOrderResponse(response)) {
|
||||
throw new Error('Invalid API response');
|
||||
}
|
||||
return response;
|
||||
})
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Step 6: Validate Changes
|
||||
|
||||
```bash
|
||||
npx tsc --noEmit --strict
|
||||
npx nx affected:test --skip-nx-cache
|
||||
npx nx affected:lint
|
||||
```
|
||||
|
||||
### Step 7: Generate Report
|
||||
|
||||
```
|
||||
Type Safety Improvements
|
||||
========================
|
||||
|
||||
Path: [analyzed path]
|
||||
|
||||
🔍 Issues Found
|
||||
---------------
|
||||
`any` usages: XX → 0
|
||||
Missing return types: XX → 0
|
||||
Untyped parameters: XX → 0
|
||||
|
||||
✅ Improvements
|
||||
---------------
|
||||
- Added Zod schemas: XX
|
||||
- Created type guards: XX
|
||||
- Fixed `any` types: XX
|
||||
- Added return types: XX
|
||||
|
||||
📈 Type Safety Score
|
||||
--------------------
|
||||
Before: XX%
|
||||
After: XX% (+XX%)
|
||||
|
||||
💡 Recommendations
|
||||
------------------
|
||||
1. Enable stricter TypeScript options
|
||||
2. Add validation to remaining APIs
|
||||
```
|
||||
|
||||
## Common Patterns
|
||||
|
||||
**API Response Validation:**
|
||||
```typescript
|
||||
const schema = z.object({...});
|
||||
type Type = z.infer<typeof schema>;
|
||||
|
||||
return this.http.get<unknown>(url).pipe(
|
||||
map(response => schema.parse(response))
|
||||
);
|
||||
```
|
||||
|
||||
**Event Handlers:**
|
||||
```typescript
|
||||
// BEFORE: onClick(event: any)
|
||||
// AFTER: onClick(event: MouseEvent)
|
||||
```
|
||||
|
||||
## References
|
||||
|
||||
- Use `docs-researcher` for latest Zod documentation
|
||||
- Zod: https://zod.dev
|
||||
- TypeScript strict mode: https://www.typescriptlang.org/tsconfig#strict
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -58,7 +58,11 @@ libs/swagger/src/lib/*
|
||||
.nx/cache
|
||||
.nx/workspace-data
|
||||
.angular
|
||||
.claude
|
||||
# Claude configuration
|
||||
.claude/*
|
||||
!.claude/agents
|
||||
!.claude/commands
|
||||
!.claude/skills
|
||||
|
||||
|
||||
storybook-static
|
||||
|
||||
11
.vscode/settings.json
vendored
11
.vscode/settings.json
vendored
@@ -20,12 +20,6 @@
|
||||
"editor.formatOnSave": false
|
||||
},
|
||||
"exportall.config.folderListener": [
|
||||
"/libs/oms/data-access/src/lib/models",
|
||||
"/libs/oms/data-access/src/lib/schemas",
|
||||
"/libs/catalogue/data-access/src/lib/models",
|
||||
"/libs/common/data-access/src/lib/models",
|
||||
"/libs/common/data-access/src/lib/error",
|
||||
"/libs/oms/data-access/src/lib/errors/return-process"
|
||||
],
|
||||
"github.copilot.chat.commitMessageGeneration.instructions": [
|
||||
{
|
||||
@@ -96,5 +90,8 @@
|
||||
"cursor-global": true,
|
||||
"cursor-workspace": true
|
||||
},
|
||||
"chat.mcp.access": "all"
|
||||
"chat.mcp.access": "all",
|
||||
"typescript.inlayHints.parameterTypes.enabled": true,
|
||||
"typescript.inlayHints.variableTypes.enabled": true,
|
||||
"editor.hover.delay": 100
|
||||
}
|
||||
|
||||
397
CLAUDE.md
397
CLAUDE.md
@@ -1,17 +1,31 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
> **Last Updated:** 2025-10-22
|
||||
> **Angular Version:** 20.1.2
|
||||
> **Nx Version:** 21.3.2
|
||||
> **Node.js:** ≥22.0.0
|
||||
> **npm:** ≥10.0.0
|
||||
|
||||
## 🔴 CRITICAL: Mandatory Agent Usage
|
||||
|
||||
**You MUST use these subagents for ALL research tasks:**
|
||||
- **`docs-researcher`**: For ALL documentation (packages, libraries, READMEs)
|
||||
- **`docs-researcher-advanced`**: Auto-escalate when docs-researcher fails
|
||||
- **`Explore`**: For ALL code pattern searches and multi-file analysis
|
||||
- **Direct tools (Read/Bash)**: ONLY for single specific files or commands
|
||||
|
||||
**Violations of this rule degrade performance and context quality. NO EXCEPTIONS.**
|
||||
|
||||
## Project Overview
|
||||
|
||||
This is an Angular monorepo managed by Nx. The main application is `isa-app`, which appears to be an inventory and returns management system for retail/e-commerce.
|
||||
This is a sophisticated Angular 20.1.2 monorepo managed by Nx 21.3.2. The main application is `isa-app`, a comprehensive inventory and returns management system for retail/e-commerce operations. The system handles complex workflows including order management (OMS), returns processing (remission), customer relationship management (CRM), product cataloging, and checkout/reward systems.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Monorepo Structure
|
||||
- **apps/isa-app**: Main Angular application
|
||||
- **libs/**: Reusable libraries organized by domain and type
|
||||
- **core/**: Core utilities (config, logging, storage, tabs)
|
||||
- **core/**: Core utilities (config, logging, storage, tabs, navigation)
|
||||
- **common/**: Shared utilities (data-access, decorators, print)
|
||||
- **ui/**: UI component libraries (buttons, dialogs, inputs, etc.)
|
||||
- **shared/**: Shared domain components (filter, scanner, product components)
|
||||
@@ -23,126 +37,321 @@ This is an Angular monorepo managed by Nx. The main application is `isa-app`, wh
|
||||
- **generated/swagger/**: Auto-generated API client code from OpenAPI specs
|
||||
|
||||
### Key Architectural Patterns
|
||||
- **Standalone Components**: Project uses Angular standalone components
|
||||
- **Feature Libraries**: Domain features organized as separate libraries (e.g., `oms-feature-return-search`)
|
||||
- **Data Access Layer**: Separate data-access libraries for each domain (e.g., `oms-data-access`, `remission-data-access`)
|
||||
- **Shared UI Components**: Reusable UI components in `libs/ui/`
|
||||
- **Generated API Clients**: Swagger/OpenAPI clients auto-generated in `generated/swagger/`
|
||||
- **Domain-Driven Design**: Clear domain boundaries with dedicated modules (OMS, remission, CRM, catalogue, checkout)
|
||||
- **Layered Architecture**: Strict dependency hierarchy (Feature → Shared/UI → Data Access → Infrastructure)
|
||||
- **Standalone Components**: All new components use Angular standalone architecture with explicit imports
|
||||
- **Feature Libraries**: Domain features organized as separate libraries (e.g., `oms-feature-return-search`, `remission-feature-remission-list`)
|
||||
- **Data Access Layer**: Separate data-access libraries for each domain with NgRx Signals stores
|
||||
- **Shared UI Components**: 17 dedicated UI component libraries with design system integration
|
||||
- **Generated API Clients**: 10 auto-generated Swagger/OpenAPI clients with post-processing pipeline
|
||||
- **Path Aliases**: Comprehensive TypeScript path mapping (`@isa/domain/layer/feature`)
|
||||
- **Component Prefixes**: Domain-specific prefixes (OMS: `oms-feature-*`, Remission: `remi-*`, UI: `ui-*`)
|
||||
- **Modern State Management**: NgRx Signals with entities, session persistence, and reactive patterns
|
||||
|
||||
## Common Development Commands
|
||||
|
||||
### Build Commands
|
||||
### Essential Commands (Project-Specific)
|
||||
```bash
|
||||
# Build the main application (development)
|
||||
npx nx build isa-app --configuration=development
|
||||
# Start development server with SSL (required for authentication flows)
|
||||
npm start
|
||||
|
||||
# Run tests for all libraries (excludes main app)
|
||||
npm test
|
||||
|
||||
# Build for development
|
||||
npm run build
|
||||
|
||||
# Build for production
|
||||
npx nx build isa-app --configuration=production
|
||||
npm run build-prod
|
||||
|
||||
# Serve the application with SSL
|
||||
npx nx serve isa-app --ssl
|
||||
```
|
||||
|
||||
### Testing Commands
|
||||
```bash
|
||||
# Run tests for a specific library (always use --skip-cache)
|
||||
npx nx run <project-name>:test --skip-cache
|
||||
# Example: npx nx run remission-data-access:test --skip-cache
|
||||
|
||||
# Run tests for all libraries except the main app
|
||||
npx nx run-many -t test --exclude isa-app --skip-cache
|
||||
|
||||
# Run a single test file
|
||||
npx nx run <project-name>:test --testFile=<path-to-test-file> --skip-cache
|
||||
|
||||
# Run tests with coverage
|
||||
npx nx run <project-name>:test --code-coverage --skip-cache
|
||||
|
||||
# Run tests in watch mode
|
||||
npx nx run <project-name>:test --watch
|
||||
```
|
||||
|
||||
### Linting Commands
|
||||
```bash
|
||||
# Lint a specific project
|
||||
npx nx lint <project-name>
|
||||
# Example: npx nx lint remission-data-access
|
||||
|
||||
# Run linting for all projects
|
||||
npx nx run-many -t lint
|
||||
```
|
||||
|
||||
### Other Useful Commands
|
||||
```bash
|
||||
# Generate Swagger API clients
|
||||
# Regenerate all API clients from Swagger/OpenAPI specs
|
||||
npm run generate:swagger
|
||||
|
||||
# Start Storybook
|
||||
npx nx run isa-app:storybook
|
||||
# Regenerate library reference documentation
|
||||
npm run docs:generate
|
||||
|
||||
# Format code with Prettier
|
||||
npm run prettier
|
||||
|
||||
# List all projects in the workspace
|
||||
npx nx list
|
||||
# Format only staged files (pre-commit hook)
|
||||
npm run pretty-quick
|
||||
|
||||
# Show project dependencies graph
|
||||
# Run CI tests with coverage
|
||||
npm run ci
|
||||
```
|
||||
|
||||
### Standard Nx Commands
|
||||
For complete command reference, see [Nx Documentation](https://nx.dev/reference/commands).
|
||||
|
||||
**Common patterns:**
|
||||
```bash
|
||||
# Test specific library (always use --skip-nx-cache)
|
||||
npx nx test <project-name> --skip-nx-cache
|
||||
|
||||
# Lint a project
|
||||
npx nx lint <project-name>
|
||||
|
||||
# Show project dependencies
|
||||
npx nx graph
|
||||
|
||||
# Run affected tests (based on git changes)
|
||||
npx nx affected:test
|
||||
# Run tests for affected projects (CI/CD)
|
||||
npx nx affected:test --skip-nx-cache
|
||||
```
|
||||
|
||||
**Important:** Always use `--skip-nx-cache` flag when running tests to ensure fresh results.
|
||||
|
||||
## Testing Framework
|
||||
|
||||
### Current Setup
|
||||
- **Jest**: Primary test runner for existing libraries
|
||||
- **Vitest**: Being adopted for new libraries (migration in progress)
|
||||
- **Testing Utilities**:
|
||||
- **Angular Testing Utilities** (TestBed, ComponentFixture): Use for new tests
|
||||
- **Spectator**: Legacy testing utility for existing tests
|
||||
- **ng-mocks**: For advanced mocking scenarios
|
||||
> **Last Reviewed:** 2025-10-22
|
||||
> **Status:** Migration in Progress (Jest → Vitest)
|
||||
|
||||
### Test File Requirements
|
||||
### Current Setup (Migration in Progress)
|
||||
- **Jest**: 40 libraries (65.6% - legacy/existing code)
|
||||
- **Vitest**: 21 libraries (34.4% - new standard)
|
||||
- All formal libraries now have test executors configured
|
||||
|
||||
### Testing Strategy
|
||||
- **New libraries**: Use Vitest + Angular Testing Utilities (TestBed, ComponentFixture)
|
||||
- **Legacy libraries**: Continue with Jest + Spectator until migrated
|
||||
- **Advanced mocking**: Use ng-mocks for complex scenarios
|
||||
|
||||
### Key Requirements
|
||||
- Test files must end with `.spec.ts`
|
||||
- Use AAA pattern (Arrange-Act-Assert)
|
||||
- Include E2E testing attributes (`data-what`, `data-which`) in HTML templates
|
||||
- Mock external dependencies and child components
|
||||
- **Always include E2E attributes**: `data-what`, `data-which`, and dynamic `data-*` in HTML templates
|
||||
- Mock external dependencies appropriately for your framework
|
||||
|
||||
**For detailed testing guidelines, framework comparison, and migration instructions, see [`docs/guidelines/testing.md`](docs/guidelines/testing.md).**
|
||||
|
||||
**References:**
|
||||
- [Jest Documentation](https://jestjs.io/)
|
||||
- [Vitest Documentation](https://vitest.dev/)
|
||||
- [Angular Testing Guide](https://angular.io/guide/testing)
|
||||
- [Spectator](https://ngneat.github.io/spectator/)
|
||||
|
||||
## State Management
|
||||
- **NgRx**: Store, Effects, Entity, Component Store, Signals
|
||||
- **RxJS**: For reactive programming patterns
|
||||
- **NgRx Signals**: Primary state management with modern functional approach using `signalStore()`
|
||||
- **Entity Management**: Uses `withEntities()` for normalized data storage
|
||||
- **Session Persistence**: State persistence with `withStorage()` using SessionStorageProvider
|
||||
- **Reactive Methods**: `rxMethod()` with `takeUntilKeydownEscape()` for user-cancellable operations
|
||||
- **Custom RxJS Operators**: Specialized operators like `takeUntilAborted()`, `takeUntilKeydown()`
|
||||
- **Error Handling**: `tapResponse()` for handling success/error states in stores
|
||||
- **Lifecycle Hooks**: `withHooks()` for cleanup and initialization (e.g., orphaned entity cleanup)
|
||||
- **Navigation State**: Use `@isa/core/navigation` for temporary navigation context (return URLs, wizard state) instead of query parameters
|
||||
|
||||
## Styling
|
||||
- **Tailwind CSS**: Primary styling framework with custom configuration
|
||||
- **SCSS**: For component-specific styles
|
||||
- **Custom Tailwind plugins**: For buttons, inputs, menus, typography
|
||||
## Styling and Design System
|
||||
- **Framework**: [Tailwind CSS](https://tailwindcss.com/docs) with extensive ISA-specific customization
|
||||
- **Custom Breakpoints**: `isa-desktop` (1024px), `isa-desktop-l` (1440px), `isa-desktop-xl` (1920px)
|
||||
- **Brand Color System**: `isa-*` color palette with semantic naming
|
||||
- **Custom Tailwind Plugins** (7): button, typography, menu, label, input, section, select-bullet
|
||||
- **Typography System**: 14 custom utilities (`.isa-text-heading-1-bold`, `.isa-text-body-2-regular`, etc.)
|
||||
- **UI Component Libraries**: 17 specialized libraries with consistent APIs (see Library Reference)
|
||||
- **Storybook**: Component documentation and development at `npm run storybook`
|
||||
|
||||
## API Integration
|
||||
- **Generated Swagger Clients**: Auto-generated TypeScript clients from OpenAPI specs
|
||||
- **Available APIs**: availability, cat-search, checkout, crm, eis, inventory, isa, oms, print, wws
|
||||
### Responsive Design with Breakpoint Service
|
||||
Use `@isa/ui/layout` for reactive breakpoint detection instead of CSS-only solutions:
|
||||
|
||||
```typescript
|
||||
import { breakpoint, Breakpoint } from '@isa/ui/layout';
|
||||
|
||||
// Detect screen size reactively
|
||||
isDesktop = breakpoint([Breakpoint.Desktop, Breakpoint.DekstopL, Breakpoint.DekstopXL]);
|
||||
```
|
||||
|
||||
**Available Breakpoints:**
|
||||
- `Tablet`: max-width: 1279px (mobile/tablet)
|
||||
- `Desktop`: 1280px - 1439px (standard desktop)
|
||||
- `DekstopL`: 1440px - 1919px (large desktop)
|
||||
- `DekstopXL`: 1920px+ (extra large)
|
||||
|
||||
**Template Usage:**
|
||||
```html
|
||||
@if (isDesktop) {
|
||||
<!-- Desktop-specific content -->
|
||||
}
|
||||
```
|
||||
|
||||
**Why:** Prefer breakpoint service over CSS-only (hidden/flex) for SSR and maintainability.
|
||||
|
||||
## API Integration and Data Access
|
||||
|
||||
**Generated Swagger Clients:** 10 auto-generated TypeScript clients in `generated/swagger/`
|
||||
- Available APIs: availability-api, cat-search-api, checkout-api, crm-api, eis-api, inventory-api, isa-api, oms-api, print-api, wws-api
|
||||
- Tool: [ng-swagger-gen](https://www.npmjs.com/package/ng-swagger-gen) with custom per-API configuration
|
||||
- Post-processing: Automatic Unicode cleanup via `tools/fix-files.js`
|
||||
- Regenerate: `npm run generate:swagger`
|
||||
|
||||
**Architecture Pattern:**
|
||||
- Business logic services wrap generated API clients
|
||||
- Type safety: TypeScript + [Zod](https://zod.dev/) schema validation
|
||||
- Error handling: Global HTTP interceptor with automatic re-authentication
|
||||
- Modern injection: Uses `inject()` function with private field pattern
|
||||
- Request cancellation: Built-in via AbortSignal and custom RxJS operators (`takeUntilAborted()`, `takeUntilKeydown()`)
|
||||
|
||||
**Data Access Libraries:** See Library Reference section for domain-specific implementations (`@isa/[domain]/data-access`).
|
||||
|
||||
## Build Configuration
|
||||
- **Angular 20.1.2**: Latest Angular version
|
||||
- **TypeScript 5.8.3**: For type safety
|
||||
- **Node.js >= 22.0.0**: Required Node version
|
||||
- **npm >= 10.0.0**: Required npm version
|
||||
- **Framework**: Angular with TypeScript (see `package.json` for current versions)
|
||||
- **Requirements**:
|
||||
- Node.js >= 22.0.0 (specified in package.json engines)
|
||||
- npm >= 10.0.0 (specified in package.json engines)
|
||||
- **Build System**: Nx monorepo with Vite for testing (Vitest)
|
||||
- **Development Server**: Serves with SSL by default (required for authentication flows)
|
||||
|
||||
## Important Conventions
|
||||
- **Component Prefix**: Each library has its own prefix (e.g., `remi` for remission, `oms` for OMS)
|
||||
- **Standalone Components**: All new components should be standalone
|
||||
- **Path Aliases**: Use TypeScript path aliases defined in `tsconfig.base.json` (e.g., `@isa/core/config`)
|
||||
- **Project Names**: Can be found in each library's `project.json` file
|
||||
## Important Conventions and Patterns
|
||||
|
||||
## Development Workflow Tips
|
||||
- Always use `npx nx run` pattern for executing tasks
|
||||
- Include `--skip-cache` flag when running tests to ensure fresh results
|
||||
- Use Nx's affected commands to optimize CI/CD pipelines
|
||||
- Project graph visualization helps understand dependencies: `npx nx graph`
|
||||
### Library Organization
|
||||
- **Naming Pattern**: `[domain]-[layer]-[feature]` (e.g., `oms-feature-return-search`, `ui-buttons`)
|
||||
- **Path Aliases**: `@isa/[domain]/[layer]/[feature]` (e.g., `@isa/oms/data-access`, `@isa/ui/buttons`)
|
||||
- **Project Names**: Found in each library's `project.json` file, following consistent naming
|
||||
|
||||
## Development Notes
|
||||
- Use start target to start the application. Only one project can be started: isa-app
|
||||
- Make sure to have a look at @docs/guidelines/testing.md before writing tests
|
||||
- Make sure to add e2e attributes to the html. Those are important for my colleagues writen e2e tests
|
||||
- Guide for the e2e testing attributes can be found in the testing.md
|
||||
- When reviewing code follow the instructions @.github/review-instructions.md
|
||||
### Component Architecture
|
||||
- **Standalone Components**: All new components must be standalone with explicit imports
|
||||
- **Component Prefixes**: Domain-specific prefixes for clear identification
|
||||
- OMS features: `oms-feature-*` (e.g., `oms-feature-return-search-main`)
|
||||
- Remission features: `remi-*`
|
||||
- UI components: `ui-*`
|
||||
- Core utilities: `core-*`
|
||||
- **Signal-based Inputs**: Use Angular signals (`input()`, `computed()`) for reactive properties
|
||||
- **Host Binding**: Dynamic CSS classes via Angular host properties
|
||||
|
||||
### Dependency Rules
|
||||
- **Unidirectional Dependencies**: Feature → Shared/UI → Data Access → Infrastructure
|
||||
- **Import Boundaries**: Use path aliases, avoid relative imports across domain boundaries
|
||||
- **Generated API Imports**: Import from `@generated/swagger/[api-name]` for API clients
|
||||
|
||||
### Code Quality
|
||||
- **Modern Angular Patterns**: Prefer `inject()` over constructor injection
|
||||
- **Type Safety**: Use Zod schemas for runtime validation alongside TypeScript
|
||||
- **Error Handling**: Custom error classes with specific error codes
|
||||
- **E2E Testing**: Always include `data-what` and `data-which` attributes in templates
|
||||
|
||||
## Development Workflow and Best Practices
|
||||
|
||||
### Project Conventions
|
||||
- **Default Branch**: `develop` (not main) - Always create PRs against develop
|
||||
- **Commit Style**: [Conventional commits](https://www.conventionalcommits.org/) without co-author tags
|
||||
- **Nx Cache**: Always use `--skip-nx-cache` for tests to ensure fresh results
|
||||
- **Testing**: New libraries use Vitest + Angular Testing Utilities; legacy use Jest + Spectator
|
||||
- **E2E Attributes**: Always include `data-what`, `data-which`, and dynamic `data-*` in templates
|
||||
- **Design System**: Use ISA-specific Tailwind utilities (`isa-accent-*`, `isa-text-*`)
|
||||
|
||||
### Code Quality Tools
|
||||
- **Linting**: [ESLint](https://eslint.org/) with Nx dependency checks
|
||||
- **Formatting**: [Prettier](https://prettier.io/) with Husky + lint-staged pre-commit hooks
|
||||
- **Type Safety**: [TypeScript](https://www.typescriptlang.org/) strict mode + [Zod](https://zod.dev/) validation
|
||||
- **Bundle Size**: Monitor carefully (2MB warning, 5MB error for main bundle)
|
||||
|
||||
### Nx Workflow Tips
|
||||
- Use `npx nx graph` to visualize dependencies
|
||||
- Use `npx nx affected:test` for CI/CD optimization
|
||||
- Reference: [Nx Documentation](https://nx.dev/getting-started/intro)
|
||||
|
||||
## Development Notes and Guidelines
|
||||
|
||||
### Getting Started
|
||||
- **Application Startup**: Only `isa-app` can be started - it's the main application entry point
|
||||
- **SSL Development**: The development server runs with SSL by default (`npm start`), which is crucial for production-like authentication flows
|
||||
- **Node Requirements**: Ensure Node.js ≥22.0.0 and npm ≥10.0.0 before starting development
|
||||
- **First-Time Setup**: After cloning, run `npm install` then `npm start` to verify everything works
|
||||
|
||||
### Essential Documentation References
|
||||
- **Testing Guidelines**: Review `docs/guidelines/testing.md` before writing any tests - it covers the Jest→Vitest migration, Spectator→Angular Testing Utilities transition, and E2E attribute requirements
|
||||
- **Code Review Standards**: Follow the structured review process in `.github/review-instructions.md` with categorized feedback (🚨 Critical, ❗ Minor, ⚠️ Warnings, ✅ Good Practices)
|
||||
- **E2E Testing Requirements**: Always include `data-what`, `data-which`, and dynamic `data-*` attributes in HTML templates - these are essential for automated testing by QA colleagues
|
||||
|
||||
### Researching and Investigating the Codebase
|
||||
|
||||
**🔴 MANDATORY: You MUST use subagents for research. Direct file reading/searching is FORBIDDEN except for single specific files.**
|
||||
|
||||
#### Required Agent Usage
|
||||
|
||||
| Task Type | Required Agent | Escalation Path |
|
||||
|-----------|---------------|-----------------|
|
||||
| **Package/Library Documentation** | `docs-researcher` | → `docs-researcher-advanced` if not found |
|
||||
| **Internal Library READMEs** | `docs-researcher` | Keep context clean |
|
||||
| **Code Pattern Search** | `Explore` | Set thoroughness level |
|
||||
| **Implementation Analysis** | `Explore` | Multiple file analysis |
|
||||
| **Single Specific File** | Read tool directly | No agent needed |
|
||||
|
||||
#### Documentation Research System (Two-Tier)
|
||||
|
||||
1. **ALWAYS start with `docs-researcher`** (Haiku, 30-120s) for any documentation need
|
||||
2. **Auto-escalate to `docs-researcher-advanced`** (Sonnet, 2-7min) when:
|
||||
- Documentation not found
|
||||
- Conflicting sources
|
||||
- Need code inference
|
||||
- Complex architectural questions
|
||||
|
||||
#### Enforcement Examples
|
||||
|
||||
```
|
||||
❌ WRONG: Read libs/ui/buttons/README.md
|
||||
✅ RIGHT: Task → docs-researcher → "Find documentation for @isa/ui/buttons"
|
||||
|
||||
❌ WRONG: Grep for "signalStore" patterns
|
||||
✅ RIGHT: Task → Explore → "Find all signalStore implementations"
|
||||
|
||||
❌ WRONG: WebSearch for Zod documentation
|
||||
✅ RIGHT: Task → docs-researcher → "Find Zod validation documentation"
|
||||
```
|
||||
|
||||
**Remember: Using subagents is NOT optional - it's mandatory for maintaining context efficiency and search quality.**
|
||||
|
||||
#### Common Research Patterns
|
||||
|
||||
| Information Need | Required Approach |
|
||||
|-----------------|-------------------|
|
||||
| **Library documentation** | `docs-researcher` → Check library-reference.md → Escalate if needed |
|
||||
| **Code patterns/examples** | `Explore` with "medium" or "very thorough" |
|
||||
| **Architecture understanding** | `npx nx graph` + `Explore` for implementation |
|
||||
| **Debugging/errors** | Direct tool use (Read specific error file, check console) |
|
||||
|
||||
#### Debugging Tips
|
||||
- **TypeScript errors**: Follow error path to exact file:line
|
||||
- **Test failures**: Use `--skip-nx-cache` for fresh output
|
||||
- **Module resolution**: Check `tsconfig.base.json` path aliases
|
||||
- **State issues**: Use Angular DevTools browser extension
|
||||
|
||||
### Library Development Patterns
|
||||
- **Library Documentation**: Use `docs-researcher` for ALL library READMEs (mandatory for context management)
|
||||
- **New Library Creation**: Use Nx generators with domain-specific naming (`[domain]-[layer]-[feature]`)
|
||||
- **Standalone Components**: All new components must be standalone with explicit imports - no NgModules
|
||||
- **Testing Framework**: New = Vitest + Angular Testing Utilities, Legacy = Jest + Spectator
|
||||
- **Path Aliases**: Always use `@isa/[domain]/[layer]/[feature]` - avoid relative imports
|
||||
|
||||
#### Library Reference Guide
|
||||
|
||||
The monorepo contains **62 libraries** organized across 12 domains. For quick lookup, see **[`docs/library-reference.md`](docs/library-reference.md)**.
|
||||
|
||||
**Quick Overview by Domain:**
|
||||
- Availability (1) | Catalogue (1) | Checkout (6) | Common (3) | Core (5) | CRM (1) | Icons (1)
|
||||
- OMS (9) | Remission (8) | Shared Components (7) | UI Components (17) | Utilities (3)
|
||||
|
||||
### API Integration Workflow
|
||||
- **Swagger Generation**: Run `npm run generate:swagger` to regenerate all 10 API clients when backend changes
|
||||
- **Data Services**: Wrap generated API clients in domain-specific data-access services with proper error handling and Zod validation
|
||||
- **State Management**: Use NgRx Signals with `signalStore()`, entity management, and session persistence for complex state
|
||||
|
||||
### Performance and Quality Considerations
|
||||
- **Bundle Monitoring**: Watch bundle sizes (2MB warning, 5MB error for main bundle)
|
||||
- **Testing Cache**: Always use `--skip-nx-cache` flag when running tests to ensure reliable results
|
||||
- **Code Quality**: Pre-commit hooks enforce Prettier formatting and ESLint rules automatically
|
||||
- **Memory Management**: Clean up subscriptions and use OnPush change detection for optimal performance
|
||||
|
||||
### Common Troubleshooting
|
||||
- **Build Issues**: Check Node version and run `npm install` if encountering module resolution errors
|
||||
- **Test Failures**: Use `--skip-nx-cache` flag and ensure test isolation (no shared state between tests)
|
||||
- **Nx Cache Issues**: If you see `existing outputs match the cache, left as is` during build or testing:
|
||||
- **Option 1**: Run `npx nx reset` to clear the Nx cache completely
|
||||
- **Option 2**: Use `--skip-nx-cache` flag to bypass Nx cache for a specific command (e.g., `npx nx test <project> --skip-nx-cache`)
|
||||
- **When to use**: Always use `--skip-nx-cache` when you need guaranteed fresh builds or test results
|
||||
- **SSL Certificates**: Development server uses SSL - accept certificate warnings in browser for localhost
|
||||
- **Import Errors**: Verify path aliases in `tsconfig.base.json` and use absolute imports for cross-library dependencies
|
||||
|
||||
### Domain-Specific Conventions
|
||||
- **Component Prefixes**: Use `oms-feature-*` for OMS, `remi-*` for remission, `ui-*` for shared components
|
||||
- **Git Workflow**: Default branch is `develop` (not main), use conventional commits without co-author tags
|
||||
- **Design System**: Use ISA-specific Tailwind utilities (`isa-accent-*`, `isa-text-*`) and custom breakpoints (`isa-desktop-*`)
|
||||
- **Logging**: Use centralized logging service (`@isa/core/logging`) with contextual information for debugging
|
||||
- **Navigation State**: Use `@isa/core/navigation` for passing temporary state between routes (return URLs, form context) instead of query parameters - keeps URLs clean and state reliable
|
||||
|
||||
@@ -7,6 +7,7 @@ LABEL build.uniqueid="${BuildUniqueID:-1}"
|
||||
WORKDIR /app
|
||||
COPY . .
|
||||
RUN umask 0022
|
||||
RUN npm install -g npm@11.6
|
||||
RUN npm version ${SEMVERSION}
|
||||
RUN npm ci --foreground-scripts
|
||||
RUN if [ "${IS_PRODUCTION}" = "true" ] ; then npm run-script build-prod ; else npm run-script build ; fi
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { inject, isDevMode, NgModule } from '@angular/core';
|
||||
import { Location } from '@angular/common';
|
||||
import { RouterModule, Routes, Router } from '@angular/router';
|
||||
import { isDevMode, NgModule } from '@angular/core';
|
||||
import { RouterModule, Routes } from '@angular/router';
|
||||
import {
|
||||
CanActivateCartGuard,
|
||||
CanActivateCartWithProcessIdGuard,
|
||||
@@ -11,7 +10,6 @@ import {
|
||||
CanActivateGoodsInGuard,
|
||||
CanActivateProductGuard,
|
||||
CanActivateProductWithProcessIdGuard,
|
||||
CanActivateRemissionGuard,
|
||||
CanActivateTaskCalendarGuard,
|
||||
IsAuthenticatedGuard,
|
||||
} from './guards';
|
||||
@@ -31,11 +29,7 @@ import {
|
||||
ActivateProcessIdWithConfigKeyGuard,
|
||||
} from './guards/activate-process-id.guard';
|
||||
import { MatomoRouteData } from 'ngx-matomo-client';
|
||||
import {
|
||||
tabResolverFn,
|
||||
TabService,
|
||||
TabNavigationService,
|
||||
} from '@isa/core/tabs';
|
||||
import { tabResolverFn, processResolverFn } from '@isa/core/tabs';
|
||||
import { provideScrollPositionRestoration } from '@isa/utils/scroll-position';
|
||||
|
||||
const routes: Routes = [
|
||||
@@ -158,12 +152,12 @@ const routes: Routes = [
|
||||
import('@page/goods-in').then((m) => m.GoodsInModule),
|
||||
canActivate: [CanActivateGoodsInGuard],
|
||||
},
|
||||
{
|
||||
path: 'remission',
|
||||
loadChildren: () =>
|
||||
import('@page/remission').then((m) => m.PageRemissionModule),
|
||||
canActivate: [CanActivateRemissionGuard],
|
||||
},
|
||||
// {
|
||||
// path: 'remission',
|
||||
// loadChildren: () =>
|
||||
// import('@page/remission').then((m) => m.PageRemissionModule),
|
||||
// canActivate: [CanActivateRemissionGuard],
|
||||
// },
|
||||
{
|
||||
path: 'package-inspection',
|
||||
loadChildren: () =>
|
||||
@@ -187,14 +181,36 @@ const routes: Routes = [
|
||||
{
|
||||
path: ':tabId',
|
||||
component: MainComponent,
|
||||
resolve: { process: tabResolverFn, tab: tabResolverFn },
|
||||
resolve: { process: processResolverFn, tab: tabResolverFn },
|
||||
canActivate: [IsAuthenticatedGuard],
|
||||
children: [
|
||||
{
|
||||
path: 'reward',
|
||||
loadChildren: () =>
|
||||
import('@isa/checkout/feature/reward-catalog').then((m) => m.routes),
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
loadChildren: () =>
|
||||
import('@isa/checkout/feature/reward-catalog').then(
|
||||
(m) => m.routes,
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'cart',
|
||||
loadChildren: () =>
|
||||
import('@isa/checkout/feature/reward-shopping-cart').then(
|
||||
(m) => m.routes,
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'order-confirmation',
|
||||
loadChildren: () =>
|
||||
import('@isa/checkout/feature/reward-order-confirmation').then(
|
||||
(m) => m.routes,
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
path: 'return',
|
||||
loadChildren: () =>
|
||||
@@ -241,9 +257,4 @@ if (isDevMode()) {
|
||||
exports: [RouterModule],
|
||||
providers: [provideScrollPositionRestoration()],
|
||||
})
|
||||
export class AppRoutingModule {
|
||||
constructor() {
|
||||
// Loading TabNavigationService to ensure tab state is synced with tab location
|
||||
inject(TabNavigationService);
|
||||
}
|
||||
}
|
||||
export class AppRoutingModule {}
|
||||
|
||||
@@ -1,35 +1,34 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import { EffectsModule } from '@ngrx/effects';
|
||||
import { ActionReducer, MetaReducer, StoreModule } from '@ngrx/store';
|
||||
import { StoreDevtoolsModule } from '@ngrx/store-devtools';
|
||||
import packageInfo from 'packageJson';
|
||||
import { environment } from '../environments/environment';
|
||||
import { RootStateService } from './store/root-state.service';
|
||||
import { rootReducer } from './store/root.reducer';
|
||||
import { RootState } from './store/root.state';
|
||||
|
||||
export function storeInLocalStorage(reducer: ActionReducer<any>): ActionReducer<any> {
|
||||
return function (state, action) {
|
||||
if (action.type === 'HYDRATE') {
|
||||
const initialState = RootStateService.LoadFromLocalStorage();
|
||||
|
||||
if (initialState?.version === packageInfo.version) {
|
||||
return reducer(initialState, action);
|
||||
}
|
||||
}
|
||||
return reducer(state, action);
|
||||
};
|
||||
}
|
||||
|
||||
export const metaReducers: MetaReducer<RootState>[] = !environment.production
|
||||
? [storeInLocalStorage]
|
||||
: [storeInLocalStorage];
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
StoreModule.forRoot(rootReducer, { metaReducers }),
|
||||
EffectsModule.forRoot([]),
|
||||
StoreDevtoolsModule.instrument({ name: 'ISA Ngrx Application Store', connectInZone: true }),
|
||||
],
|
||||
})
|
||||
export class AppStoreModule {}
|
||||
import { NgModule } from '@angular/core';
|
||||
import { EffectsModule } from '@ngrx/effects';
|
||||
import { ActionReducer, MetaReducer, StoreModule } from '@ngrx/store';
|
||||
import { StoreDevtoolsModule } from '@ngrx/store-devtools';
|
||||
import { environment } from '../environments/environment';
|
||||
import { rootReducer } from './store/root.reducer';
|
||||
import { RootState } from './store/root.state';
|
||||
|
||||
export function storeInLocalStorage(
|
||||
reducer: ActionReducer<any>,
|
||||
): ActionReducer<any> {
|
||||
return function (state, action) {
|
||||
if (action.type === 'HYDRATE') {
|
||||
return reducer(action['payload'], action);
|
||||
}
|
||||
return reducer(state, action);
|
||||
};
|
||||
}
|
||||
|
||||
export const metaReducers: MetaReducer<RootState>[] = !environment.production
|
||||
? [storeInLocalStorage]
|
||||
: [storeInLocalStorage];
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
StoreModule.forRoot(rootReducer, { metaReducers }),
|
||||
EffectsModule.forRoot([]),
|
||||
StoreDevtoolsModule.instrument({
|
||||
name: 'ISA Ngrx Application Store',
|
||||
connectInZone: true,
|
||||
}),
|
||||
],
|
||||
})
|
||||
export class AppStoreModule {}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { version } from '../../../../package.json';
|
||||
import {
|
||||
HTTP_INTERCEPTORS,
|
||||
provideHttpClient,
|
||||
@@ -11,6 +12,7 @@ import {
|
||||
NgModule,
|
||||
inject,
|
||||
provideAppInitializer,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { BrowserModule } from '@angular/platform-browser';
|
||||
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
|
||||
@@ -53,7 +55,6 @@ import {
|
||||
ScanAdapterService,
|
||||
ScanditScanAdapterModule,
|
||||
} from '@adapter/scan';
|
||||
import { RootStateService } from './store/root-state.service';
|
||||
import * as Commands from './commands';
|
||||
import { PreviewComponent } from './preview';
|
||||
import { NativeContainerService } from '@external/native-container';
|
||||
@@ -67,7 +68,7 @@ import {
|
||||
matWifiOff,
|
||||
} from '@ng-icons/material-icons/baseline';
|
||||
import { NetworkStatusService } from './services/network-status.service';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { debounceTime, firstValueFrom } from 'rxjs';
|
||||
import { provideMatomo } from 'ngx-matomo-client';
|
||||
import { withRouter, withRouteData } from 'ngx-matomo-client';
|
||||
import {
|
||||
@@ -76,8 +77,17 @@ import {
|
||||
LogLevel,
|
||||
withSink,
|
||||
ConsoleLogSink,
|
||||
logger,
|
||||
} from '@isa/core/logging';
|
||||
import { IDBStorageProvider, UserStorageProvider } from '@isa/core/storage';
|
||||
import {
|
||||
IDBStorageProvider,
|
||||
provideUserSubFactory,
|
||||
UserStorageProvider,
|
||||
} from '@isa/core/storage';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { TabNavigationService } from '@isa/core/tabs';
|
||||
import { OAuthService } from 'angular-oauth2-oidc';
|
||||
import z from 'zod';
|
||||
|
||||
registerLocaleData(localeDe, localeDeExtra);
|
||||
registerLocaleData(localeDe, 'de', localeDeExtra);
|
||||
@@ -111,16 +121,13 @@ export function _appInitializerFactory(config: Config, injector: Injector) {
|
||||
const auth = injector.get(AuthService);
|
||||
try {
|
||||
await auth.init();
|
||||
} catch (error) {
|
||||
} catch {
|
||||
statusElement.innerHTML = 'Authentifizierung wird durchgeführt...';
|
||||
const strategy = injector.get(LoginStrategy);
|
||||
await strategy.login();
|
||||
return;
|
||||
}
|
||||
|
||||
statusElement.innerHTML = 'App wird initialisiert...';
|
||||
const state = injector.get(RootStateService);
|
||||
await state.init();
|
||||
|
||||
statusElement.innerHTML = 'Native Container wird initialisiert...';
|
||||
const nativeContainer = injector.get(NativeContainerService);
|
||||
await nativeContainer.init();
|
||||
@@ -129,8 +136,24 @@ export function _appInitializerFactory(config: Config, injector: Injector) {
|
||||
await injector.get(IDBStorageProvider).init();
|
||||
|
||||
statusElement.innerHTML = 'Benutzerzustand wird geladen...';
|
||||
await injector.get(UserStorageProvider).init();
|
||||
const userStorage = injector.get(UserStorageProvider);
|
||||
await userStorage.init();
|
||||
|
||||
const store = injector.get(Store);
|
||||
// Hydrate Ngrx Store
|
||||
const state = userStorage.get('store');
|
||||
if (state && state['version'] === version) {
|
||||
store.dispatch({ type: 'HYDRATE', payload: userStorage.get('store') });
|
||||
}
|
||||
// Subscribe on Store changes and save to user storage
|
||||
store.pipe(debounceTime(1000)).subscribe((state) => {
|
||||
userStorage.set('store', { ...state, version });
|
||||
});
|
||||
|
||||
// Inject tab navigation service to initialize it
|
||||
injector.get(TabNavigationService).init();
|
||||
} catch (error) {
|
||||
console.error('Error during app initialization', error);
|
||||
laoderElement.remove();
|
||||
statusElement.classList.add('text-xl');
|
||||
statusElement.innerHTML +=
|
||||
@@ -175,6 +198,31 @@ export function _notificationsHubOptionsFactory(
|
||||
return options;
|
||||
}
|
||||
|
||||
const USER_SUB_FACTORY = () => {
|
||||
const _logger = logger(() => ({
|
||||
context: 'USER_SUB',
|
||||
}));
|
||||
const auth = inject(OAuthService);
|
||||
|
||||
const claims = auth.getIdentityClaims();
|
||||
|
||||
if (!claims || typeof claims !== 'object' || !('sub' in claims)) {
|
||||
const err = new Error('No valid identity claims found. User is anonymous.');
|
||||
_logger.error(err.message);
|
||||
throw err;
|
||||
}
|
||||
|
||||
const validation = z.string().safeParse(claims['sub']);
|
||||
|
||||
if (!validation.success) {
|
||||
const err = new Error('Invalid "sub" claim in identity claims.');
|
||||
_logger.error(err.message, { claims });
|
||||
throw err;
|
||||
}
|
||||
|
||||
return signal(validation.data);
|
||||
};
|
||||
|
||||
@NgModule({
|
||||
declarations: [AppComponent, MainComponent],
|
||||
bootstrap: [AppComponent],
|
||||
@@ -248,6 +296,7 @@ export function _notificationsHubOptionsFactory(
|
||||
provide: DEFAULT_CURRENCY_CODE,
|
||||
useValue: 'EUR',
|
||||
},
|
||||
provideUserSubFactory(USER_SUB_FACTORY),
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
@@ -1,205 +1,206 @@
|
||||
import { Injectable } from "@angular/core";
|
||||
import {
|
||||
ActivatedRouteSnapshot,
|
||||
Router,
|
||||
RouterStateSnapshot,
|
||||
} from "@angular/router";
|
||||
import { ApplicationProcess, ApplicationService } from "@core/application";
|
||||
import { DomainCheckoutService } from "@domain/checkout";
|
||||
import { logger } from "@isa/core/logging";
|
||||
import { CustomerSearchNavigation } from "@shared/services/navigation";
|
||||
import { first } from "rxjs/operators";
|
||||
|
||||
@Injectable({ providedIn: "root" })
|
||||
export class CanActivateCustomerGuard {
|
||||
#logger = logger(() => ({
|
||||
context: "CanActivateCustomerGuard",
|
||||
tags: ["guard", "customer", "navigation"],
|
||||
}));
|
||||
|
||||
constructor(
|
||||
private readonly _applicationService: ApplicationService,
|
||||
private readonly _checkoutService: DomainCheckoutService,
|
||||
private readonly _router: Router,
|
||||
private readonly _navigation: CustomerSearchNavigation,
|
||||
) {}
|
||||
|
||||
async canActivate(
|
||||
route: ActivatedRouteSnapshot,
|
||||
{ url }: RouterStateSnapshot,
|
||||
) {
|
||||
if (url.startsWith("/kunde/customer/search/")) {
|
||||
const processId = Date.now(); // Generate a new process ID
|
||||
// Extract parts before and after the pattern
|
||||
const parts = url.split("/kunde/customer/");
|
||||
if (parts.length === 2) {
|
||||
const prefix = parts[0] + "/kunde/";
|
||||
const suffix = "customer/" + parts[1];
|
||||
|
||||
// Construct the new URL with process ID inserted
|
||||
const newUrl = `${prefix}${processId}/${suffix}`;
|
||||
|
||||
this.#logger.info("Redirecting to URL with process ID", () => ({
|
||||
originalUrl: url,
|
||||
newUrl,
|
||||
processId,
|
||||
}));
|
||||
|
||||
// Navigate to the new URL and prevent original navigation
|
||||
this._router.navigateByUrl(newUrl);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const processes = await this._applicationService
|
||||
.getProcesses$("customer")
|
||||
.pipe(first())
|
||||
.toPromise();
|
||||
const lastActivatedProcessId = (
|
||||
await this._applicationService
|
||||
.getLastActivatedProcessWithSectionAndType$("customer", "cart")
|
||||
.pipe(first())
|
||||
.toPromise()
|
||||
)?.id;
|
||||
|
||||
const lastActivatedCartCheckoutProcessId = (
|
||||
await this._applicationService
|
||||
.getLastActivatedProcessWithSectionAndType$("customer", "cart-checkout")
|
||||
.pipe(first())
|
||||
.toPromise()
|
||||
)?.id;
|
||||
|
||||
const lastActivatedGoodsOutProcessId = (
|
||||
await this._applicationService
|
||||
.getLastActivatedProcessWithSectionAndType$("customer", "goods-out")
|
||||
.pipe(first())
|
||||
.toPromise()
|
||||
)?.id;
|
||||
|
||||
const activatedProcessId = await this._applicationService
|
||||
.getActivatedProcessId$()
|
||||
.pipe(first())
|
||||
.toPromise();
|
||||
|
||||
// Darf nur reinkommen wenn der aktuell aktive Tab ein Bestellabschluss Tab ist
|
||||
if (
|
||||
!!lastActivatedCartCheckoutProcessId &&
|
||||
lastActivatedCartCheckoutProcessId === activatedProcessId
|
||||
) {
|
||||
await this.fromCartCheckoutProcess(
|
||||
processes,
|
||||
lastActivatedCartCheckoutProcessId,
|
||||
);
|
||||
return false;
|
||||
} else if (
|
||||
!!lastActivatedGoodsOutProcessId &&
|
||||
lastActivatedGoodsOutProcessId === activatedProcessId
|
||||
) {
|
||||
await this.fromGoodsOutProcess(processes, lastActivatedGoodsOutProcessId);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!lastActivatedProcessId) {
|
||||
await this.fromCartProcess(processes);
|
||||
return false;
|
||||
} else {
|
||||
await this.navigateToDefaultRoute(lastActivatedProcessId);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async navigateToDefaultRoute(processId: number) {
|
||||
const route = this._navigation.defaultRoute({ processId });
|
||||
|
||||
await this._router.navigate(route.path, { queryParams: route.queryParams });
|
||||
}
|
||||
|
||||
// Bei offener Artikelsuche/Kundensuche und Klick auf Footer Kundensuche
|
||||
async fromCartProcess(processes: ApplicationProcess[]) {
|
||||
const newProcessId = Date.now();
|
||||
await this._applicationService.createProcess({
|
||||
id: newProcessId,
|
||||
type: "cart",
|
||||
section: "customer",
|
||||
name: `Vorgang ${this.processNumber(processes.filter((process) => process.type === "cart"))}`,
|
||||
});
|
||||
|
||||
await this.navigateToDefaultRoute(newProcessId);
|
||||
}
|
||||
|
||||
// Bei offener Bestellbestätigung und Klick auf Footer Kundensuche
|
||||
async fromCartCheckoutProcess(
|
||||
processes: ApplicationProcess[],
|
||||
processId: number,
|
||||
) {
|
||||
// Um alle Checkout Daten zu resetten die mit dem Prozess assoziiert sind
|
||||
this._checkoutService.removeProcess({ processId });
|
||||
|
||||
// Ändere type cart-checkout zu cart
|
||||
this._applicationService.patchProcess(processId, {
|
||||
id: processId,
|
||||
type: "cart",
|
||||
section: "customer",
|
||||
name: `Vorgang ${this.processNumber(processes.filter((process) => process.type === "cart"))}`,
|
||||
data: {},
|
||||
});
|
||||
|
||||
// Navigation
|
||||
await this.navigateToDefaultRoute(processId);
|
||||
}
|
||||
|
||||
// Bei offener Warenausgabe und Klick auf Footer Kundensuche
|
||||
async fromGoodsOutProcess(
|
||||
processes: ApplicationProcess[],
|
||||
processId: number,
|
||||
) {
|
||||
const buyer = await this._checkoutService
|
||||
.getBuyer({ processId })
|
||||
.pipe(first())
|
||||
.toPromise();
|
||||
const customerFeatures = await this._checkoutService
|
||||
.getCustomerFeatures({ processId })
|
||||
.pipe(first())
|
||||
.toPromise();
|
||||
const name = buyer
|
||||
? customerFeatures?.b2b
|
||||
? buyer.organisation?.name
|
||||
? buyer.organisation?.name
|
||||
: buyer.lastName
|
||||
: buyer.lastName
|
||||
: `Vorgang ${this.processNumber(processes.filter((process) => process.type === "cart"))}`;
|
||||
|
||||
// Ändere type goods-out zu cart
|
||||
this._applicationService.patchProcess(processId, {
|
||||
id: processId,
|
||||
type: "cart",
|
||||
section: "customer",
|
||||
name,
|
||||
});
|
||||
|
||||
// Navigation
|
||||
await this.navigateToDefaultRoute(processId);
|
||||
}
|
||||
|
||||
processNumber(processes: ApplicationProcess[]) {
|
||||
const processNumbers = processes?.map((process) =>
|
||||
Number(process?.name?.replace(/\D/g, "")),
|
||||
);
|
||||
return !!processNumbers && processNumbers.length > 0
|
||||
? this.findMissingNumber(processNumbers)
|
||||
: 1;
|
||||
}
|
||||
|
||||
findMissingNumber(processNumbers: number[]) {
|
||||
for (
|
||||
let missingNumber = 1;
|
||||
missingNumber < Math.max(...processNumbers);
|
||||
missingNumber++
|
||||
) {
|
||||
if (!processNumbers.find((number) => number === missingNumber)) {
|
||||
return missingNumber;
|
||||
}
|
||||
}
|
||||
return Math.max(...processNumbers) + 1;
|
||||
}
|
||||
}
|
||||
import { Injectable } from '@angular/core';
|
||||
import {
|
||||
ActivatedRouteSnapshot,
|
||||
Router,
|
||||
RouterStateSnapshot,
|
||||
} from '@angular/router';
|
||||
import { ApplicationProcess, ApplicationService } from '@core/application';
|
||||
import { DomainCheckoutService } from '@domain/checkout';
|
||||
import { logger } from '@isa/core/logging';
|
||||
import { CustomerSearchNavigation } from '@shared/services/navigation';
|
||||
import { first } from 'rxjs/operators';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class CanActivateCustomerGuard {
|
||||
#logger = logger(() => ({
|
||||
module: 'isa-app',
|
||||
importMetaUrl: import.meta.url,
|
||||
class: 'CanActivateCustomerGuard',
|
||||
}));
|
||||
|
||||
constructor(
|
||||
private readonly _applicationService: ApplicationService,
|
||||
private readonly _checkoutService: DomainCheckoutService,
|
||||
private readonly _router: Router,
|
||||
private readonly _navigation: CustomerSearchNavigation,
|
||||
) {}
|
||||
|
||||
async canActivate(
|
||||
route: ActivatedRouteSnapshot,
|
||||
{ url }: RouterStateSnapshot,
|
||||
) {
|
||||
if (url.startsWith('/kunde/customer/search/')) {
|
||||
const processId = Date.now(); // Generate a new process ID
|
||||
// Extract parts before and after the pattern
|
||||
const parts = url.split('/kunde/customer/');
|
||||
if (parts.length === 2) {
|
||||
const prefix = parts[0] + '/kunde/';
|
||||
const suffix = 'customer/' + parts[1];
|
||||
|
||||
// Construct the new URL with process ID inserted
|
||||
const newUrl = `${prefix}${processId}/${suffix}`;
|
||||
|
||||
this.#logger.info('Redirecting to URL with process ID', () => ({
|
||||
originalUrl: url,
|
||||
newUrl,
|
||||
processId,
|
||||
}));
|
||||
|
||||
// Navigate to the new URL and prevent original navigation
|
||||
this._router.navigateByUrl(newUrl);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const processes = await this._applicationService
|
||||
.getProcesses$('customer')
|
||||
.pipe(first())
|
||||
.toPromise();
|
||||
const lastActivatedProcessId = (
|
||||
await this._applicationService
|
||||
.getLastActivatedProcessWithSectionAndType$('customer', 'cart')
|
||||
.pipe(first())
|
||||
.toPromise()
|
||||
)?.id;
|
||||
|
||||
const lastActivatedCartCheckoutProcessId = (
|
||||
await this._applicationService
|
||||
.getLastActivatedProcessWithSectionAndType$('customer', 'cart-checkout')
|
||||
.pipe(first())
|
||||
.toPromise()
|
||||
)?.id;
|
||||
|
||||
const lastActivatedGoodsOutProcessId = (
|
||||
await this._applicationService
|
||||
.getLastActivatedProcessWithSectionAndType$('customer', 'goods-out')
|
||||
.pipe(first())
|
||||
.toPromise()
|
||||
)?.id;
|
||||
|
||||
const activatedProcessId = await this._applicationService
|
||||
.getActivatedProcessId$()
|
||||
.pipe(first())
|
||||
.toPromise();
|
||||
|
||||
// Darf nur reinkommen wenn der aktuell aktive Tab ein Bestellabschluss Tab ist
|
||||
if (
|
||||
!!lastActivatedCartCheckoutProcessId &&
|
||||
lastActivatedCartCheckoutProcessId === activatedProcessId
|
||||
) {
|
||||
await this.fromCartCheckoutProcess(
|
||||
processes,
|
||||
lastActivatedCartCheckoutProcessId,
|
||||
);
|
||||
return false;
|
||||
} else if (
|
||||
!!lastActivatedGoodsOutProcessId &&
|
||||
lastActivatedGoodsOutProcessId === activatedProcessId
|
||||
) {
|
||||
await this.fromGoodsOutProcess(processes, lastActivatedGoodsOutProcessId);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!lastActivatedProcessId) {
|
||||
await this.fromCartProcess(processes);
|
||||
return false;
|
||||
} else {
|
||||
await this.navigateToDefaultRoute(lastActivatedProcessId);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async navigateToDefaultRoute(processId: number) {
|
||||
const route = this._navigation.defaultRoute({ processId });
|
||||
|
||||
await this._router.navigate(route.path, { queryParams: route.queryParams });
|
||||
}
|
||||
|
||||
// Bei offener Artikelsuche/Kundensuche und Klick auf Footer Kundensuche
|
||||
async fromCartProcess(processes: ApplicationProcess[]) {
|
||||
const newProcessId = Date.now();
|
||||
await this._applicationService.createProcess({
|
||||
id: newProcessId,
|
||||
type: 'cart',
|
||||
section: 'customer',
|
||||
name: `Vorgang ${this.processNumber(processes.filter((process) => process.type === 'cart'))}`,
|
||||
});
|
||||
|
||||
await this.navigateToDefaultRoute(newProcessId);
|
||||
}
|
||||
|
||||
// Bei offener Bestellbestätigung und Klick auf Footer Kundensuche
|
||||
async fromCartCheckoutProcess(
|
||||
processes: ApplicationProcess[],
|
||||
processId: number,
|
||||
) {
|
||||
// Um alle Checkout Daten zu resetten die mit dem Prozess assoziiert sind
|
||||
this._checkoutService.removeProcess({ processId });
|
||||
|
||||
// Ändere type cart-checkout zu cart
|
||||
this._applicationService.patchProcess(processId, {
|
||||
id: processId,
|
||||
type: 'cart',
|
||||
section: 'customer',
|
||||
name: `Vorgang ${this.processNumber(processes.filter((process) => process.type === 'cart'))}`,
|
||||
data: {},
|
||||
});
|
||||
|
||||
// Navigation
|
||||
await this.navigateToDefaultRoute(processId);
|
||||
}
|
||||
|
||||
// Bei offener Warenausgabe und Klick auf Footer Kundensuche
|
||||
async fromGoodsOutProcess(
|
||||
processes: ApplicationProcess[],
|
||||
processId: number,
|
||||
) {
|
||||
const buyer = await this._checkoutService
|
||||
.getBuyer({ processId })
|
||||
.pipe(first())
|
||||
.toPromise();
|
||||
const customerFeatures = await this._checkoutService
|
||||
.getCustomerFeatures({ processId })
|
||||
.pipe(first())
|
||||
.toPromise();
|
||||
const name = buyer
|
||||
? customerFeatures?.b2b
|
||||
? buyer.organisation?.name
|
||||
? buyer.organisation?.name
|
||||
: buyer.lastName
|
||||
: buyer.lastName
|
||||
: `Vorgang ${this.processNumber(processes.filter((process) => process.type === 'cart'))}`;
|
||||
|
||||
// Ändere type goods-out zu cart
|
||||
this._applicationService.patchProcess(processId, {
|
||||
id: processId,
|
||||
type: 'cart',
|
||||
section: 'customer',
|
||||
name,
|
||||
});
|
||||
|
||||
// Navigation
|
||||
await this.navigateToDefaultRoute(processId);
|
||||
}
|
||||
|
||||
processNumber(processes: ApplicationProcess[]) {
|
||||
const processNumbers = processes?.map((process) =>
|
||||
Number(process?.name?.replace(/\D/g, '')),
|
||||
);
|
||||
return !!processNumbers && processNumbers.length > 0
|
||||
? this.findMissingNumber(processNumbers)
|
||||
: 1;
|
||||
}
|
||||
|
||||
findMissingNumber(processNumbers: number[]) {
|
||||
for (
|
||||
let missingNumber = 1;
|
||||
missingNumber < Math.max(...processNumbers);
|
||||
missingNumber++
|
||||
) {
|
||||
if (!processNumbers.find((number) => number === missingNumber)) {
|
||||
return missingNumber;
|
||||
}
|
||||
}
|
||||
return Math.max(...processNumbers) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,42 +1,50 @@
|
||||
import { inject, Injectable, Injector } from '@angular/core';
|
||||
import { HttpInterceptor, HttpEvent, HttpHandler, HttpRequest, HttpErrorResponse } from '@angular/common/http';
|
||||
import { from, NEVER, Observable, throwError } from 'rxjs';
|
||||
import { UiMessageModalComponent, UiModalService } from '@ui/modal';
|
||||
import { catchError, filter, mergeMap, takeUntil, tap } from 'rxjs/operators';
|
||||
import { AuthService, LoginStrategy } from '@core/auth';
|
||||
import { IsaLogProvider } from '../providers';
|
||||
import { LogLevel } from '@core/logger';
|
||||
import { injectOnline$ } from '../services/network-status.service';
|
||||
|
||||
@Injectable()
|
||||
export class HttpErrorInterceptor implements HttpInterceptor {
|
||||
readonly offline$ = injectOnline$().pipe(filter((online) => !online));
|
||||
readonly injector = inject(Injector);
|
||||
|
||||
constructor(
|
||||
private _modal: UiModalService,
|
||||
private _auth: AuthService,
|
||||
private _isaLogProvider: IsaLogProvider,
|
||||
) {}
|
||||
|
||||
intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
|
||||
return next.handle(req).pipe(
|
||||
takeUntil(this.offline$),
|
||||
catchError((error: HttpErrorResponse, caught: any) => this.handleError(error)),
|
||||
);
|
||||
}
|
||||
|
||||
handleError(error: HttpErrorResponse): Observable<any> {
|
||||
if (error.status === 401) {
|
||||
const strategy = this.injector.get(LoginStrategy);
|
||||
|
||||
return from(strategy.login('Sie sind nicht mehr angemeldet')).pipe(mergeMap(() => NEVER));
|
||||
}
|
||||
|
||||
if (!error.url.endsWith('/isa/logging')) {
|
||||
this._isaLogProvider.log(LogLevel.ERROR, 'Http Error', error);
|
||||
}
|
||||
|
||||
return throwError(error);
|
||||
}
|
||||
}
|
||||
import { inject, Injectable, Injector } from '@angular/core';
|
||||
import {
|
||||
HttpInterceptor,
|
||||
HttpEvent,
|
||||
HttpHandler,
|
||||
HttpRequest,
|
||||
HttpErrorResponse,
|
||||
} from '@angular/common/http';
|
||||
import { from, NEVER, Observable, throwError } from 'rxjs';
|
||||
import { catchError, filter, mergeMap, takeUntil } from 'rxjs/operators';
|
||||
import { LoginStrategy } from '@core/auth';
|
||||
import { IsaLogProvider } from '../providers';
|
||||
import { LogLevel } from '@core/logger';
|
||||
import { injectOnline$ } from '../services/network-status.service';
|
||||
|
||||
@Injectable()
|
||||
export class HttpErrorInterceptor implements HttpInterceptor {
|
||||
readonly offline$ = injectOnline$().pipe(filter((online) => !online));
|
||||
readonly injector = inject(Injector);
|
||||
|
||||
constructor(private _isaLogProvider: IsaLogProvider) {}
|
||||
|
||||
intercept(
|
||||
req: HttpRequest<any>,
|
||||
next: HttpHandler,
|
||||
): Observable<HttpEvent<any>> {
|
||||
return next.handle(req).pipe(
|
||||
takeUntil(this.offline$),
|
||||
catchError((error: HttpErrorResponse, caught: any) =>
|
||||
this.handleError(error),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
handleError(error: HttpErrorResponse): Observable<any> {
|
||||
if (error.status === 401) {
|
||||
const strategy = this.injector.get(LoginStrategy);
|
||||
|
||||
return from(strategy.login('Sie sind nicht mehr angemeldet')).pipe(
|
||||
mergeMap(() => NEVER),
|
||||
);
|
||||
}
|
||||
|
||||
if (!error.url.endsWith('/isa/logging')) {
|
||||
this._isaLogProvider.log(LogLevel.ERROR, 'Http Error', error);
|
||||
}
|
||||
|
||||
return throwError(error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,9 @@ import {
|
||||
} from "@ui/modal";
|
||||
import { IsaLogProvider } from "./isa.log-provider";
|
||||
import { LogLevel } from "@core/logger";
|
||||
import { ZodError } from "zod";
|
||||
import { extractZodErrorMessage } from "@isa/common/data-access";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
@Injectable({ providedIn: "root" })
|
||||
export class IsaErrorHandler implements ErrorHandler {
|
||||
@@ -28,7 +31,7 @@ export class IsaErrorHandler implements ErrorHandler {
|
||||
}
|
||||
|
||||
if (error instanceof HttpErrorResponse && error?.status === 401) {
|
||||
await this._modal
|
||||
await firstValueFrom(this._modal
|
||||
.open({
|
||||
content: UiDialogModalComponent,
|
||||
title: "Sitzung abgelaufen",
|
||||
@@ -41,12 +44,33 @@ export class IsaErrorHandler implements ErrorHandler {
|
||||
],
|
||||
} as DialogModel,
|
||||
})
|
||||
.afterClosed$.toPromise();
|
||||
.afterClosed$);
|
||||
|
||||
this._authService.logout();
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle Zod validation errors
|
||||
if (error instanceof ZodError) {
|
||||
const zodErrorMessage = extractZodErrorMessage(error);
|
||||
|
||||
await firstValueFrom(this._modal
|
||||
.open({
|
||||
content: UiDialogModalComponent,
|
||||
title: "Validierungsfehler",
|
||||
data: {
|
||||
handleCommand: false,
|
||||
content: `Die eingegebenen Daten sind ungültig:\n\n${zodErrorMessage}`,
|
||||
actions: [
|
||||
{ command: "CLOSE", selected: true, label: "OK" },
|
||||
],
|
||||
} as DialogModel,
|
||||
})
|
||||
.afterClosed$);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this._isaLogProvider.log(LogLevel.ERROR, "Client Error", error);
|
||||
} catch (logError) {
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Logger, LogLevel } from '@core/logger';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { debounceTime, switchMap, takeUntil } from 'rxjs/operators';
|
||||
import { RootState } from './root.state';
|
||||
import packageInfo from 'packageJson';
|
||||
import { environment } from '../../environments/environment';
|
||||
import { Subject } from 'rxjs';
|
||||
import { AuthService } from '@core/auth';
|
||||
import { injectStorage, UserStorageProvider } from '@isa/core/storage';
|
||||
import { isEqual } from 'lodash';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class RootStateService {
|
||||
static LOCAL_STORAGE_KEY = 'ISA_APP_INITIALSTATE';
|
||||
|
||||
#storage = injectStorage(UserStorageProvider);
|
||||
|
||||
private _cancelSave = new Subject<void>();
|
||||
|
||||
constructor(
|
||||
private readonly _authService: AuthService,
|
||||
private _logger: Logger,
|
||||
private _store: Store,
|
||||
) {
|
||||
if (!environment.production) {
|
||||
console.log(
|
||||
'Die UserState kann in der Konsole mit der Funktion "clearUserState()" geleert werden.',
|
||||
);
|
||||
}
|
||||
|
||||
window['clearUserState'] = () => {
|
||||
this.clear();
|
||||
};
|
||||
}
|
||||
|
||||
async init() {
|
||||
await this.load();
|
||||
this._store.dispatch({
|
||||
type: 'HYDRATE',
|
||||
payload: RootStateService.LoadFromLocalStorage(),
|
||||
});
|
||||
this.initSave();
|
||||
}
|
||||
|
||||
initSave() {
|
||||
this._store
|
||||
.select((state) => state)
|
||||
.pipe(takeUntil(this._cancelSave), debounceTime(1000))
|
||||
.subscribe((state) => {
|
||||
const data = {
|
||||
...state,
|
||||
version: packageInfo.version,
|
||||
sub: this._authService.getClaimByKey('sub'),
|
||||
};
|
||||
RootStateService.SaveToLocalStorageRaw(JSON.stringify(data));
|
||||
return this.#storage.set('state', data);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the initial state from local storage and returns true/false if state was changed
|
||||
*/
|
||||
async load(): Promise<boolean> {
|
||||
try {
|
||||
const res = await this.#storage.get('state');
|
||||
|
||||
const storageContent = RootStateService.LoadFromLocalStorageRaw();
|
||||
|
||||
if (res) {
|
||||
RootStateService.SaveToLocalStorageRaw(JSON.stringify(res));
|
||||
}
|
||||
|
||||
if (!isEqual(res, storageContent)) {
|
||||
return true;
|
||||
}
|
||||
} catch (error) {
|
||||
this._logger.log(LogLevel.ERROR, error);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async clear() {
|
||||
try {
|
||||
this._cancelSave.next();
|
||||
await this.#storage.clear('state');
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
RootStateService.RemoveFromLocalStorage();
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
this._logger.log(LogLevel.ERROR, error);
|
||||
}
|
||||
}
|
||||
|
||||
static SaveToLocalStorage(state: RootState) {
|
||||
RootStateService.SaveToLocalStorageRaw(JSON.stringify(state));
|
||||
}
|
||||
|
||||
static SaveToLocalStorageRaw(state: string) {
|
||||
localStorage.setItem(RootStateService.LOCAL_STORAGE_KEY, state);
|
||||
}
|
||||
|
||||
static LoadFromLocalStorage(): RootState {
|
||||
const raw = RootStateService.LoadFromLocalStorageRaw();
|
||||
if (raw) {
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch (error) {
|
||||
console.error('Error parsing local storage:', error);
|
||||
this.RemoveFromLocalStorage();
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
static LoadFromLocalStorageRaw(): string {
|
||||
return localStorage.getItem(RootStateService.LOCAL_STORAGE_KEY);
|
||||
}
|
||||
|
||||
static RemoveFromLocalStorage() {
|
||||
localStorage.removeItem(RootStateService.LOCAL_STORAGE_KEY);
|
||||
}
|
||||
}
|
||||
@@ -142,7 +142,6 @@ export class ApplicationServiceAdapter extends ApplicationService {
|
||||
|
||||
patchProcessData(processId: number, data: Record<string, unknown>): void {
|
||||
const currentProcess = this.#tabService.entityMap()[processId];
|
||||
|
||||
const currentData: TabMetadata =
|
||||
(currentProcess?.metadata?.['process_data'] as TabMetadata) ?? {};
|
||||
|
||||
|
||||
@@ -1,174 +1,178 @@
|
||||
import { coerceArray } from "@angular/cdk/coercion";
|
||||
import { inject, Injectable } from "@angular/core";
|
||||
import { Config } from "@core/config";
|
||||
import { isNullOrUndefined } from "@utils/common";
|
||||
import { AuthConfig, OAuthService } from "angular-oauth2-oidc";
|
||||
import { JwksValidationHandler } from "angular-oauth2-oidc-jwks";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
|
||||
/**
|
||||
* Storage key for the URL to redirect to after login
|
||||
*/
|
||||
const REDIRECT_URL_KEY = "auth_redirect_url";
|
||||
|
||||
@Injectable({
|
||||
providedIn: "root",
|
||||
})
|
||||
export class AuthService {
|
||||
private readonly _initialized = new BehaviorSubject<boolean>(false);
|
||||
get initialized$() {
|
||||
return this._initialized.asObservable();
|
||||
}
|
||||
|
||||
private _authConfig: AuthConfig;
|
||||
constructor(
|
||||
private _config: Config,
|
||||
private readonly _oAuthService: OAuthService,
|
||||
) {
|
||||
this._oAuthService.events?.subscribe((event) => {
|
||||
if (event.type === "token_received") {
|
||||
console.log(
|
||||
"SSO Token Expiration:",
|
||||
new Date(this._oAuthService.getAccessTokenExpiration()),
|
||||
);
|
||||
|
||||
// Handle redirect after successful authentication
|
||||
setTimeout(() => {
|
||||
const redirectUrl = this._getAndClearRedirectUrl();
|
||||
if (redirectUrl) {
|
||||
window.location.href = redirectUrl;
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async init() {
|
||||
if (this._initialized.getValue()) {
|
||||
throw new Error("AuthService is already initialized");
|
||||
}
|
||||
|
||||
this._authConfig = this._config.get("@core/auth");
|
||||
|
||||
this._authConfig.redirectUri = window.location.origin;
|
||||
|
||||
this._authConfig.silentRefreshRedirectUri =
|
||||
window.location.origin + "/silent-refresh.html";
|
||||
this._authConfig.useSilentRefresh = true;
|
||||
|
||||
this._oAuthService.configure(this._authConfig);
|
||||
this._oAuthService.tokenValidationHandler = new JwksValidationHandler();
|
||||
|
||||
this._oAuthService.setupAutomaticSilentRefresh();
|
||||
|
||||
await this._oAuthService.loadDiscoveryDocumentAndTryLogin();
|
||||
|
||||
this._initialized.next(true);
|
||||
}
|
||||
|
||||
isAuthenticated() {
|
||||
return this.isIdTokenValid();
|
||||
}
|
||||
|
||||
isIdTokenValid() {
|
||||
console.log(
|
||||
"ID Token Expiration:",
|
||||
new Date(this._oAuthService.getIdTokenExpiration()),
|
||||
);
|
||||
return this._oAuthService.hasValidIdToken();
|
||||
}
|
||||
|
||||
isAccessTokenValid() {
|
||||
console.log(
|
||||
"ACCESS Token Expiration:",
|
||||
new Date(this._oAuthService.getAccessTokenExpiration()),
|
||||
);
|
||||
return this._oAuthService.hasValidAccessToken();
|
||||
}
|
||||
|
||||
getToken() {
|
||||
return this._oAuthService.getAccessToken();
|
||||
}
|
||||
|
||||
getClaims() {
|
||||
const token = this._oAuthService.getAccessToken();
|
||||
return this.parseJwt(token);
|
||||
}
|
||||
|
||||
getClaimByKey(key: string) {
|
||||
const claims = this.getClaims();
|
||||
if (isNullOrUndefined(claims)) {
|
||||
return null;
|
||||
}
|
||||
return claims[key];
|
||||
}
|
||||
|
||||
parseJwt(token: string) {
|
||||
if (isNullOrUndefined(token)) {
|
||||
return null;
|
||||
}
|
||||
const base64Url = token.split(".")[1];
|
||||
const base64 = base64Url.replace(/-/g, "+").replace(/_/g, "/");
|
||||
|
||||
const encoded = window.atob(base64);
|
||||
return JSON.parse(encoded);
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the URL to redirect to after successful login
|
||||
*/
|
||||
_saveRedirectUrl(): void {
|
||||
localStorage.setItem(REDIRECT_URL_KEY, window.location.href);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets and clears the saved redirect URL
|
||||
*/
|
||||
_getAndClearRedirectUrl(): string | null {
|
||||
const url = localStorage.getItem(REDIRECT_URL_KEY);
|
||||
localStorage.removeItem(REDIRECT_URL_KEY);
|
||||
return url;
|
||||
}
|
||||
|
||||
login() {
|
||||
this._saveRedirectUrl();
|
||||
this._oAuthService.initLoginFlow();
|
||||
}
|
||||
|
||||
setKeyCardToken(token: string) {
|
||||
this._oAuthService.customQueryParams = {
|
||||
temp_token: token,
|
||||
};
|
||||
}
|
||||
|
||||
async logout() {
|
||||
await this._oAuthService.revokeTokenAndLogout();
|
||||
}
|
||||
|
||||
hasRole(role: string | string[]) {
|
||||
const roles = coerceArray(role);
|
||||
|
||||
const userRoles = this.getClaimByKey("role");
|
||||
|
||||
if (isNullOrUndefined(userRoles)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return roles.every((r) => userRoles.includes(r));
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
try {
|
||||
if (
|
||||
this._authConfig.responseType.includes("code") &&
|
||||
this._authConfig.scope.includes("offline_access")
|
||||
) {
|
||||
await this._oAuthService.refreshToken();
|
||||
} else {
|
||||
await this._oAuthService.silentRefresh();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
import { coerceArray } from '@angular/cdk/coercion';
|
||||
import { inject, Injectable } from '@angular/core';
|
||||
import { Config } from '@core/config';
|
||||
import { isNullOrUndefined } from '@utils/common';
|
||||
import { AuthConfig, OAuthService } from 'angular-oauth2-oidc';
|
||||
import { JwksValidationHandler } from 'angular-oauth2-oidc-jwks';
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
|
||||
/**
|
||||
* Storage key for the URL to redirect to after login
|
||||
*/
|
||||
const REDIRECT_URL_KEY = 'auth_redirect_url';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class AuthService {
|
||||
private readonly _initialized = new BehaviorSubject<boolean>(false);
|
||||
get initialized$() {
|
||||
return this._initialized.asObservable();
|
||||
}
|
||||
|
||||
private _authConfig: AuthConfig;
|
||||
constructor(
|
||||
private _config: Config,
|
||||
private readonly _oAuthService: OAuthService,
|
||||
) {
|
||||
this._oAuthService.events?.subscribe((event) => {
|
||||
if (event.type === 'token_received') {
|
||||
console.log(
|
||||
'SSO Token Expiration:',
|
||||
new Date(this._oAuthService.getAccessTokenExpiration()),
|
||||
);
|
||||
|
||||
// Handle redirect after successful authentication
|
||||
setTimeout(() => {
|
||||
const redirectUrl = this._getAndClearRedirectUrl();
|
||||
if (redirectUrl) {
|
||||
window.location.href = redirectUrl;
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async init() {
|
||||
if (this._initialized.getValue()) {
|
||||
throw new Error('AuthService is already initialized');
|
||||
}
|
||||
|
||||
this._authConfig = this._config.get('@core/auth');
|
||||
|
||||
this._authConfig.redirectUri = window.location.origin;
|
||||
|
||||
this._authConfig.silentRefreshRedirectUri =
|
||||
window.location.origin + '/silent-refresh.html';
|
||||
this._authConfig.useSilentRefresh = true;
|
||||
|
||||
this._oAuthService.configure(this._authConfig);
|
||||
this._oAuthService.tokenValidationHandler = new JwksValidationHandler();
|
||||
|
||||
this._oAuthService.setupAutomaticSilentRefresh();
|
||||
|
||||
await this._oAuthService.loadDiscoveryDocumentAndTryLogin();
|
||||
|
||||
if (!this._oAuthService.getAccessToken()) {
|
||||
throw new Error('No access token. User is not authenticated.');
|
||||
}
|
||||
|
||||
this._initialized.next(true);
|
||||
}
|
||||
|
||||
isAuthenticated() {
|
||||
return this.isIdTokenValid();
|
||||
}
|
||||
|
||||
isIdTokenValid() {
|
||||
console.log(
|
||||
'ID Token Expiration:',
|
||||
new Date(this._oAuthService.getIdTokenExpiration()),
|
||||
);
|
||||
return this._oAuthService.hasValidIdToken();
|
||||
}
|
||||
|
||||
isAccessTokenValid() {
|
||||
console.log(
|
||||
'ACCESS Token Expiration:',
|
||||
new Date(this._oAuthService.getAccessTokenExpiration()),
|
||||
);
|
||||
return this._oAuthService.hasValidAccessToken();
|
||||
}
|
||||
|
||||
getToken() {
|
||||
return this._oAuthService.getAccessToken();
|
||||
}
|
||||
|
||||
getClaims() {
|
||||
const token = this._oAuthService.getAccessToken();
|
||||
return this.parseJwt(token);
|
||||
}
|
||||
|
||||
getClaimByKey(key: string) {
|
||||
const claims = this.getClaims();
|
||||
if (isNullOrUndefined(claims)) {
|
||||
return null;
|
||||
}
|
||||
return claims[key];
|
||||
}
|
||||
|
||||
parseJwt(token: string) {
|
||||
if (isNullOrUndefined(token)) {
|
||||
return null;
|
||||
}
|
||||
const base64Url = token.split('.')[1];
|
||||
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
|
||||
|
||||
const encoded = window.atob(base64);
|
||||
return JSON.parse(encoded);
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the URL to redirect to after successful login
|
||||
*/
|
||||
_saveRedirectUrl(): void {
|
||||
localStorage.setItem(REDIRECT_URL_KEY, window.location.href);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets and clears the saved redirect URL
|
||||
*/
|
||||
_getAndClearRedirectUrl(): string | null {
|
||||
const url = localStorage.getItem(REDIRECT_URL_KEY);
|
||||
localStorage.removeItem(REDIRECT_URL_KEY);
|
||||
return url;
|
||||
}
|
||||
|
||||
login() {
|
||||
this._saveRedirectUrl();
|
||||
this._oAuthService.initLoginFlow();
|
||||
}
|
||||
|
||||
setKeyCardToken(token: string) {
|
||||
this._oAuthService.customQueryParams = {
|
||||
temp_token: token,
|
||||
};
|
||||
}
|
||||
|
||||
async logout() {
|
||||
await this._oAuthService.revokeTokenAndLogout();
|
||||
}
|
||||
|
||||
hasRole(role: string | string[]) {
|
||||
const roles = coerceArray(role);
|
||||
|
||||
const userRoles = this.getClaimByKey('role');
|
||||
|
||||
if (isNullOrUndefined(userRoles)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return roles.every((r) => userRoles.includes(r));
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
try {
|
||||
if (
|
||||
this._authConfig.responseType.includes('code') &&
|
||||
this._authConfig.scope.includes('offline_access')
|
||||
) {
|
||||
await this._oAuthService.refreshToken();
|
||||
} else {
|
||||
await this._oAuthService.silentRefresh();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,18 @@
|
||||
<div class="notification-list scroll-bar">
|
||||
@for (notification of notifications; track notification) {
|
||||
<modal-notifications-list-item [item]="notification" (itemSelected)="itemSelected($event)"></modal-notifications-list-item>
|
||||
<modal-notifications-list-item
|
||||
[item]="notification"
|
||||
(itemSelected)="itemSelected($event)"
|
||||
></modal-notifications-list-item>
|
||||
<hr />
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<a class="cta-primary" [routerLink]="['/filiale/remission/create']" (click)="navigated.emit()">Zur Remission</a>
|
||||
<a
|
||||
class="cta-primary"
|
||||
[routerLink]="remissionPath()"
|
||||
(click)="navigated.emit()"
|
||||
>Zur Remission</a
|
||||
>
|
||||
</div>
|
||||
|
||||
@@ -1,35 +1,55 @@
|
||||
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output, inject } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { PickupShelfInNavigationService } from '@shared/services/navigation';
|
||||
import { UiFilter } from '@ui/filter';
|
||||
import { MessageBoardItemDTO } from '@hub/notifications';
|
||||
|
||||
@Component({
|
||||
selector: 'modal-notifications-remission-group',
|
||||
templateUrl: 'notifications-remission-group.component.html',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
standalone: false,
|
||||
})
|
||||
export class ModalNotificationsRemissionGroupComponent {
|
||||
private _pickupShelfInNavigationService = inject(PickupShelfInNavigationService);
|
||||
|
||||
@Input()
|
||||
notifications: MessageBoardItemDTO[];
|
||||
|
||||
@Output()
|
||||
navigated = new EventEmitter<void>();
|
||||
|
||||
constructor(private _router: Router) {}
|
||||
|
||||
itemSelected(item: MessageBoardItemDTO) {
|
||||
const defaultNav = this._pickupShelfInNavigationService.listRoute();
|
||||
const queryParams = UiFilter.getQueryParamsFromQueryTokenDTO(item.queryToken);
|
||||
this._router.navigate(defaultNav.path, {
|
||||
queryParams: {
|
||||
...defaultNav.queryParams,
|
||||
...queryParams,
|
||||
},
|
||||
});
|
||||
this.navigated.emit();
|
||||
}
|
||||
}
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
EventEmitter,
|
||||
Input,
|
||||
Output,
|
||||
inject,
|
||||
linkedSignal,
|
||||
} from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { PickupShelfInNavigationService } from '@shared/services/navigation';
|
||||
import { UiFilter } from '@ui/filter';
|
||||
import { MessageBoardItemDTO } from '@hub/notifications';
|
||||
import { TabService } from '@isa/core/tabs';
|
||||
|
||||
@Component({
|
||||
selector: 'modal-notifications-remission-group',
|
||||
templateUrl: 'notifications-remission-group.component.html',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
standalone: false,
|
||||
})
|
||||
export class ModalNotificationsRemissionGroupComponent {
|
||||
tabService = inject(TabService);
|
||||
private _pickupShelfInNavigationService = inject(
|
||||
PickupShelfInNavigationService,
|
||||
);
|
||||
|
||||
@Input()
|
||||
notifications: MessageBoardItemDTO[];
|
||||
|
||||
@Output()
|
||||
navigated = new EventEmitter<void>();
|
||||
|
||||
remissionPath = linkedSignal(() => [
|
||||
'/',
|
||||
this.tabService.activatedTab()?.id || Date.now(),
|
||||
'remission',
|
||||
]);
|
||||
|
||||
constructor(private _router: Router) {}
|
||||
|
||||
itemSelected(item: MessageBoardItemDTO) {
|
||||
const defaultNav = this._pickupShelfInNavigationService.listRoute();
|
||||
const queryParams = UiFilter.getQueryParamsFromQueryTokenDTO(
|
||||
item.queryToken,
|
||||
);
|
||||
this._router.navigate(defaultNav.path, {
|
||||
queryParams: {
|
||||
...defaultNav.queryParams,
|
||||
...queryParams,
|
||||
},
|
||||
});
|
||||
this.navigated.emit();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,33 +1,48 @@
|
||||
import { ItemType, PriceDTO, PriceValueDTO, VATValueDTO } from '@generated/swagger/checkout-api';
|
||||
import { OrderType, PurchaseOption } from './store';
|
||||
|
||||
export const PURCHASE_OPTIONS: PurchaseOption[] = [
|
||||
'in-store',
|
||||
'pickup',
|
||||
'delivery',
|
||||
'dig-delivery',
|
||||
'b2b-delivery',
|
||||
'download',
|
||||
];
|
||||
|
||||
export const DELIVERY_PURCHASE_OPTIONS: PurchaseOption[] = ['delivery', 'dig-delivery', 'b2b-delivery'];
|
||||
|
||||
export const PURCHASE_OPTION_TO_ORDER_TYPE: { [purchaseOption: string]: OrderType } = {
|
||||
'in-store': 'Rücklage',
|
||||
pickup: 'Abholung',
|
||||
delivery: 'Versand',
|
||||
'dig-delivery': 'Versand',
|
||||
'b2b-delivery': 'Versand',
|
||||
};
|
||||
|
||||
export const GIFT_CARD_TYPE = 66560 as ItemType;
|
||||
|
||||
export const DEFAULT_PRICE_DTO: PriceDTO = { value: { value: undefined }, vat: { vatType: 0 } };
|
||||
|
||||
export const DEFAULT_PRICE_VALUE: PriceValueDTO = { value: 0, currency: 'EUR' };
|
||||
|
||||
export const DEFAULT_VAT_VALUE: VATValueDTO = { value: 0 };
|
||||
|
||||
export const GIFT_CARD_MAX_PRICE = 200;
|
||||
|
||||
export const PRICE_PATTERN = /^\d+(,\d{1,2})?$/;
|
||||
import {
|
||||
ItemType,
|
||||
PriceDTO,
|
||||
PriceValueDTO,
|
||||
VATValueDTO,
|
||||
} from '@generated/swagger/checkout-api';
|
||||
import { PurchaseOption } from './store';
|
||||
import { OrderType } from '@isa/checkout/data-access';
|
||||
|
||||
export const PURCHASE_OPTIONS: PurchaseOption[] = [
|
||||
'in-store',
|
||||
'pickup',
|
||||
'delivery',
|
||||
'dig-delivery',
|
||||
'b2b-delivery',
|
||||
'download',
|
||||
];
|
||||
|
||||
export const DELIVERY_PURCHASE_OPTIONS: PurchaseOption[] = [
|
||||
'delivery',
|
||||
'dig-delivery',
|
||||
'b2b-delivery',
|
||||
];
|
||||
|
||||
export const PURCHASE_OPTION_TO_ORDER_TYPE: {
|
||||
[purchaseOption: string]: OrderType;
|
||||
} = {
|
||||
'in-store': 'Rücklage',
|
||||
'pickup': 'Abholung',
|
||||
'delivery': 'Versand',
|
||||
'dig-delivery': 'Versand',
|
||||
'b2b-delivery': 'Versand',
|
||||
};
|
||||
|
||||
export const GIFT_CARD_TYPE = 66560 as ItemType;
|
||||
|
||||
export const DEFAULT_PRICE_DTO: PriceDTO = {
|
||||
value: { value: undefined },
|
||||
vat: { vatType: 0 },
|
||||
};
|
||||
|
||||
export const DEFAULT_PRICE_VALUE: PriceValueDTO = { value: 0, currency: 'EUR' };
|
||||
|
||||
export const DEFAULT_VAT_VALUE: VATValueDTO = { value: 0 };
|
||||
|
||||
export const GIFT_CARD_MAX_PRICE = 200;
|
||||
|
||||
export const PRICE_PATTERN = /^\d+(,\d{1,2})?$/;
|
||||
|
||||
@@ -1,185 +1,268 @@
|
||||
<div class="flex flex-row">
|
||||
<div class="shared-purchase-options-list-item__thumbnail w-16 max-h-28">
|
||||
<img class="rounded shadow-card max-w-full max-h-full" [src]="product?.ean | productImage" [alt]="product?.name" />
|
||||
</div>
|
||||
<div class="shared-purchase-options-list-item__product grow ml-4">
|
||||
<div class="shared-purchase-options-list-item__contributors font-bold">
|
||||
{{ product?.contributors }}
|
||||
</div>
|
||||
<div class="shared-purchase-options-list-item__name font-bold h-12" sharedScaleContent>
|
||||
{{ product?.name }}
|
||||
</div>
|
||||
<div class="shared-purchase-options-list-item__format flex flex-row items-center">
|
||||
<shared-icon [icon]="product?.format"></shared-icon>
|
||||
<span class="ml-2 font-bold">{{ product?.formatDetail }}</span>
|
||||
</div>
|
||||
<div class="shared-purchase-options-list-item__manufacturer-and-ean">
|
||||
{{ product?.manufacturer }}
|
||||
@if (product?.manufacturer && product?.ean) {
|
||||
<span>|</span>
|
||||
}
|
||||
{{ product?.ean }}
|
||||
</div>
|
||||
<div class="shared-purchase-options-list-item__volume-and-publication-date">
|
||||
{{ product?.volume }}
|
||||
@if (product?.volume && product?.publicationDate) {
|
||||
<span>|</span>
|
||||
}
|
||||
{{ product?.publicationDate | date: 'dd. MMMM yyyy' }}
|
||||
</div>
|
||||
<div class="shared-purchase-options-list-item__availabilities mt-3 grid grid-flow-row gap-2 justify-start">
|
||||
@if ((availabilities$ | async)?.length) {
|
||||
<div class="whitespace-nowrap self-center">Verfügbar als</div>
|
||||
}
|
||||
@for (availability of availabilities$ | async; track availability) {
|
||||
<div class="grid grid-flow-col gap-4 justify-start">
|
||||
<div
|
||||
class="shared-purchase-options-list-item__availability grid grid-flow-col gap-2 items-center"
|
||||
[attr.data-option]="availability.purchaseOption"
|
||||
>
|
||||
@switch (availability.purchaseOption) {
|
||||
@case ('delivery') {
|
||||
<shared-icon icon="isa-truck" [size]="22"></shared-icon>
|
||||
{{ availability.data.estimatedDelivery?.start | date: 'EE dd.MM.' }}
|
||||
-
|
||||
{{ availability.data.estimatedDelivery?.stop | date: 'EE dd.MM.' }}
|
||||
}
|
||||
@case ('dig-delivery') {
|
||||
<shared-icon icon="isa-truck" [size]="22"></shared-icon>
|
||||
{{ availability.data.estimatedDelivery?.start | date: 'EE dd.MM.' }}
|
||||
-
|
||||
{{ availability.data.estimatedDelivery?.stop | date: 'EE dd.MM.' }}
|
||||
}
|
||||
@case ('b2b-delivery') {
|
||||
<shared-icon icon="isa-b2b-truck" [size]="24"></shared-icon>
|
||||
{{ availability.data.estimatedShippingDate | date: 'dd. MMMM yyyy' }}
|
||||
}
|
||||
@case ('pickup') {
|
||||
<shared-icon
|
||||
class="cursor-pointer"
|
||||
#uiOverlayTrigger="uiOverlayTrigger"
|
||||
[uiOverlayTrigger]="orderDeadlineTooltip"
|
||||
[class.tooltip-active]="uiOverlayTrigger.opened"
|
||||
icon="isa-box-out"
|
||||
[size]="18"
|
||||
></shared-icon>
|
||||
{{ availability.data.estimatedShippingDate | date: 'dd. MMMM yyyy' }}
|
||||
<ui-tooltip
|
||||
#orderDeadlineTooltip
|
||||
yPosition="above"
|
||||
xPosition="after"
|
||||
[yOffset]="-12"
|
||||
[xOffset]="4"
|
||||
[warning]="true"
|
||||
[closeable]="true"
|
||||
>
|
||||
<b>{{ availability.data?.orderDeadline | orderDeadline }}</b>
|
||||
</ui-tooltip>
|
||||
}
|
||||
@case ('in-store') {
|
||||
<shared-icon icon="isa-shopping-bag" [size]="18"></shared-icon>
|
||||
{{ availability.data.inStock }}x
|
||||
@if (isEVT) {
|
||||
ab {{ isEVT | date: 'dd. MMMM yyyy' }}
|
||||
} @else {
|
||||
ab sofort
|
||||
}
|
||||
}
|
||||
@case ('download') {
|
||||
<shared-icon icon="isa-download" [size]="22"></shared-icon>
|
||||
Download
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="shared-purchase-options-list-item__price text-right ml-4 flex flex-col items-end">
|
||||
<div class="shared-purchase-options-list-item__price-value font-bold text-xl flex flex-row items-center">
|
||||
<div class="relative flex flex-row justify-end items-start">
|
||||
@if (canEditVat$ | async) {
|
||||
<ui-select
|
||||
class="w-[6.5rem] min-h-[3.4375rem] p-4 rounded-card border border-solid border-[#AEB7C1] mr-4"
|
||||
tabindex="-1"
|
||||
[formControl]="manualVatFormControl"
|
||||
[defaultLabel]="'MwSt'"
|
||||
>
|
||||
@for (vat of vats$ | async; track vat) {
|
||||
<ui-select-option [label]="vat.name + '%'" [value]="vat.vatType"></ui-select-option>
|
||||
}
|
||||
</ui-select>
|
||||
}
|
||||
@if (canEditPrice$ | async) {
|
||||
<shared-input-control
|
||||
[class.ml-6]="priceFormControl?.invalid && priceFormControl?.dirty"
|
||||
>
|
||||
<shared-input-control-indicator>
|
||||
@if (priceFormControl?.invalid && priceFormControl?.dirty) {
|
||||
<shared-icon icon="mat-info"></shared-icon>
|
||||
}
|
||||
</shared-input-control-indicator>
|
||||
<input
|
||||
[uiOverlayTrigger]="giftCardTooltip"
|
||||
triggerOn="none"
|
||||
#quantityInput
|
||||
#priceOverlayTrigger="uiOverlayTrigger"
|
||||
sharedInputControlInput
|
||||
type="string"
|
||||
class="w-24"
|
||||
[formControl]="priceFormControl"
|
||||
placeholder="00,00"
|
||||
(sharedOnInit)="onPriceInputInit(quantityInput, priceOverlayTrigger)"
|
||||
sharedNumberValue
|
||||
/>
|
||||
<shared-input-control-suffix>EUR</shared-input-control-suffix>
|
||||
<shared-input-control-error error="required">Preis ist ungültig</shared-input-control-error>
|
||||
<shared-input-control-error error="pattern">Preis ist ungültig</shared-input-control-error>
|
||||
<shared-input-control-error error="min">Preis ist ungültig</shared-input-control-error>
|
||||
<shared-input-control-error error="max">Preis ist ungültig</shared-input-control-error>
|
||||
</shared-input-control>
|
||||
} @else {
|
||||
{{ priceValue$ | async | currency: 'EUR' : 'code' }}
|
||||
}
|
||||
|
||||
<ui-tooltip [warning]="true" xPosition="after" yPosition="below" [xOffset]="-55" [yOffset]="18" [closeable]="true" #giftCardTooltip>
|
||||
Tragen Sie hier den
|
||||
<br />
|
||||
Gutscheinbetrag ein.
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<ui-quantity-dropdown class="mt-2" [formControl]="quantityFormControl" [range]="maxSelectableQuantity$ | async"></ui-quantity-dropdown>
|
||||
<div class="pt-7">
|
||||
@if ((canAddResult$ | async)?.canAdd) {
|
||||
<input
|
||||
class="fancy-checkbox"
|
||||
[class.checked]="selectedFormControl?.value"
|
||||
[formControl]="selectedFormControl"
|
||||
type="checkbox"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (canAddResult$ | async; as canAddResult) {
|
||||
@if (!canAddResult.canAdd) {
|
||||
<span class="inline-block font-bold text-[#BE8100] mt-[14px] max-w-[19rem]">
|
||||
{{ canAddResult.message }}
|
||||
</span>
|
||||
}
|
||||
}
|
||||
|
||||
@if (showMaxAvailableQuantity$ | async) {
|
||||
<span class="font-bold text-[#BE8100] mt-[14px]">
|
||||
{{ (availability$ | async)?.inStock }} Exemplare sofort lieferbar
|
||||
</span>
|
||||
}
|
||||
@if (showNotAvailable$ | async) {
|
||||
<span class="font-bold text-[#BE8100] mt-[14px]">Derzeit nicht bestellbar</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-row">
|
||||
<div class="w-16"></div>
|
||||
<div class="grow shared-purchase-options-list-item__availabilities"></div>
|
||||
</div>
|
||||
<div class="flex flex-row">
|
||||
<div class="shared-purchase-options-list-item__thumbnail w-16 max-h-28">
|
||||
<img
|
||||
class="rounded shadow-card max-w-full max-h-full"
|
||||
[src]="product?.ean | productImage"
|
||||
[alt]="product?.name"
|
||||
/>
|
||||
</div>
|
||||
<div class="shared-purchase-options-list-item__product grow ml-4">
|
||||
<div class="shared-purchase-options-list-item__contributors font-bold">
|
||||
{{ product?.contributors }}
|
||||
</div>
|
||||
<div
|
||||
class="shared-purchase-options-list-item__name font-bold h-12"
|
||||
sharedScaleContent
|
||||
>
|
||||
{{ product?.name }}
|
||||
</div>
|
||||
<div
|
||||
class="shared-purchase-options-list-item__format flex flex-row items-center"
|
||||
>
|
||||
<shared-icon [icon]="product?.format"></shared-icon>
|
||||
<span class="ml-2 font-bold">{{ product?.formatDetail }}</span>
|
||||
</div>
|
||||
<div class="shared-purchase-options-list-item__manufacturer-and-ean">
|
||||
{{ product?.manufacturer }}
|
||||
@if (product?.manufacturer && product?.ean) {
|
||||
<span>|</span>
|
||||
}
|
||||
{{ product?.ean }}
|
||||
</div>
|
||||
<div class="shared-purchase-options-list-item__volume-and-publication-date">
|
||||
{{ product?.volume }}
|
||||
@if (product?.volume && product?.publicationDate) {
|
||||
<span>|</span>
|
||||
}
|
||||
{{ product?.publicationDate | date: 'dd. MMMM yyyy' }}
|
||||
</div>
|
||||
<div
|
||||
class="shared-purchase-options-list-item__availabilities mt-3 grid grid-flow-row gap-2 justify-start"
|
||||
>
|
||||
@if ((availabilities$ | async)?.length) {
|
||||
<div class="whitespace-nowrap self-center">Verfügbar als</div>
|
||||
}
|
||||
@for (availability of availabilities$ | async; track availability) {
|
||||
<div class="grid grid-flow-col gap-4 justify-start">
|
||||
<div
|
||||
class="shared-purchase-options-list-item__availability grid grid-flow-col gap-2 items-center"
|
||||
[attr.data-option]="availability.purchaseOption"
|
||||
>
|
||||
@switch (availability.purchaseOption) {
|
||||
@case ('delivery') {
|
||||
<shared-icon icon="isa-truck" [size]="22"></shared-icon>
|
||||
{{
|
||||
availability.data.estimatedDelivery?.start | date: 'EE dd.MM.'
|
||||
}}
|
||||
-
|
||||
{{
|
||||
availability.data.estimatedDelivery?.stop | date: 'EE dd.MM.'
|
||||
}}
|
||||
}
|
||||
@case ('dig-delivery') {
|
||||
<shared-icon icon="isa-truck" [size]="22"></shared-icon>
|
||||
{{
|
||||
availability.data.estimatedDelivery?.start | date: 'EE dd.MM.'
|
||||
}}
|
||||
-
|
||||
{{
|
||||
availability.data.estimatedDelivery?.stop | date: 'EE dd.MM.'
|
||||
}}
|
||||
}
|
||||
@case ('b2b-delivery') {
|
||||
<shared-icon icon="isa-b2b-truck" [size]="24"></shared-icon>
|
||||
{{
|
||||
availability.data.estimatedShippingDate
|
||||
| date: 'dd. MMMM yyyy'
|
||||
}}
|
||||
}
|
||||
@case ('pickup') {
|
||||
<shared-icon
|
||||
class="cursor-pointer"
|
||||
#uiOverlayTrigger="uiOverlayTrigger"
|
||||
[uiOverlayTrigger]="orderDeadlineTooltip"
|
||||
[class.tooltip-active]="uiOverlayTrigger.opened"
|
||||
icon="isa-box-out"
|
||||
[size]="18"
|
||||
></shared-icon>
|
||||
{{
|
||||
availability.data.estimatedShippingDate
|
||||
| date: 'dd. MMMM yyyy'
|
||||
}}
|
||||
<ui-tooltip
|
||||
#orderDeadlineTooltip
|
||||
yPosition="above"
|
||||
xPosition="after"
|
||||
[yOffset]="-12"
|
||||
[xOffset]="4"
|
||||
[warning]="true"
|
||||
[closeable]="true"
|
||||
>
|
||||
<b>{{ availability.data?.orderDeadline | orderDeadline }}</b>
|
||||
</ui-tooltip>
|
||||
}
|
||||
@case ('in-store') {
|
||||
<shared-icon icon="isa-shopping-bag" [size]="18"></shared-icon>
|
||||
{{ availability.data.inStock }}x
|
||||
@if (isEVT) {
|
||||
ab {{ isEVT | date: 'dd. MMMM yyyy' }}
|
||||
} @else {
|
||||
ab sofort
|
||||
}
|
||||
}
|
||||
@case ('download') {
|
||||
<shared-icon icon="isa-download" [size]="22"></shared-icon>
|
||||
Download
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="shared-purchase-options-list-item__price text-right ml-4 flex flex-col items-end"
|
||||
>
|
||||
<div
|
||||
class="shared-purchase-options-list-item__price-value font-bold text-xl flex flex-row items-center"
|
||||
>
|
||||
<div class="relative flex flex-row justify-end items-start">
|
||||
@if (showRedemptionPoints()) {
|
||||
<span class="isa-text-body-2-regular text-isa-neutral-600"
|
||||
>Einlösen für:</span
|
||||
>
|
||||
<span class="ml-2 isa-text-body-2-bold text-isa-secondary-900"
|
||||
>{{
|
||||
redemptionPoints() * quantityFormControl.value
|
||||
}}
|
||||
Lesepunkte</span
|
||||
>
|
||||
} @else {
|
||||
@if (canEditVat$ | async) {
|
||||
<ui-select
|
||||
class="w-[6.5rem] min-h-[3.4375rem] p-4 rounded-card border border-solid border-[#AEB7C1] mr-4"
|
||||
tabindex="-1"
|
||||
[formControl]="manualVatFormControl"
|
||||
[defaultLabel]="'MwSt'"
|
||||
>
|
||||
@for (vat of vats$ | async; track vat) {
|
||||
<ui-select-option
|
||||
[label]="vat.name + '%'"
|
||||
[value]="vat.vatType"
|
||||
></ui-select-option>
|
||||
}
|
||||
</ui-select>
|
||||
}
|
||||
@if (canEditPrice$ | async) {
|
||||
<shared-input-control
|
||||
[class.ml-6]="
|
||||
priceFormControl?.invalid && priceFormControl?.dirty
|
||||
"
|
||||
>
|
||||
<shared-input-control-indicator>
|
||||
@if (priceFormControl?.invalid && priceFormControl?.dirty) {
|
||||
<shared-icon icon="mat-info"></shared-icon>
|
||||
}
|
||||
</shared-input-control-indicator>
|
||||
<input
|
||||
[uiOverlayTrigger]="giftCardTooltip"
|
||||
triggerOn="none"
|
||||
#quantityInput
|
||||
#priceOverlayTrigger="uiOverlayTrigger"
|
||||
sharedInputControlInput
|
||||
type="string"
|
||||
class="w-24"
|
||||
[formControl]="priceFormControl"
|
||||
placeholder="00,00"
|
||||
(sharedOnInit)="
|
||||
onPriceInputInit(quantityInput, priceOverlayTrigger)
|
||||
"
|
||||
sharedNumberValue
|
||||
/>
|
||||
<shared-input-control-suffix>EUR</shared-input-control-suffix>
|
||||
<shared-input-control-error error="required"
|
||||
>Preis ist ungültig</shared-input-control-error
|
||||
>
|
||||
<shared-input-control-error error="pattern"
|
||||
>Preis ist ungültig</shared-input-control-error
|
||||
>
|
||||
<shared-input-control-error error="min"
|
||||
>Preis ist ungültig</shared-input-control-error
|
||||
>
|
||||
<shared-input-control-error error="max"
|
||||
>Preis ist ungültig</shared-input-control-error
|
||||
>
|
||||
</shared-input-control>
|
||||
} @else {
|
||||
{{ priceValue$ | async | currency: 'EUR' : 'code' }}
|
||||
}
|
||||
}
|
||||
<ui-tooltip
|
||||
[warning]="true"
|
||||
xPosition="after"
|
||||
yPosition="below"
|
||||
[xOffset]="-55"
|
||||
[yOffset]="18"
|
||||
[closeable]="true"
|
||||
#giftCardTooltip
|
||||
>
|
||||
Tragen Sie hier den
|
||||
<br />
|
||||
Gutscheinbetrag ein.
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<ui-quantity-dropdown
|
||||
class="mt-2"
|
||||
[formControl]="quantityFormControl"
|
||||
[range]="maxSelectableQuantity$ | async"
|
||||
data-what="purchase-option-quantity"
|
||||
[attr.data-which]="product?.ean"
|
||||
></ui-quantity-dropdown>
|
||||
<div class="pt-7">
|
||||
@if ((canAddResult$ | async)?.canAdd) {
|
||||
<input
|
||||
class="fancy-checkbox"
|
||||
[class.checked]="selectedFormControl?.value"
|
||||
[formControl]="selectedFormControl"
|
||||
type="checkbox"
|
||||
data-what="purchase-option-selector"
|
||||
[attr.data-which]="product?.ean"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (canAddResult$ | async; as canAddResult) {
|
||||
@if (!canAddResult.canAdd) {
|
||||
<span
|
||||
class="inline-block font-bold text-[#BE8100] mt-[14px] max-w-[19rem]"
|
||||
>
|
||||
{{ canAddResult.message }}
|
||||
</span>
|
||||
}
|
||||
}
|
||||
|
||||
@if (showMaxAvailableQuantity$ | async) {
|
||||
<span class="font-bold text-[#BE8100] mt-[14px]">
|
||||
{{ (availability$ | async)?.inStock }} Exemplare sofort lieferbar
|
||||
</span>
|
||||
}
|
||||
@if (showNotAvailable$ | async) {
|
||||
<span class="font-bold text-[#BE8100] mt-[14px]"
|
||||
>Derzeit nicht bestellbar</span
|
||||
>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-row">
|
||||
<div class="w-16"></div>
|
||||
<div class="grow shared-purchase-options-list-item__availabilities"></div>
|
||||
</div>
|
||||
@if (showLowStockMessage()) {
|
||||
<div
|
||||
class="text-isa-accent-red isa-text-body-2-bold mt-6 flex flex-row items-center gap-2"
|
||||
>
|
||||
<ng-icon name="isaOtherInfo" size="1.5rem"></ng-icon>
|
||||
<div>Geringer Bestand - Artikel holen vor Abschluss</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -1,349 +1,467 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, ChangeDetectionStrategy, Input, OnInit, OnDestroy, OnChanges, SimpleChanges } from '@angular/core';
|
||||
import { FormControl, FormsModule, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||
import { ProductImageModule } from '@cdn/product-image';
|
||||
import { InputControlModule } from '@shared/components/input-control';
|
||||
import { ElementLifecycleModule } from '@shared/directives/element-lifecycle';
|
||||
import { UiCommonModule, UiOverlayTriggerDirective } from '@ui/common';
|
||||
import { UiQuantityDropdownModule } from '@ui/quantity-dropdown';
|
||||
import { UiSpinnerModule } from '@ui/spinner';
|
||||
import { UiTooltipModule } from '@ui/tooltip';
|
||||
import { combineLatest, ReplaySubject, Subscription } from 'rxjs';
|
||||
import { IconComponent } from '@shared/components/icon';
|
||||
import { map, take, shareReplay, startWith, switchMap, withLatestFrom } from 'rxjs/operators';
|
||||
import { GIFT_CARD_MAX_PRICE, PRICE_PATTERN } from '../constants';
|
||||
import { Item, PurchaseOptionsStore, isItemDTO, isShoppingCartItemDTO } from '../store';
|
||||
import { OrderDeadlinePipeModule } from '@shared/pipes/order-deadline';
|
||||
import { UiSelectModule } from '@ui/select';
|
||||
import { KeyValueDTOOfStringAndString } from '@generated/swagger/cat-search-api';
|
||||
import { ScaleContentComponent } from '@shared/components/scale-content';
|
||||
import moment from 'moment';
|
||||
|
||||
@Component({
|
||||
selector: 'shared-purchase-options-list-item',
|
||||
templateUrl: 'purchase-options-list-item.component.html',
|
||||
styleUrls: ['purchase-options-list-item.component.css'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
CommonModule,
|
||||
UiQuantityDropdownModule,
|
||||
UiSelectModule,
|
||||
ProductImageModule,
|
||||
IconComponent,
|
||||
UiSpinnerModule,
|
||||
ReactiveFormsModule,
|
||||
InputControlModule,
|
||||
FormsModule,
|
||||
ElementLifecycleModule,
|
||||
UiTooltipModule,
|
||||
UiCommonModule,
|
||||
ScaleContentComponent,
|
||||
OrderDeadlinePipeModule,
|
||||
],
|
||||
host: { class: 'shared-purchase-options-list-item' },
|
||||
})
|
||||
export class PurchaseOptionsListItemComponent implements OnInit, OnDestroy, OnChanges {
|
||||
private _subscriptions = new Subscription();
|
||||
|
||||
private _itemSubject = new ReplaySubject<Item>(1);
|
||||
|
||||
@Input() item: Item;
|
||||
|
||||
get item$() {
|
||||
return this._itemSubject.asObservable();
|
||||
}
|
||||
|
||||
get product() {
|
||||
return this.item.product;
|
||||
}
|
||||
|
||||
quantityFormControl = new FormControl<number>(null);
|
||||
|
||||
private readonly _giftCardValidators = [
|
||||
Validators.required,
|
||||
Validators.min(1),
|
||||
Validators.max(GIFT_CARD_MAX_PRICE),
|
||||
Validators.pattern(PRICE_PATTERN),
|
||||
];
|
||||
|
||||
private readonly _defaultValidators = [
|
||||
Validators.required,
|
||||
Validators.min(0.01),
|
||||
Validators.max(999.99),
|
||||
Validators.pattern(PRICE_PATTERN),
|
||||
];
|
||||
|
||||
priceFormControl = new FormControl<string>(null);
|
||||
|
||||
manualVatFormControl = new FormControl<string>('', [Validators.required]);
|
||||
|
||||
selectedFormControl = new FormControl<boolean>(false);
|
||||
|
||||
availabilities$ = this.item$.pipe(switchMap((item) => this._store.getAvailabilitiesForItem$(item.id)));
|
||||
|
||||
availability$ = combineLatest([this.item$, this._store.purchaseOption$]).pipe(
|
||||
switchMap(([item, purchaseOption]) => this._store.getAvailabilityWithPurchaseOption$(item.id, purchaseOption)),
|
||||
map((availability) => availability?.data),
|
||||
);
|
||||
|
||||
price$ = this.item$.pipe(switchMap((item) => this._store.getPrice$(item.id)));
|
||||
|
||||
priceValue$ = this.price$.pipe(map((price) => price?.value?.value));
|
||||
|
||||
// Ticket #4074 analog zu Ticket #2244
|
||||
// take(2) um die Response des Katalogpreises und danach um die Response der OLAs abzuwarten
|
||||
// Logik gilt ausschließlich für Archivartikel
|
||||
setManualPrice$ = this.price$.pipe(
|
||||
take(2),
|
||||
map((price) => {
|
||||
// Logik nur beim Hinzufügen über Kaufoptionen, da über Ändern im Warenkorb die Info fehlt ob das jeweilige ShoppingCartItem ein Archivartikel ist oder nicht
|
||||
const features = this.item?.features as KeyValueDTOOfStringAndString[];
|
||||
if (!!features && Array.isArray(features)) {
|
||||
const isArchive = !!features?.find((feature) => feature?.enabled === true && feature?.key === 'ARC');
|
||||
return isArchive ? !price?.value?.value || price?.vat === undefined : false;
|
||||
}
|
||||
return false;
|
||||
}),
|
||||
);
|
||||
|
||||
vats$ = this._store.vats$.pipe(shareReplay());
|
||||
|
||||
priceVat$ = this.price$.pipe(map((price) => price?.vat?.vatType));
|
||||
|
||||
canAddResult$ = this.item$.pipe(
|
||||
switchMap((item) => this._store.getCanAddResultForItemAndCurrentPurchaseOption$(item.id)),
|
||||
);
|
||||
|
||||
canEditPrice$ = this.item$.pipe(
|
||||
switchMap((item) => combineLatest([this.canAddResult$, this._store.getCanEditPrice$(item.id)])),
|
||||
map(([canAddResult, canEditPrice]) => canAddResult?.canAdd && canEditPrice),
|
||||
);
|
||||
|
||||
canEditVat$ = this.item$.pipe(
|
||||
switchMap((item) => combineLatest([this.canAddResult$, this._store.getCanEditVat$(item.id)])),
|
||||
map(([canAddResult, canEditVat]) => canAddResult?.canAdd && canEditVat),
|
||||
);
|
||||
|
||||
isGiftCard$ = this.item$.pipe(switchMap((item) => this._store.getIsGiftCard$(item.id)));
|
||||
|
||||
maxSelectableQuantity$ = combineLatest([this._store.purchaseOption$, this.availability$]).pipe(
|
||||
map(([purchaseOption, availability]) => {
|
||||
if (purchaseOption === 'in-store') {
|
||||
return availability?.inStock;
|
||||
}
|
||||
|
||||
return 999;
|
||||
}),
|
||||
startWith(999),
|
||||
);
|
||||
|
||||
showMaxAvailableQuantity$ = combineLatest([this._store.purchaseOption$, this.availability$, this.item$]).pipe(
|
||||
map(([purchaseOption, availability, item]) => {
|
||||
if (purchaseOption === 'pickup' && availability?.inStock < item.quantity) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}),
|
||||
);
|
||||
|
||||
fetchingAvailabilities$ = this.item$
|
||||
.pipe(switchMap((item) => this._store.getFetchingAvailabilitiesForItem$(item.id)))
|
||||
.pipe(map((fetchingAvailabilities) => fetchingAvailabilities.length > 0));
|
||||
|
||||
showNotAvailable$ = combineLatest([this.availabilities$, this.fetchingAvailabilities$]).pipe(
|
||||
map(([availabilities, fetchingAvailabilities]) => {
|
||||
if (fetchingAvailabilities) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (availabilities.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}),
|
||||
);
|
||||
|
||||
// Ticket #4813 Artikeldetailseite // EVT-Datum bei der Kaufoption Rücklage anzeigen
|
||||
get isEVT() {
|
||||
// Einstieg über Kaufoptionen - Hier wird die Katalogavailability verwendet die am ItemDTO hängt
|
||||
if (isItemDTO(this.item, this._store.type)) {
|
||||
const firstDayOfSale = this.item?.catalogAvailability?.firstDayOfSale;
|
||||
return this.firstDayOfSaleBiggerThanToday(firstDayOfSale);
|
||||
}
|
||||
|
||||
// Einstieg über Warenkorb - Da wir hier ein ShoppingCartItemDTO haben werden hier die Katalogdaten neu gefetched und eine Katalogavailability drangehängt
|
||||
if (isShoppingCartItemDTO(this.item, this._store.type)) {
|
||||
const catalogAvailabilities = this._store.availabilities?.filter(
|
||||
(availability) => availability?.purchaseOption === 'catalog',
|
||||
);
|
||||
// #4813 Fix: Hier muss als Kriterium auf EAN statt itemId verglichen werden, denn ein ShoppingCartItemDTO (this.item) hat eine andere ItemId wie das ItemDTO (availability.itemId)
|
||||
const firstDayOfSale = catalogAvailabilities?.find(
|
||||
(availability) => this.item?.product?.ean === availability?.ean,
|
||||
)?.data?.firstDayOfSale;
|
||||
return this.firstDayOfSaleBiggerThanToday(firstDayOfSale);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
constructor(private _store: PurchaseOptionsStore) {}
|
||||
|
||||
firstDayOfSaleBiggerThanToday(firstDayOfSale: string): Date {
|
||||
if (!!firstDayOfSale && moment(firstDayOfSale)?.isAfter(moment())) {
|
||||
return moment(firstDayOfSale).toDate();
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
onPriceInputInit(target: HTMLElement, overlayTrigger: UiOverlayTriggerDirective) {
|
||||
if (this._store.getIsGiftCard(this.item.id)) {
|
||||
overlayTrigger.open();
|
||||
}
|
||||
|
||||
target?.focus();
|
||||
}
|
||||
|
||||
// Wichtig für das korrekte Setzen des Preises an das Item für den Endpoint request
|
||||
parsePrice(value: string) {
|
||||
if (PRICE_PATTERN.test(value)) {
|
||||
return parseFloat(value.replace(',', '.'));
|
||||
}
|
||||
}
|
||||
|
||||
stringifyPrice(value: number) {
|
||||
if (!value) return '';
|
||||
|
||||
const price = value.toFixed(2).replace('.', ',');
|
||||
if (price.includes(',')) {
|
||||
const [integer, decimal] = price.split(',');
|
||||
return `${integer},${decimal.padEnd(2, '0')}`;
|
||||
}
|
||||
|
||||
return price;
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.initPriceValidatorSubscription();
|
||||
this.initQuantitySubscription();
|
||||
this.initPriceSubscription();
|
||||
this.initVatSubscription();
|
||||
this.initSelectedSubscription();
|
||||
}
|
||||
|
||||
ngOnChanges({ item }: SimpleChanges) {
|
||||
if (item) {
|
||||
this._itemSubject.next(this.item);
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this._itemSubject.complete();
|
||||
this._subscriptions.unsubscribe();
|
||||
}
|
||||
|
||||
initPriceValidatorSubscription() {
|
||||
const sub = this.item$.pipe(switchMap((item) => this._store.getIsGiftCard$(item.id))).subscribe((isGiftCard) => {
|
||||
if (isGiftCard) {
|
||||
this.priceFormControl.setValidators(this._giftCardValidators);
|
||||
} else {
|
||||
this.priceFormControl.setValidators(this._defaultValidators);
|
||||
}
|
||||
});
|
||||
|
||||
this._subscriptions.add(sub);
|
||||
}
|
||||
|
||||
initQuantitySubscription() {
|
||||
const sub = this.item$.subscribe((item) => {
|
||||
if (this.quantityFormControl.value !== item.quantity) {
|
||||
this.quantityFormControl.setValue(item.quantity);
|
||||
}
|
||||
});
|
||||
|
||||
const valueChangesSub = this.quantityFormControl.valueChanges.subscribe((quantity) => {
|
||||
if (this.item.quantity !== quantity) {
|
||||
this._store.setItemQuantity(this.item.id, quantity);
|
||||
}
|
||||
});
|
||||
|
||||
this._subscriptions.add(sub);
|
||||
this._subscriptions.add(valueChangesSub);
|
||||
}
|
||||
|
||||
initPriceSubscription() {
|
||||
const sub = combineLatest([this.canEditPrice$, this.price$]).subscribe(([canEditPrice, price]) => {
|
||||
if (!canEditPrice) {
|
||||
return;
|
||||
}
|
||||
|
||||
const priceStr = this.stringifyPrice(price?.value?.value);
|
||||
if (priceStr === '') return;
|
||||
|
||||
if (this.parsePrice(this.priceFormControl.value) !== price?.value?.value) {
|
||||
this.priceFormControl.setValue(priceStr);
|
||||
}
|
||||
});
|
||||
|
||||
const valueChangesSub = combineLatest([this.canEditPrice$, this.priceFormControl.valueChanges]).subscribe(
|
||||
([canEditPrice, value]) => {
|
||||
if (!canEditPrice) {
|
||||
return;
|
||||
}
|
||||
|
||||
const price = this._store.getPrice(this.item.id);
|
||||
const parsedPrice = this.parsePrice(value);
|
||||
|
||||
if (!parsedPrice) {
|
||||
this._store.setPrice(this.item.id, null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (price[this.item.id] !== parsedPrice) {
|
||||
this._store.setPrice(this.item.id, this.parsePrice(value));
|
||||
}
|
||||
},
|
||||
);
|
||||
this._subscriptions.add(sub);
|
||||
this._subscriptions.add(valueChangesSub);
|
||||
}
|
||||
|
||||
initVatSubscription() {
|
||||
const valueChangesSub = this.manualVatFormControl.valueChanges
|
||||
.pipe(withLatestFrom(this.vats$))
|
||||
.subscribe(([formVatType, vats]) => {
|
||||
const price = this._store.getPrice(this.item.id);
|
||||
|
||||
const vat = vats.find((vat) => vat?.vatType === Number(formVatType));
|
||||
|
||||
if (!vat) {
|
||||
this._store.setVat(this.item.id, null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (price[this.item.id]?.vat?.vatType !== vat?.vatType) {
|
||||
this._store.setVat(this.item.id, vat);
|
||||
}
|
||||
});
|
||||
this._subscriptions.add(valueChangesSub);
|
||||
}
|
||||
|
||||
initSelectedSubscription() {
|
||||
const sub = this.item$
|
||||
.pipe(switchMap((item) => this._store.selectedItemIds$.pipe(map((ids) => ids.includes(item.id)))))
|
||||
.subscribe((selected) => {
|
||||
if (this.selectedFormControl.value !== selected) {
|
||||
this.selectedFormControl.setValue(selected);
|
||||
}
|
||||
});
|
||||
const valueChangesSub = this.selectedFormControl.valueChanges.subscribe((selected) => {
|
||||
const current = this._store.selectedItemIds.includes(this.item.id);
|
||||
if (current !== selected) {
|
||||
this._store.setSelectedItem(this.item.id, selected);
|
||||
}
|
||||
});
|
||||
this._subscriptions.add(sub);
|
||||
this._subscriptions.add(valueChangesSub);
|
||||
}
|
||||
}
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
Component,
|
||||
ChangeDetectionStrategy,
|
||||
OnInit,
|
||||
OnDestroy,
|
||||
OnChanges,
|
||||
SimpleChanges,
|
||||
computed,
|
||||
input,
|
||||
} from '@angular/core';
|
||||
import {
|
||||
FormControl,
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
Validators,
|
||||
} from '@angular/forms';
|
||||
import { ProductImageModule } from '@cdn/product-image';
|
||||
import { InputControlModule } from '@shared/components/input-control';
|
||||
import { ElementLifecycleModule } from '@shared/directives/element-lifecycle';
|
||||
import { UiCommonModule, UiOverlayTriggerDirective } from '@ui/common';
|
||||
import { UiQuantityDropdownModule } from '@ui/quantity-dropdown';
|
||||
import { UiSpinnerModule } from '@ui/spinner';
|
||||
import { UiTooltipModule } from '@ui/tooltip';
|
||||
import { combineLatest, ReplaySubject, Subscription } from 'rxjs';
|
||||
import { IconComponent } from '@shared/components/icon';
|
||||
import {
|
||||
map,
|
||||
take,
|
||||
shareReplay,
|
||||
startWith,
|
||||
switchMap,
|
||||
withLatestFrom,
|
||||
} from 'rxjs/operators';
|
||||
import { GIFT_CARD_MAX_PRICE, PRICE_PATTERN } from '../constants';
|
||||
import {
|
||||
Item,
|
||||
PurchaseOptionsStore,
|
||||
isItemDTO,
|
||||
isShoppingCartItemDTO,
|
||||
} from '../store';
|
||||
import { OrderDeadlinePipeModule } from '@shared/pipes/order-deadline';
|
||||
import { UiSelectModule } from '@ui/select';
|
||||
import { KeyValueDTOOfStringAndString } from '@generated/swagger/cat-search-api';
|
||||
import { ScaleContentComponent } from '@shared/components/scale-content';
|
||||
import moment from 'moment';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import { isaOtherInfo } from '@isa/icons';
|
||||
|
||||
@Component({
|
||||
selector: 'shared-purchase-options-list-item',
|
||||
templateUrl: 'purchase-options-list-item.component.html',
|
||||
styleUrls: ['purchase-options-list-item.component.css'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
CommonModule,
|
||||
UiQuantityDropdownModule,
|
||||
UiSelectModule,
|
||||
ProductImageModule,
|
||||
IconComponent,
|
||||
UiSpinnerModule,
|
||||
ReactiveFormsModule,
|
||||
InputControlModule,
|
||||
FormsModule,
|
||||
ElementLifecycleModule,
|
||||
UiTooltipModule,
|
||||
UiCommonModule,
|
||||
ScaleContentComponent,
|
||||
OrderDeadlinePipeModule,
|
||||
NgIcon,
|
||||
],
|
||||
host: { class: 'shared-purchase-options-list-item' },
|
||||
providers: [provideIcons({ isaOtherInfo })],
|
||||
})
|
||||
export class PurchaseOptionsListItemComponent
|
||||
implements OnInit, OnDestroy, OnChanges
|
||||
{
|
||||
private _subscriptions = new Subscription();
|
||||
|
||||
private _itemSubject = new ReplaySubject<Item>(1);
|
||||
|
||||
item = input.required<Item>();
|
||||
|
||||
get item$() {
|
||||
return this._itemSubject.asObservable();
|
||||
}
|
||||
|
||||
get product() {
|
||||
return this.item().product;
|
||||
}
|
||||
|
||||
redemptionPoints = computed(() => {
|
||||
const item = this.item();
|
||||
if (isShoppingCartItemDTO(item, this._store.type)) {
|
||||
return item.loyalty?.value;
|
||||
}
|
||||
|
||||
return item.redemptionPoints;
|
||||
});
|
||||
|
||||
showRedemptionPoints = toSignal(this._store.useRedemptionPoints$);
|
||||
|
||||
quantityFormControl = new FormControl<number>(null);
|
||||
|
||||
private readonly _giftCardValidators = [
|
||||
Validators.required,
|
||||
Validators.min(1),
|
||||
Validators.max(GIFT_CARD_MAX_PRICE),
|
||||
Validators.pattern(PRICE_PATTERN),
|
||||
];
|
||||
|
||||
private readonly _defaultValidators = [
|
||||
Validators.required,
|
||||
Validators.min(0.01),
|
||||
Validators.max(999.99),
|
||||
Validators.pattern(PRICE_PATTERN),
|
||||
];
|
||||
|
||||
priceFormControl = new FormControl<string>(null);
|
||||
|
||||
manualVatFormControl = new FormControl<string>('', [Validators.required]);
|
||||
|
||||
selectedFormControl = new FormControl<boolean>(false);
|
||||
|
||||
availabilities$ = this.item$.pipe(
|
||||
switchMap((item) => this._store.getAvailabilitiesForItem$(item.id)),
|
||||
);
|
||||
|
||||
availability$ = combineLatest([this.item$, this._store.purchaseOption$]).pipe(
|
||||
switchMap(([item, purchaseOption]) =>
|
||||
this._store.getAvailabilityWithPurchaseOption$(item.id, purchaseOption),
|
||||
),
|
||||
map((availability) => availability?.data),
|
||||
);
|
||||
|
||||
availability = toSignal(this.availability$);
|
||||
|
||||
price$ = this.item$.pipe(switchMap((item) => this._store.getPrice$(item.id)));
|
||||
|
||||
priceValue$ = this.price$.pipe(map((price) => price?.value?.value));
|
||||
|
||||
// Ticket #4074 analog zu Ticket #2244
|
||||
// take(2) um die Response des Katalogpreises und danach um die Response der OLAs abzuwarten
|
||||
// Logik gilt ausschließlich für Archivartikel
|
||||
setManualPrice$ = this.price$.pipe(
|
||||
take(2),
|
||||
map((price) => {
|
||||
// Logik nur beim Hinzufügen über Kaufoptionen, da über Ändern im Warenkorb die Info fehlt ob das jeweilige ShoppingCartItem ein Archivartikel ist oder nicht
|
||||
const features = this.item().features as KeyValueDTOOfStringAndString[];
|
||||
if (!!features && Array.isArray(features)) {
|
||||
const isArchive = !!features?.find(
|
||||
(feature) => feature?.enabled === true && feature?.key === 'ARC',
|
||||
);
|
||||
return isArchive
|
||||
? !price?.value?.value || price?.vat === undefined
|
||||
: false;
|
||||
}
|
||||
return false;
|
||||
}),
|
||||
);
|
||||
|
||||
vats$ = this._store.vats$.pipe(shareReplay());
|
||||
|
||||
priceVat$ = this.price$.pipe(map((price) => price?.vat?.vatType));
|
||||
|
||||
canAddResult$ = this.item$.pipe(
|
||||
switchMap((item) =>
|
||||
this._store.getCanAddResultForItemAndCurrentPurchaseOption$(item.id),
|
||||
),
|
||||
);
|
||||
|
||||
canEditPrice$ = this.item$.pipe(
|
||||
switchMap((item) =>
|
||||
combineLatest([
|
||||
this.canAddResult$,
|
||||
this._store.getCanEditPrice$(item.id),
|
||||
]),
|
||||
),
|
||||
map(([canAddResult, canEditPrice]) => canAddResult?.canAdd && canEditPrice),
|
||||
);
|
||||
|
||||
canEditVat$ = this.item$.pipe(
|
||||
switchMap((item) =>
|
||||
combineLatest([this.canAddResult$, this._store.getCanEditVat$(item.id)]),
|
||||
),
|
||||
map(([canAddResult, canEditVat]) => canAddResult?.canAdd && canEditVat),
|
||||
);
|
||||
|
||||
isGiftCard$ = this.item$.pipe(
|
||||
switchMap((item) => this._store.getIsGiftCard$(item.id)),
|
||||
);
|
||||
|
||||
maxSelectableQuantity$ = combineLatest([
|
||||
this._store.purchaseOption$,
|
||||
this.availability$,
|
||||
]).pipe(
|
||||
map(([purchaseOption, availability]) => {
|
||||
if (purchaseOption === 'in-store') {
|
||||
return availability?.inStock;
|
||||
}
|
||||
|
||||
return 999;
|
||||
}),
|
||||
startWith(999),
|
||||
);
|
||||
|
||||
showMaxAvailableQuantity$ = combineLatest([
|
||||
this._store.purchaseOption$,
|
||||
this.availability$,
|
||||
this.item$,
|
||||
]).pipe(
|
||||
map(([purchaseOption, availability, item]) => {
|
||||
if (
|
||||
purchaseOption === 'pickup' &&
|
||||
availability?.inStock < item.quantity
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}),
|
||||
);
|
||||
|
||||
fetchingAvailabilities$ = this.item$
|
||||
.pipe(
|
||||
switchMap((item) =>
|
||||
this._store.getFetchingAvailabilitiesForItem$(item.id),
|
||||
),
|
||||
)
|
||||
.pipe(map((fetchingAvailabilities) => fetchingAvailabilities.length > 0));
|
||||
|
||||
showNotAvailable$ = combineLatest([
|
||||
this.availabilities$,
|
||||
this.fetchingAvailabilities$,
|
||||
]).pipe(
|
||||
map(([availabilities, fetchingAvailabilities]) => {
|
||||
if (fetchingAvailabilities) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (availabilities.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}),
|
||||
);
|
||||
|
||||
// Ticket #4813 Artikeldetailseite // EVT-Datum bei der Kaufoption Rücklage anzeigen
|
||||
get isEVT() {
|
||||
// Einstieg über Kaufoptionen - Hier wird die Katalogavailability verwendet die am ItemDTO hängt
|
||||
if (isItemDTO(this.item, this._store.type)) {
|
||||
const firstDayOfSale = this.item?.catalogAvailability?.firstDayOfSale;
|
||||
return this.firstDayOfSaleBiggerThanToday(firstDayOfSale);
|
||||
}
|
||||
|
||||
// Einstieg über Warenkorb - Da wir hier ein ShoppingCartItemDTO haben werden hier die Katalogdaten neu gefetched und eine Katalogavailability drangehängt
|
||||
if (isShoppingCartItemDTO(this.item, this._store.type)) {
|
||||
const catalogAvailabilities = this._store.availabilities?.filter(
|
||||
(availability) => availability?.purchaseOption === 'catalog',
|
||||
);
|
||||
// #4813 Fix: Hier muss als Kriterium auf EAN statt itemId verglichen werden, denn ein ShoppingCartItemDTO (this.item) hat eine andere ItemId wie das ItemDTO (availability.itemId)
|
||||
const firstDayOfSale = catalogAvailabilities?.find(
|
||||
(availability) => this.item().product?.ean === availability?.ean,
|
||||
)?.data?.firstDayOfSale;
|
||||
return this.firstDayOfSaleBiggerThanToday(firstDayOfSale);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
useRedemptionPoints = toSignal(this._store.useRedemptionPoints$);
|
||||
|
||||
purchaseOption = toSignal(this._store.purchaseOption$);
|
||||
|
||||
isReservePurchaseOption = computed(() => {
|
||||
return this.purchaseOption() === 'in-store';
|
||||
});
|
||||
|
||||
showLowStockMessage = computed(() => {
|
||||
return (
|
||||
this.useRedemptionPoints() &&
|
||||
this.isReservePurchaseOption() &&
|
||||
(!this.availability() || this.availability().inStock < 2)
|
||||
);
|
||||
});
|
||||
|
||||
constructor(private _store: PurchaseOptionsStore) {}
|
||||
|
||||
firstDayOfSaleBiggerThanToday(firstDayOfSale: string): Date {
|
||||
if (!!firstDayOfSale && moment(firstDayOfSale)?.isAfter(moment())) {
|
||||
return moment(firstDayOfSale).toDate();
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
onPriceInputInit(
|
||||
target: HTMLElement,
|
||||
overlayTrigger: UiOverlayTriggerDirective,
|
||||
) {
|
||||
if (this._store.getIsGiftCard(this.item().id)) {
|
||||
overlayTrigger.open();
|
||||
}
|
||||
|
||||
target?.focus();
|
||||
}
|
||||
|
||||
// Wichtig für das korrekte Setzen des Preises an das Item für den Endpoint request
|
||||
parsePrice(value: string) {
|
||||
if (PRICE_PATTERN.test(value)) {
|
||||
return parseFloat(value.replace(',', '.'));
|
||||
}
|
||||
}
|
||||
|
||||
stringifyPrice(value: number) {
|
||||
if (!value) return '';
|
||||
|
||||
const price = value.toFixed(2).replace('.', ',');
|
||||
if (price.includes(',')) {
|
||||
const [integer, decimal] = price.split(',');
|
||||
return `${integer},${decimal.padEnd(2, '0')}`;
|
||||
}
|
||||
|
||||
return price;
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.initPriceValidatorSubscription();
|
||||
this.initQuantitySubscription();
|
||||
this.initPriceSubscription();
|
||||
this.initVatSubscription();
|
||||
this.initSelectedSubscription();
|
||||
}
|
||||
|
||||
ngOnChanges({ item }: SimpleChanges) {
|
||||
if (item) {
|
||||
this._itemSubject.next(this.item());
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this._itemSubject.complete();
|
||||
this._subscriptions.unsubscribe();
|
||||
}
|
||||
|
||||
initPriceValidatorSubscription() {
|
||||
const sub = this.item$
|
||||
.pipe(switchMap((item) => this._store.getIsGiftCard$(item.id)))
|
||||
.subscribe((isGiftCard) => {
|
||||
if (isGiftCard) {
|
||||
this.priceFormControl.setValidators(this._giftCardValidators);
|
||||
} else {
|
||||
this.priceFormControl.setValidators(this._defaultValidators);
|
||||
}
|
||||
});
|
||||
|
||||
this._subscriptions.add(sub);
|
||||
}
|
||||
|
||||
initQuantitySubscription() {
|
||||
const sub = this.item$.subscribe((item) => {
|
||||
if (this.quantityFormControl.value !== item.quantity) {
|
||||
this.quantityFormControl.setValue(item.quantity);
|
||||
}
|
||||
});
|
||||
|
||||
const valueChangesSub = this.quantityFormControl.valueChanges.subscribe(
|
||||
(quantity) => {
|
||||
if (this.item().quantity !== quantity) {
|
||||
this._store.setItemQuantity(this.item().id, quantity);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
this._subscriptions.add(sub);
|
||||
this._subscriptions.add(valueChangesSub);
|
||||
}
|
||||
|
||||
initPriceSubscription() {
|
||||
const sub = combineLatest([this.canEditPrice$, this.price$]).subscribe(
|
||||
([canEditPrice, price]) => {
|
||||
if (!canEditPrice) {
|
||||
return;
|
||||
}
|
||||
|
||||
const priceStr = this.stringifyPrice(price?.value?.value);
|
||||
if (priceStr === '') return;
|
||||
|
||||
if (
|
||||
this.parsePrice(this.priceFormControl.value) !== price?.value?.value
|
||||
) {
|
||||
this.priceFormControl.setValue(priceStr);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const valueChangesSub = combineLatest([
|
||||
this.canEditPrice$,
|
||||
this.priceFormControl.valueChanges,
|
||||
]).subscribe(([canEditPrice, value]) => {
|
||||
if (!canEditPrice) {
|
||||
return;
|
||||
}
|
||||
|
||||
const price = this._store.getPrice(this.item().id);
|
||||
const parsedPrice = this.parsePrice(value);
|
||||
|
||||
if (!parsedPrice) {
|
||||
this._store.setPrice(this.item().id, null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (price[this.item().id] !== parsedPrice) {
|
||||
this._store.setPrice(this.item().id, this.parsePrice(value));
|
||||
}
|
||||
});
|
||||
this._subscriptions.add(sub);
|
||||
this._subscriptions.add(valueChangesSub);
|
||||
}
|
||||
|
||||
initVatSubscription() {
|
||||
const valueChangesSub = this.manualVatFormControl.valueChanges
|
||||
.pipe(withLatestFrom(this.vats$))
|
||||
.subscribe(([formVatType, vats]) => {
|
||||
const price = this._store.getPrice(this.item().id);
|
||||
|
||||
const vat = vats.find((vat) => vat?.vatType === Number(formVatType));
|
||||
|
||||
if (!vat) {
|
||||
this._store.setVat(this.item().id, null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (price[this.item().id]?.vat?.vatType !== vat?.vatType) {
|
||||
this._store.setVat(this.item().id, vat);
|
||||
}
|
||||
});
|
||||
this._subscriptions.add(valueChangesSub);
|
||||
}
|
||||
|
||||
initSelectedSubscription() {
|
||||
const sub = this.item$
|
||||
.pipe(
|
||||
switchMap((item) =>
|
||||
this._store.selectedItemIds$.pipe(
|
||||
map((ids) => ids.includes(item.id)),
|
||||
),
|
||||
),
|
||||
)
|
||||
.subscribe((selected) => {
|
||||
if (this.selectedFormControl.value !== selected) {
|
||||
this.selectedFormControl.setValue(selected);
|
||||
}
|
||||
});
|
||||
const valueChangesSub = this.selectedFormControl.valueChanges.subscribe(
|
||||
(selected) => {
|
||||
const current = this._store.selectedItemIds.includes(this.item().id);
|
||||
if (current !== selected) {
|
||||
this._store.setSelectedItem(this.item().id, selected);
|
||||
}
|
||||
},
|
||||
);
|
||||
this._subscriptions.add(sub);
|
||||
this._subscriptions.add(valueChangesSub);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,145 +1,181 @@
|
||||
import { Component, ChangeDetectionStrategy, OnInit, OnDestroy, TrackByFunction, HostBinding } from '@angular/core';
|
||||
import { UiModalRef } from '@ui/modal';
|
||||
import { PurchaseOptionsModalData } from './purchase-options-modal.data';
|
||||
|
||||
import { PurchaseOptionsListHeaderComponent } from './purchase-options-list-header';
|
||||
import { PurchaseOptionsListItemComponent } from './purchase-options-list-item';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Subject, zip } from 'rxjs';
|
||||
import {
|
||||
DeliveryPurchaseOptionTileComponent,
|
||||
DownloadPurchaseOptionTileComponent,
|
||||
InStorePurchaseOptionTileComponent,
|
||||
PickupPurchaseOptionTileComponent,
|
||||
} from './purchase-options-tile';
|
||||
import { isGiftCard, Item, PurchaseOption, PurchaseOptionsStore } from './store';
|
||||
import { delay, map, shareReplay, skip, switchMap, takeUntil, tap } from 'rxjs/operators';
|
||||
import { KeyValueDTOOfStringAndString } from '@generated/swagger/cat-search-api';
|
||||
import { provideComponentStore } from '@ngrx/component-store';
|
||||
|
||||
@Component({
|
||||
selector: 'shared-purchase-options-modal',
|
||||
templateUrl: 'purchase-options-modal.component.html',
|
||||
styleUrls: ['purchase-options-modal.component.css'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
providers: [provideComponentStore(PurchaseOptionsStore)],
|
||||
imports: [
|
||||
CommonModule,
|
||||
PurchaseOptionsListHeaderComponent,
|
||||
PurchaseOptionsListItemComponent,
|
||||
DeliveryPurchaseOptionTileComponent,
|
||||
InStorePurchaseOptionTileComponent,
|
||||
PickupPurchaseOptionTileComponent,
|
||||
DownloadPurchaseOptionTileComponent,
|
||||
],
|
||||
})
|
||||
export class PurchaseOptionsModalComponent implements OnInit, OnDestroy {
|
||||
get type() {
|
||||
return this._uiModalRef.data.type;
|
||||
}
|
||||
|
||||
@HostBinding('attr.data-loading')
|
||||
get fetchingData() {
|
||||
return this.store.fetchingAvailabilities.length > 0;
|
||||
}
|
||||
|
||||
items$ = this.store.items$;
|
||||
|
||||
hasPrice$ = this.items$.pipe(
|
||||
switchMap((items) =>
|
||||
items.map((item) => {
|
||||
let isArchive = false;
|
||||
const features = item?.features as KeyValueDTOOfStringAndString[];
|
||||
// Ticket #4074 analog zu Ticket #2244
|
||||
// Ob Archivartikel kann nur über Kaufoptionen herausgefunden werden, nicht über Ändern im Warenkorb da am ShoppingCartItem das Archivartikel Feature fehlt
|
||||
if (!!features && Array.isArray(features)) {
|
||||
isArchive = !!features?.find((feature) => feature?.enabled === true && feature?.key === 'ARC');
|
||||
}
|
||||
return zip(
|
||||
this.store
|
||||
?.getPrice$(item?.id)
|
||||
.pipe(
|
||||
map((price) =>
|
||||
isArchive
|
||||
? !!price?.value?.value && price?.vat !== undefined && price?.vat?.value !== undefined
|
||||
: !!price?.value?.value,
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
switchMap((hasPrices) => hasPrices),
|
||||
map((hasPrices) => {
|
||||
const containsItemWithNoPrice = hasPrices?.filter((hasPrice) => hasPrice === false) ?? [];
|
||||
return containsItemWithNoPrice?.length === 0;
|
||||
}),
|
||||
);
|
||||
|
||||
purchasingOptions$ = this.store.getPurchaseOptionsInAvailabilities$;
|
||||
|
||||
isDownloadOnly$ = this.purchasingOptions$.pipe(
|
||||
map((purchasingOptions) => purchasingOptions.length === 1 && purchasingOptions[0] === 'download'),
|
||||
);
|
||||
|
||||
isGiftCardOnly$ = this.store.items$.pipe(map((items) => items.every((item) => isGiftCard(item, this.store.type))));
|
||||
|
||||
hasDownload$ = this.purchasingOptions$.pipe(map((purchasingOptions) => purchasingOptions.includes('download')));
|
||||
|
||||
canContinue$ = this.store.canContinue$;
|
||||
|
||||
private _onDestroy$ = new Subject<void>();
|
||||
|
||||
saving = false;
|
||||
|
||||
constructor(
|
||||
private _uiModalRef: UiModalRef<string, PurchaseOptionsModalData>,
|
||||
public store: PurchaseOptionsStore,
|
||||
) {
|
||||
this.store.initialize(this._uiModalRef.data);
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.items$.pipe(takeUntil(this._onDestroy$), skip(1), delay(100)).subscribe((items) => {
|
||||
if (items.length === 0) {
|
||||
this._uiModalRef.close();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._uiModalRef.data?.preSelectOption?.option) {
|
||||
this.store.setPurchaseOption(this._uiModalRef.data?.preSelectOption?.option);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this._onDestroy$.next();
|
||||
this._onDestroy$.complete();
|
||||
}
|
||||
|
||||
itemTrackBy: TrackByFunction<Item> = (_, item) => item.id;
|
||||
|
||||
showOption(option: PurchaseOption): boolean {
|
||||
return this._uiModalRef.data?.preSelectOption?.showOptionOnly
|
||||
? this._uiModalRef.data?.preSelectOption?.option === option
|
||||
: true;
|
||||
}
|
||||
|
||||
async save(action: string) {
|
||||
if (this.saving) {
|
||||
return;
|
||||
}
|
||||
this.saving = true;
|
||||
|
||||
try {
|
||||
await this.store.save();
|
||||
|
||||
if (this.store.items.length === 0) {
|
||||
this._uiModalRef.close(action);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
this.saving = false;
|
||||
}
|
||||
}
|
||||
import {
|
||||
Component,
|
||||
ChangeDetectionStrategy,
|
||||
OnInit,
|
||||
OnDestroy,
|
||||
TrackByFunction,
|
||||
HostBinding,
|
||||
} from '@angular/core';
|
||||
import { UiModalRef } from '@ui/modal';
|
||||
import { PurchaseOptionsModalContext } from './purchase-options-modal.data';
|
||||
|
||||
import { PurchaseOptionsListHeaderComponent } from './purchase-options-list-header';
|
||||
import { PurchaseOptionsListItemComponent } from './purchase-options-list-item';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Subject, zip } from 'rxjs';
|
||||
import {
|
||||
DeliveryPurchaseOptionTileComponent,
|
||||
DownloadPurchaseOptionTileComponent,
|
||||
InStorePurchaseOptionTileComponent,
|
||||
PickupPurchaseOptionTileComponent,
|
||||
} from './purchase-options-tile';
|
||||
import {
|
||||
isGiftCard,
|
||||
Item,
|
||||
PurchaseOption,
|
||||
PurchaseOptionsStore,
|
||||
} from './store';
|
||||
import {
|
||||
delay,
|
||||
map,
|
||||
shareReplay,
|
||||
skip,
|
||||
switchMap,
|
||||
takeUntil,
|
||||
tap,
|
||||
} from 'rxjs/operators';
|
||||
import { KeyValueDTOOfStringAndString } from '@generated/swagger/cat-search-api';
|
||||
import { provideComponentStore } from '@ngrx/component-store';
|
||||
|
||||
@Component({
|
||||
selector: 'shared-purchase-options-modal',
|
||||
templateUrl: 'purchase-options-modal.component.html',
|
||||
styleUrls: ['purchase-options-modal.component.css'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
providers: [provideComponentStore(PurchaseOptionsStore)],
|
||||
imports: [
|
||||
CommonModule,
|
||||
PurchaseOptionsListHeaderComponent,
|
||||
PurchaseOptionsListItemComponent,
|
||||
DeliveryPurchaseOptionTileComponent,
|
||||
InStorePurchaseOptionTileComponent,
|
||||
PickupPurchaseOptionTileComponent,
|
||||
DownloadPurchaseOptionTileComponent,
|
||||
],
|
||||
})
|
||||
export class PurchaseOptionsModalComponent implements OnInit, OnDestroy {
|
||||
get type() {
|
||||
return this._uiModalRef.data.type;
|
||||
}
|
||||
|
||||
@HostBinding('attr.data-loading')
|
||||
get fetchingData() {
|
||||
return this.store.fetchingAvailabilities.length > 0;
|
||||
}
|
||||
|
||||
items$ = this.store.items$;
|
||||
|
||||
hasPrice$ = this.items$.pipe(
|
||||
switchMap((items) =>
|
||||
items.map((item) => {
|
||||
let isArchive = false;
|
||||
const features = item?.features as KeyValueDTOOfStringAndString[];
|
||||
// Ticket #4074 analog zu Ticket #2244
|
||||
// Ob Archivartikel kann nur über Kaufoptionen herausgefunden werden, nicht über Ändern im Warenkorb da am ShoppingCartItem das Archivartikel Feature fehlt
|
||||
if (!!features && Array.isArray(features)) {
|
||||
isArchive = !!features?.find(
|
||||
(feature) => feature?.enabled === true && feature?.key === 'ARC',
|
||||
);
|
||||
}
|
||||
return zip(
|
||||
this.store
|
||||
?.getPrice$(item?.id)
|
||||
.pipe(
|
||||
map((price) =>
|
||||
isArchive
|
||||
? !!price?.value?.value &&
|
||||
price?.vat !== undefined &&
|
||||
price?.vat?.value !== undefined
|
||||
: !!price?.value?.value,
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
switchMap((hasPrices) => hasPrices),
|
||||
map((hasPrices) => {
|
||||
const containsItemWithNoPrice =
|
||||
hasPrices?.filter((hasPrice) => hasPrice === false) ?? [];
|
||||
return containsItemWithNoPrice?.length === 0;
|
||||
}),
|
||||
);
|
||||
|
||||
purchasingOptions$ = this.store.getPurchaseOptionsInAvailabilities$;
|
||||
|
||||
isDownloadOnly$ = this.purchasingOptions$.pipe(
|
||||
map(
|
||||
(purchasingOptions) =>
|
||||
purchasingOptions.length === 1 && purchasingOptions[0] === 'download',
|
||||
),
|
||||
);
|
||||
|
||||
isGiftCardOnly$ = this.store.items$.pipe(
|
||||
map((items) => items.every((item) => isGiftCard(item, this.store.type))),
|
||||
);
|
||||
|
||||
hasDownload$ = this.purchasingOptions$.pipe(
|
||||
map((purchasingOptions) => purchasingOptions.includes('download')),
|
||||
);
|
||||
|
||||
canContinue$ = this.store.canContinue$;
|
||||
|
||||
private _onDestroy$ = new Subject<void>();
|
||||
|
||||
saving = false;
|
||||
|
||||
constructor(
|
||||
private _uiModalRef: UiModalRef<string, PurchaseOptionsModalContext>,
|
||||
public store: PurchaseOptionsStore,
|
||||
) {
|
||||
this.store.initialize(this._uiModalRef.data);
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.items$
|
||||
.pipe(takeUntil(this._onDestroy$), skip(1), delay(100))
|
||||
.subscribe((items) => {
|
||||
if (items.length === 0) {
|
||||
this._uiModalRef.close();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._uiModalRef.data?.preSelectOption?.option) {
|
||||
this.store.setPurchaseOption(
|
||||
this._uiModalRef.data?.preSelectOption?.option,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this._onDestroy$.next();
|
||||
this._onDestroy$.complete();
|
||||
}
|
||||
|
||||
itemTrackBy: TrackByFunction<Item> = (_, item) => item.id;
|
||||
|
||||
showOption(option: PurchaseOption): boolean {
|
||||
return this._uiModalRef.data?.preSelectOption?.showOptionOnly
|
||||
? this._uiModalRef.data?.preSelectOption?.option === option
|
||||
: true;
|
||||
}
|
||||
|
||||
async save(action: string) {
|
||||
if (this.saving) {
|
||||
return;
|
||||
}
|
||||
this.saving = true;
|
||||
|
||||
try {
|
||||
await this.store.save();
|
||||
|
||||
if (this.store.items.length === 0) {
|
||||
this._uiModalRef.close(action);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
this.saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,30 @@
|
||||
import { ItemDTO } from '@generated/swagger/cat-search-api';
|
||||
import { ShoppingCartItemDTO, BranchDTO } from '@generated/swagger/checkout-api';
|
||||
import { ActionType, PurchaseOption } from './store';
|
||||
|
||||
export interface PurchaseOptionsModalData {
|
||||
processId: number;
|
||||
type: ActionType;
|
||||
items: Array<ItemDTO | ShoppingCartItemDTO>;
|
||||
pickupBranch?: BranchDTO;
|
||||
inStoreBranch?: BranchDTO;
|
||||
preSelectOption?: { option: PurchaseOption; showOptionOnly?: boolean };
|
||||
}
|
||||
import { ItemDTO } from '@generated/swagger/cat-search-api';
|
||||
import {
|
||||
ShoppingCartItemDTO,
|
||||
BranchDTO,
|
||||
} from '@generated/swagger/checkout-api';
|
||||
import { Customer } from '@isa/crm/data-access';
|
||||
import { ActionType, PurchaseOption } from './store';
|
||||
|
||||
export interface PurchaseOptionsModalData {
|
||||
tabId: number;
|
||||
shoppingCartId: number;
|
||||
type: ActionType;
|
||||
useRedemptionPoints?: boolean;
|
||||
items: Array<ItemDTO | ShoppingCartItemDTO>;
|
||||
pickupBranch?: BranchDTO;
|
||||
inStoreBranch?: BranchDTO;
|
||||
preSelectOption?: { option: PurchaseOption; showOptionOnly?: boolean };
|
||||
}
|
||||
|
||||
export interface PurchaseOptionsModalContext {
|
||||
shoppingCartId: number;
|
||||
type: ActionType;
|
||||
useRedemptionPoints: boolean;
|
||||
items: Array<ItemDTO | ShoppingCartItemDTO>;
|
||||
selectedCustomer?: Customer;
|
||||
selectedBranch?: BranchDTO;
|
||||
pickupBranch?: BranchDTO;
|
||||
inStoreBranch?: BranchDTO;
|
||||
preSelectOption?: { option: PurchaseOption; showOptionOnly?: boolean };
|
||||
}
|
||||
|
||||
@@ -1,16 +1,49 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { UiModalRef, UiModalService } from '@ui/modal';
|
||||
import { PurchaseOptionsModalComponent } from './purchase-options-modal.component';
|
||||
import { PurchaseOptionsModalData } from './purchase-options-modal.data';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class PurchaseOptionsModalService {
|
||||
constructor(private _uiModal: UiModalService) {}
|
||||
|
||||
open(data: PurchaseOptionsModalData): UiModalRef<string, PurchaseOptionsModalData> {
|
||||
return this._uiModal.open<string, PurchaseOptionsModalData>({
|
||||
content: PurchaseOptionsModalComponent,
|
||||
data,
|
||||
});
|
||||
}
|
||||
}
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { UiModalRef, UiModalService } from '@ui/modal';
|
||||
import { PurchaseOptionsModalComponent } from './purchase-options-modal.component';
|
||||
import {
|
||||
PurchaseOptionsModalData,
|
||||
PurchaseOptionsModalContext,
|
||||
} from './purchase-options-modal.data';
|
||||
import {
|
||||
CustomerFacade,
|
||||
Customer,
|
||||
CrmTabMetadataService,
|
||||
} from '@isa/crm/data-access';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class PurchaseOptionsModalService {
|
||||
#uiModal = inject(UiModalService);
|
||||
#crmTabMetadataService = inject(CrmTabMetadataService);
|
||||
#customerFacade = inject(CustomerFacade);
|
||||
|
||||
async open(
|
||||
data: PurchaseOptionsModalData,
|
||||
): Promise<UiModalRef<string, PurchaseOptionsModalData>> {
|
||||
const context: PurchaseOptionsModalContext = {
|
||||
useRedemptionPoints: !!data.useRedemptionPoints,
|
||||
...data,
|
||||
};
|
||||
|
||||
context.selectedCustomer = await this.#getSelectedCustomer(data);
|
||||
|
||||
return this.#uiModal.open<string, PurchaseOptionsModalContext>({
|
||||
content: PurchaseOptionsModalComponent,
|
||||
data: context,
|
||||
});
|
||||
}
|
||||
|
||||
#getSelectedCustomer({
|
||||
tabId,
|
||||
}: {
|
||||
tabId: number;
|
||||
}): Promise<Customer | undefined> {
|
||||
const customerId = this.#crmTabMetadataService.selectedCustomerId(tabId);
|
||||
|
||||
if (!customerId) {
|
||||
return Promise.resolve(undefined);
|
||||
}
|
||||
|
||||
return this.#customerFacade.fetchCustomer({ customerId });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,158 +1,180 @@
|
||||
import { PriceDTO } from '@generated/swagger/availability-api';
|
||||
import { ItemDTO } from '@generated/swagger/cat-search-api';
|
||||
import { AvailabilityDTO, OLAAvailabilityDTO, ShoppingCartItemDTO } from '@generated/swagger/checkout-api';
|
||||
import { GIFT_CARD_TYPE } from '../constants';
|
||||
import {
|
||||
ActionType,
|
||||
Item,
|
||||
ItemData,
|
||||
ItemPayloadWithSourceId,
|
||||
OrderType,
|
||||
PurchaseOption,
|
||||
} from './purchase-options.types';
|
||||
|
||||
export function isItemDTO(item: any, type: ActionType): item is ItemDTO {
|
||||
return type === 'add';
|
||||
}
|
||||
|
||||
export function isItemDTOArray(items: any, type: ActionType): items is ItemDTO[] {
|
||||
return type === 'add';
|
||||
}
|
||||
|
||||
export function isShoppingCartItemDTO(item: any, type: ActionType): item is ShoppingCartItemDTO {
|
||||
return type === 'update';
|
||||
}
|
||||
|
||||
export function isShoppingCartItemDTOArray(items: any, type: ActionType): items is ShoppingCartItemDTO[] {
|
||||
return type === 'update';
|
||||
}
|
||||
|
||||
export function mapToItemData(item: Item, type: ActionType): ItemData {
|
||||
const price: PriceDTO = {};
|
||||
|
||||
if (isItemDTO(item, type)) {
|
||||
price.value = item?.catalogAvailability?.price?.value ?? {};
|
||||
price.vat = item?.catalogAvailability?.price?.vat ?? {};
|
||||
|
||||
return {
|
||||
ean: item.product.ean,
|
||||
itemId: item.id,
|
||||
price,
|
||||
sourceId: item.id,
|
||||
quantity: item.quantity ?? 1,
|
||||
};
|
||||
} else {
|
||||
price.value = item?.unitPrice?.value ?? {};
|
||||
price.vat = item?.unitPrice?.vat ?? {};
|
||||
|
||||
return {
|
||||
ean: item.product.ean,
|
||||
itemId: Number(item.product.catalogProductNumber),
|
||||
price,
|
||||
sourceId: item.id,
|
||||
quantity: item.quantity ?? 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function isDownload(item: Item): boolean {
|
||||
return item.product.format === 'DL' || item.product.format === 'EB';
|
||||
}
|
||||
|
||||
export function isGiftCard(item: Item, type: ActionType): boolean {
|
||||
if (isItemDTO(item, type)) {
|
||||
return item?.type === GIFT_CARD_TYPE;
|
||||
} else {
|
||||
return item?.itemType === GIFT_CARD_TYPE;
|
||||
}
|
||||
}
|
||||
|
||||
export function isArchive(item: Item, type: ActionType): boolean {
|
||||
if (isItemDTO(item, type)) {
|
||||
return item?.features?.some((f) => f.key === 'ARC');
|
||||
} else {
|
||||
return !!item?.features?.['ARC'];
|
||||
}
|
||||
}
|
||||
|
||||
export function mapToItemPayload({
|
||||
item,
|
||||
quantity,
|
||||
availability,
|
||||
type,
|
||||
}: {
|
||||
item: ItemDTO | ShoppingCartItemDTO;
|
||||
quantity: number;
|
||||
availability: AvailabilityDTO;
|
||||
type: ActionType;
|
||||
}): ItemPayloadWithSourceId {
|
||||
return {
|
||||
availabilities: [mapToOlaAvailability({ item, quantity, availability, type })],
|
||||
id: String(getCatalogId(item, type)),
|
||||
sourceId: item.id,
|
||||
};
|
||||
}
|
||||
|
||||
export function getCatalogId(item: ItemDTO | ShoppingCartItemDTO, type: ActionType): number | string {
|
||||
return isItemDTO(item, type) ? item.id : item.product.catalogProductNumber;
|
||||
}
|
||||
|
||||
export function mapToOlaAvailability({
|
||||
availability,
|
||||
item,
|
||||
quantity,
|
||||
type,
|
||||
}: {
|
||||
availability: AvailabilityDTO;
|
||||
item: ItemDTO | ShoppingCartItemDTO;
|
||||
quantity: number;
|
||||
type: ActionType;
|
||||
}): OLAAvailabilityDTO {
|
||||
return {
|
||||
status: availability?.availabilityType,
|
||||
at: availability?.estimatedShippingDate,
|
||||
ean: item?.product?.ean,
|
||||
itemId: Number(getCatalogId(item, type)),
|
||||
format: item?.product?.format,
|
||||
isPrebooked: availability?.isPrebooked,
|
||||
logisticianId: availability?.logistician?.id,
|
||||
price: availability?.price,
|
||||
qty: quantity,
|
||||
ssc: availability?.ssc,
|
||||
sscText: availability?.sscText,
|
||||
supplierId: availability?.supplier?.id,
|
||||
supplierProductNumber: availability?.supplierProductNumber,
|
||||
};
|
||||
}
|
||||
|
||||
export function getOrderTypeForPurchaseOption(purchaseOption: PurchaseOption): OrderType | undefined {
|
||||
switch (purchaseOption) {
|
||||
case 'delivery':
|
||||
case 'dig-delivery':
|
||||
case 'b2b-delivery':
|
||||
return 'Versand';
|
||||
case 'pickup':
|
||||
return 'Abholung';
|
||||
case 'in-store':
|
||||
return 'Rücklage';
|
||||
case 'download':
|
||||
return 'Download';
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function getPurchaseOptionForOrderType(orderType: OrderType): PurchaseOption | undefined {
|
||||
switch (orderType) {
|
||||
case 'Versand':
|
||||
return 'delivery';
|
||||
case 'Abholung':
|
||||
return 'pickup';
|
||||
case 'Rücklage':
|
||||
return 'in-store';
|
||||
case 'Download':
|
||||
return 'download';
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
import { PriceDTO } from '@generated/swagger/availability-api';
|
||||
import { ItemDTO } from '@generated/swagger/cat-search-api';
|
||||
import {
|
||||
AvailabilityDTO,
|
||||
OLAAvailabilityDTO,
|
||||
ShoppingCartItemDTO,
|
||||
} from '@generated/swagger/checkout-api';
|
||||
import { GIFT_CARD_TYPE } from '../constants';
|
||||
import {
|
||||
ActionType,
|
||||
Item,
|
||||
ItemData,
|
||||
ItemPayloadWithSourceId,
|
||||
PurchaseOption,
|
||||
} from './purchase-options.types';
|
||||
import { OrderType } from '@isa/checkout/data-access';
|
||||
|
||||
export function isItemDTO(item: any, type: ActionType): item is ItemDTO {
|
||||
return type === 'add';
|
||||
}
|
||||
|
||||
export function isItemDTOArray(
|
||||
items: any,
|
||||
type: ActionType,
|
||||
): items is ItemDTO[] {
|
||||
return type === 'add';
|
||||
}
|
||||
|
||||
export function isShoppingCartItemDTO(
|
||||
item: any,
|
||||
type: ActionType,
|
||||
): item is ShoppingCartItemDTO {
|
||||
return type === 'update';
|
||||
}
|
||||
|
||||
export function isShoppingCartItemDTOArray(
|
||||
items: any,
|
||||
type: ActionType,
|
||||
): items is ShoppingCartItemDTO[] {
|
||||
return type === 'update';
|
||||
}
|
||||
|
||||
export function mapToItemData(item: Item, type: ActionType): ItemData {
|
||||
const price: PriceDTO = {};
|
||||
|
||||
if (isItemDTO(item, type)) {
|
||||
price.value = item?.catalogAvailability?.price?.value ?? {};
|
||||
price.vat = item?.catalogAvailability?.price?.vat ?? {};
|
||||
|
||||
return {
|
||||
ean: item.product.ean,
|
||||
itemId: item.id,
|
||||
price,
|
||||
sourceId: item.id,
|
||||
quantity: item.quantity ?? 1,
|
||||
};
|
||||
} else {
|
||||
price.value = item?.unitPrice?.value ?? {};
|
||||
price.vat = item?.unitPrice?.vat ?? {};
|
||||
|
||||
return {
|
||||
ean: item.product.ean,
|
||||
itemId: Number(item.product.catalogProductNumber),
|
||||
price,
|
||||
sourceId: item.id,
|
||||
quantity: item.quantity ?? 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function isDownload(item: Item): boolean {
|
||||
return item.product.format === 'DL' || item.product.format === 'EB';
|
||||
}
|
||||
|
||||
export function isGiftCard(item: Item, type: ActionType): boolean {
|
||||
if (isItemDTO(item, type)) {
|
||||
return item?.type === GIFT_CARD_TYPE;
|
||||
} else {
|
||||
return item?.itemType === GIFT_CARD_TYPE;
|
||||
}
|
||||
}
|
||||
|
||||
export function isArchive(item: Item, type: ActionType): boolean {
|
||||
if (isItemDTO(item, type)) {
|
||||
return item?.features?.some((f) => f.key === 'ARC');
|
||||
} else {
|
||||
return !!item?.features?.['ARC'];
|
||||
}
|
||||
}
|
||||
|
||||
export function mapToItemPayload({
|
||||
item,
|
||||
quantity,
|
||||
availability,
|
||||
type,
|
||||
}: {
|
||||
item: ItemDTO | ShoppingCartItemDTO;
|
||||
quantity: number;
|
||||
availability: AvailabilityDTO;
|
||||
type: ActionType;
|
||||
}): ItemPayloadWithSourceId {
|
||||
return {
|
||||
availabilities: [
|
||||
mapToOlaAvailability({ item, quantity, availability, type }),
|
||||
],
|
||||
id: String(getCatalogId(item, type)),
|
||||
sourceId: item.id,
|
||||
};
|
||||
}
|
||||
|
||||
export function getCatalogId(
|
||||
item: ItemDTO | ShoppingCartItemDTO,
|
||||
type: ActionType,
|
||||
): number | string {
|
||||
return isItemDTO(item, type) ? item.id : item.product.catalogProductNumber;
|
||||
}
|
||||
|
||||
export function mapToOlaAvailability({
|
||||
availability,
|
||||
item,
|
||||
quantity,
|
||||
type,
|
||||
}: {
|
||||
availability: AvailabilityDTO;
|
||||
item: ItemDTO | ShoppingCartItemDTO;
|
||||
quantity: number;
|
||||
type: ActionType;
|
||||
}): OLAAvailabilityDTO {
|
||||
return {
|
||||
status: availability?.availabilityType,
|
||||
at: availability?.estimatedShippingDate,
|
||||
ean: item?.product?.ean,
|
||||
itemId: Number(getCatalogId(item, type)),
|
||||
format: item?.product?.format,
|
||||
isPrebooked: availability?.isPrebooked,
|
||||
logisticianId: availability?.logistician?.id,
|
||||
price: availability?.price,
|
||||
qty: quantity,
|
||||
ssc: availability?.ssc,
|
||||
sscText: availability?.sscText,
|
||||
supplierId: availability?.supplier?.id,
|
||||
supplierProductNumber: availability?.supplierProductNumber,
|
||||
};
|
||||
}
|
||||
|
||||
export function getOrderTypeForPurchaseOption(
|
||||
purchaseOption: PurchaseOption,
|
||||
): OrderType | undefined {
|
||||
switch (purchaseOption) {
|
||||
case 'delivery':
|
||||
case 'dig-delivery':
|
||||
case 'b2b-delivery':
|
||||
return 'Versand';
|
||||
case 'pickup':
|
||||
return 'Abholung';
|
||||
case 'in-store':
|
||||
return 'Rücklage';
|
||||
case 'download':
|
||||
return 'Download';
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function getPurchaseOptionForOrderType(
|
||||
orderType: OrderType,
|
||||
): PurchaseOption | undefined {
|
||||
switch (orderType) {
|
||||
case 'Versand':
|
||||
return 'delivery';
|
||||
case 'Abholung':
|
||||
return 'pickup';
|
||||
case 'Rücklage':
|
||||
return 'in-store';
|
||||
case 'Download':
|
||||
return 'download';
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,178 +1,240 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { DomainAvailabilityService } from '@domain/availability';
|
||||
import { DomainCheckoutService } from '@domain/checkout';
|
||||
import {
|
||||
AddToShoppingCartDTO,
|
||||
AvailabilityDTO,
|
||||
EntityDTOContainerOfDestinationDTO,
|
||||
ItemPayload,
|
||||
ItemsResult,
|
||||
ShoppingCartDTO,
|
||||
UpdateShoppingCartItemDTO,
|
||||
} from '@generated/swagger/checkout-api';
|
||||
import { Observable } from 'rxjs';
|
||||
import { map, shareReplay, take } from 'rxjs/operators';
|
||||
import { Branch, ItemData } from './purchase-options.types';
|
||||
import { memorize } from '@utils/common';
|
||||
import { AuthService } from '@core/auth';
|
||||
import { ApplicationService } from '@core/application';
|
||||
import { DomainOmsService } from '@domain/oms';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class PurchaseOptionsService {
|
||||
constructor(
|
||||
private _availabilityService: DomainAvailabilityService,
|
||||
private _checkoutService: DomainCheckoutService,
|
||||
private _omsService: DomainOmsService,
|
||||
private _auth: AuthService,
|
||||
private _app: ApplicationService,
|
||||
) {}
|
||||
|
||||
getVats$() {
|
||||
return this._omsService.getVATs();
|
||||
}
|
||||
|
||||
getSelectedBranchForProcess(processId: number): Observable<Branch> {
|
||||
return this._app.getSelectedBranch$(processId).pipe(take(1), shareReplay(1));
|
||||
}
|
||||
|
||||
getCustomerFeatures(processId: number): Observable<Record<string, string>> {
|
||||
return this._checkoutService.getCustomerFeatures({ processId }).pipe(take(1), shareReplay(1));
|
||||
}
|
||||
|
||||
@memorize()
|
||||
fetchDefaultBranch(): Observable<Branch> {
|
||||
return this.getBranch({ branchNumber: this._auth.getClaimByKey('branch_no') }).pipe(take(1), shareReplay(1));
|
||||
}
|
||||
|
||||
fetchPickupAvailability(item: ItemData, quantity: number, branch: Branch): Observable<AvailabilityDTO> {
|
||||
return this._availabilityService
|
||||
.getPickUpAvailability({
|
||||
branch,
|
||||
quantity,
|
||||
item,
|
||||
})
|
||||
.pipe(map((res) => (Array.isArray(res) ? res[0] : undefined)));
|
||||
}
|
||||
|
||||
fetchInStoreAvailability(item: ItemData, quantity: number, branch: Branch): Observable<AvailabilityDTO> {
|
||||
return this._availabilityService.getTakeAwayAvailability({
|
||||
item,
|
||||
quantity,
|
||||
branch,
|
||||
});
|
||||
}
|
||||
|
||||
fetchDeliveryAvailability(item: ItemData, quantity: number): Observable<AvailabilityDTO> {
|
||||
return this._availabilityService.getDeliveryAvailability({
|
||||
item,
|
||||
quantity,
|
||||
});
|
||||
}
|
||||
|
||||
fetchDigDeliveryAvailability(item: ItemData, quantity: number): Observable<AvailabilityDTO> {
|
||||
return this._availabilityService.getDigDeliveryAvailability({
|
||||
item,
|
||||
quantity,
|
||||
});
|
||||
}
|
||||
|
||||
fetchB2bDeliveryAvailability(item: ItemData, quantity: number): Observable<AvailabilityDTO> {
|
||||
return this._availabilityService.getB2bDeliveryAvailability({
|
||||
item,
|
||||
quantity,
|
||||
});
|
||||
}
|
||||
|
||||
fetchDownloadAvailability(item: ItemData): Observable<AvailabilityDTO> {
|
||||
return this._availabilityService.getDownloadAvailability({
|
||||
item,
|
||||
});
|
||||
}
|
||||
|
||||
isAvailable(availability: AvailabilityDTO): boolean {
|
||||
return this._availabilityService.isAvailable({ availability });
|
||||
}
|
||||
|
||||
fetchCanAdd(processId: number, orderType: string, payload: ItemPayload[]): Observable<ItemsResult[]> {
|
||||
return this._checkoutService.canAddItems({
|
||||
processId,
|
||||
orderType,
|
||||
payload,
|
||||
});
|
||||
}
|
||||
|
||||
removeItemFromShoppingCart(processId: number, shoppingCartItemId: number): Promise<ShoppingCartDTO> {
|
||||
return this._checkoutService
|
||||
.updateItemInShoppingCart({
|
||||
processId,
|
||||
shoppingCartItemId,
|
||||
update: {
|
||||
availability: null,
|
||||
quantity: 0,
|
||||
},
|
||||
})
|
||||
.toPromise();
|
||||
}
|
||||
|
||||
getInStoreDestination(branch: Branch): EntityDTOContainerOfDestinationDTO {
|
||||
return {
|
||||
data: { target: 1, targetBranch: { id: branch.id } },
|
||||
};
|
||||
}
|
||||
|
||||
getPickupDestination(branch: Branch): EntityDTOContainerOfDestinationDTO {
|
||||
return {
|
||||
data: { target: 1, targetBranch: { id: branch.id } },
|
||||
};
|
||||
}
|
||||
|
||||
getDeliveryDestination(availability: AvailabilityDTO): EntityDTOContainerOfDestinationDTO {
|
||||
return {
|
||||
data: { target: 2, logistician: availability?.logistician },
|
||||
};
|
||||
}
|
||||
|
||||
getDownloadDestination(availability: AvailabilityDTO): EntityDTOContainerOfDestinationDTO {
|
||||
return {
|
||||
data: { target: 16, logistician: availability?.logistician },
|
||||
};
|
||||
}
|
||||
|
||||
addItemToShoppingCart(processId: number, items: AddToShoppingCartDTO[]) {
|
||||
return this._checkoutService.addItemToShoppingCart({
|
||||
processId,
|
||||
items,
|
||||
});
|
||||
}
|
||||
|
||||
updateItemInShoppingCart(processId: number, shoppingCartItemId: number, payload: UpdateShoppingCartItemDTO) {
|
||||
return this._checkoutService.updateItemInShoppingCart({
|
||||
processId,
|
||||
shoppingCartItemId,
|
||||
update: payload,
|
||||
});
|
||||
}
|
||||
|
||||
@memorize({ comparer: (_) => true })
|
||||
getBranches(): Observable<Branch[]> {
|
||||
return this._availabilityService.getBranches().pipe(
|
||||
map((branches) => {
|
||||
return branches.filter((branch) => branch.isShippingEnabled == true);
|
||||
}),
|
||||
shareReplay(1),
|
||||
);
|
||||
}
|
||||
|
||||
getBranch(params: { id: number }): Observable<Branch>;
|
||||
getBranch(params: { branchNumber: string }): Observable<Branch>;
|
||||
getBranch(params: { id: number; branchNumber: string }): Observable<Branch>;
|
||||
getBranch(params: { id?: number; branchNumber?: string }): Observable<Branch> {
|
||||
return this.getBranches().pipe(
|
||||
map((branches) => {
|
||||
const branch = branches.find((branch) => branch.id == params.id || branch.branchNumber == params.branchNumber);
|
||||
return branch;
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
import { inject, Injectable } from '@angular/core';
|
||||
import { DomainAvailabilityService } from '@domain/availability';
|
||||
import { DomainCheckoutService } from '@domain/checkout';
|
||||
import {
|
||||
AddToShoppingCartDTO,
|
||||
AvailabilityDTO,
|
||||
EntityDTOContainerOfDestinationDTO,
|
||||
ItemPayload,
|
||||
ItemsResult,
|
||||
ShoppingCartDTO,
|
||||
UpdateShoppingCartItemDTO,
|
||||
} from '@generated/swagger/checkout-api';
|
||||
import { Observable } from 'rxjs';
|
||||
import { map, shareReplay, take } from 'rxjs/operators';
|
||||
import { Branch, ItemData } from './purchase-options.types';
|
||||
import { memorize } from '@utils/common';
|
||||
import { AuthService } from '@core/auth';
|
||||
import { ApplicationService } from '@core/application';
|
||||
import { DomainOmsService } from '@domain/oms';
|
||||
import { OrderType, PurchaseOptionsFacade } from '@isa/checkout/data-access';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class PurchaseOptionsService {
|
||||
#purchaseOptionsFacade = inject(PurchaseOptionsFacade);
|
||||
|
||||
constructor(
|
||||
private _availabilityService: DomainAvailabilityService,
|
||||
private _checkoutService: DomainCheckoutService,
|
||||
private _omsService: DomainOmsService,
|
||||
private _auth: AuthService,
|
||||
private _app: ApplicationService,
|
||||
) {}
|
||||
|
||||
getVats$() {
|
||||
return this._omsService.getVATs();
|
||||
}
|
||||
|
||||
getSelectedBranchForProcess(processId: number): Observable<Branch> {
|
||||
return this._app
|
||||
.getSelectedBranch$(processId)
|
||||
.pipe(take(1), shareReplay(1));
|
||||
}
|
||||
|
||||
getCustomerFeatures(processId: number): Observable<Record<string, string>> {
|
||||
return this._checkoutService
|
||||
.getCustomerFeatures({ processId })
|
||||
.pipe(take(1), shareReplay(1));
|
||||
}
|
||||
|
||||
@memorize()
|
||||
fetchDefaultBranch(): Observable<Branch> {
|
||||
return this.getBranch({
|
||||
branchNumber: this._auth.getClaimByKey('branch_no'),
|
||||
}).pipe(take(1), shareReplay(1));
|
||||
}
|
||||
|
||||
fetchPickupAvailability(
|
||||
item: ItemData,
|
||||
quantity: number,
|
||||
branch: Branch,
|
||||
): Observable<AvailabilityDTO> {
|
||||
return this._availabilityService
|
||||
.getPickUpAvailability({
|
||||
branch,
|
||||
quantity,
|
||||
item,
|
||||
})
|
||||
.pipe(map((res) => (Array.isArray(res) ? res[0] : undefined)));
|
||||
}
|
||||
|
||||
fetchInStoreAvailability(
|
||||
item: ItemData,
|
||||
quantity: number,
|
||||
branch: Branch,
|
||||
): Observable<AvailabilityDTO> {
|
||||
return this._availabilityService.getTakeAwayAvailability({
|
||||
item,
|
||||
quantity,
|
||||
branch,
|
||||
});
|
||||
}
|
||||
|
||||
fetchDeliveryAvailability(
|
||||
item: ItemData,
|
||||
quantity: number,
|
||||
): Observable<AvailabilityDTO> {
|
||||
return this._availabilityService.getDeliveryAvailability({
|
||||
item,
|
||||
quantity,
|
||||
});
|
||||
}
|
||||
|
||||
fetchDigDeliveryAvailability(
|
||||
item: ItemData,
|
||||
quantity: number,
|
||||
): Observable<AvailabilityDTO> {
|
||||
return this._availabilityService.getDigDeliveryAvailability({
|
||||
item,
|
||||
quantity,
|
||||
});
|
||||
}
|
||||
|
||||
fetchB2bDeliveryAvailability(
|
||||
item: ItemData,
|
||||
quantity: number,
|
||||
): Observable<AvailabilityDTO> {
|
||||
return this._availabilityService.getB2bDeliveryAvailability({
|
||||
item,
|
||||
quantity,
|
||||
});
|
||||
}
|
||||
|
||||
fetchDownloadAvailability(item: ItemData): Observable<AvailabilityDTO> {
|
||||
return this._availabilityService.getDownloadAvailability({
|
||||
item,
|
||||
});
|
||||
}
|
||||
|
||||
isAvailable(availability: AvailabilityDTO): boolean {
|
||||
return this._availabilityService.isAvailable({ availability });
|
||||
}
|
||||
|
||||
fetchCanAdd(
|
||||
shoppingCartId: number,
|
||||
orderType: OrderType,
|
||||
payload: ItemPayload[],
|
||||
customerFeatures: Record<string, string>,
|
||||
): Promise<ItemsResult[]> {
|
||||
return this.#purchaseOptionsFacade.canAddItems({
|
||||
shoppingCartId,
|
||||
payload: payload.map((p) => ({
|
||||
...p,
|
||||
customerFeatures: customerFeatures,
|
||||
orderType: orderType,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
removeItemFromShoppingCart(
|
||||
shoppingCartId: number,
|
||||
shoppingCartItemId: number,
|
||||
): Promise<ShoppingCartDTO> {
|
||||
const shoppingCart = this.#purchaseOptionsFacade.removeItem({
|
||||
shoppingCartId,
|
||||
shoppingCartItemId,
|
||||
});
|
||||
|
||||
return shoppingCart;
|
||||
}
|
||||
|
||||
getInStoreDestination(branch: Branch): EntityDTOContainerOfDestinationDTO {
|
||||
return {
|
||||
data: { target: 1, targetBranch: { id: branch.id } },
|
||||
};
|
||||
}
|
||||
|
||||
getPickupDestination(branch: Branch): EntityDTOContainerOfDestinationDTO {
|
||||
return {
|
||||
data: { target: 1, targetBranch: { id: branch.id } },
|
||||
};
|
||||
}
|
||||
|
||||
getDeliveryDestination(
|
||||
availability: AvailabilityDTO,
|
||||
): EntityDTOContainerOfDestinationDTO {
|
||||
return {
|
||||
data: { target: 2, logistician: availability?.logistician },
|
||||
};
|
||||
}
|
||||
|
||||
getDownloadDestination(
|
||||
availability: AvailabilityDTO,
|
||||
): EntityDTOContainerOfDestinationDTO {
|
||||
return {
|
||||
data: { target: 16, logistician: availability?.logistician },
|
||||
};
|
||||
}
|
||||
|
||||
async addItemToShoppingCart(
|
||||
shoppingCartId: number,
|
||||
items: AddToShoppingCartDTO[],
|
||||
) {
|
||||
const shoppingCart = await this.#purchaseOptionsFacade.addItem({
|
||||
shoppingCartId,
|
||||
items,
|
||||
});
|
||||
console.log('added item to cart', { shoppingCart });
|
||||
this._checkoutService.updateProcessCount(
|
||||
this._app.activatedProcessId,
|
||||
shoppingCart,
|
||||
);
|
||||
return shoppingCart;
|
||||
}
|
||||
|
||||
async updateItemInShoppingCart(
|
||||
shoppingCartId: number,
|
||||
shoppingCartItemId: number,
|
||||
payload: UpdateShoppingCartItemDTO,
|
||||
) {
|
||||
const shoppingCart = await this.#purchaseOptionsFacade.updateItem({
|
||||
shoppingCartId,
|
||||
shoppingCartItemId,
|
||||
values: payload,
|
||||
});
|
||||
console.log('updated item in cart', { shoppingCart });
|
||||
this._checkoutService.updateProcessCount(
|
||||
this._app.activatedProcessId,
|
||||
shoppingCart,
|
||||
);
|
||||
}
|
||||
|
||||
@memorize({ comparer: (_) => true })
|
||||
getBranches(): Observable<Branch[]> {
|
||||
return this._availabilityService.getBranches().pipe(
|
||||
map((branches) => {
|
||||
return branches.filter((branch) => branch.isShippingEnabled == true);
|
||||
}),
|
||||
shareReplay(1),
|
||||
);
|
||||
}
|
||||
|
||||
getBranch(params: { id: number }): Observable<Branch>;
|
||||
getBranch(params: { branchNumber: string }): Observable<Branch>;
|
||||
getBranch(params: { id: number; branchNumber: string }): Observable<Branch>;
|
||||
getBranch(params: {
|
||||
id?: number;
|
||||
branchNumber?: string;
|
||||
}): Observable<Branch> {
|
||||
return this.getBranches().pipe(
|
||||
map((branches) => {
|
||||
const branch = branches.find(
|
||||
(branch) =>
|
||||
branch.id == params.id ||
|
||||
branch.branchNumber == params.branchNumber,
|
||||
);
|
||||
return branch;
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,38 +1,40 @@
|
||||
import { PriceDTO } from '@generated/swagger/checkout-api';
|
||||
import {
|
||||
ActionType,
|
||||
Availability,
|
||||
Branch,
|
||||
CanAdd,
|
||||
FetchingAvailability,
|
||||
Item,
|
||||
PurchaseOption,
|
||||
} from './purchase-options.types';
|
||||
|
||||
export interface PurchaseOptionsState {
|
||||
type: ActionType;
|
||||
|
||||
processId: number;
|
||||
|
||||
items: Item[];
|
||||
|
||||
availabilities: Availability[];
|
||||
|
||||
canAddResults: CanAdd[];
|
||||
|
||||
purchaseOption: PurchaseOption;
|
||||
|
||||
selectedItemIds: number[];
|
||||
|
||||
prices: { [itemId: number]: PriceDTO };
|
||||
|
||||
defaultBranch: Branch;
|
||||
|
||||
pickupBranch: Branch;
|
||||
|
||||
inStoreBranch: Branch;
|
||||
|
||||
customerFeatures: Record<string, string>;
|
||||
|
||||
fetchingAvailabilities: Array<FetchingAvailability>;
|
||||
}
|
||||
import { PriceDTO } from '@generated/swagger/checkout-api';
|
||||
import {
|
||||
ActionType,
|
||||
Availability,
|
||||
Branch,
|
||||
CanAdd,
|
||||
FetchingAvailability,
|
||||
Item,
|
||||
PurchaseOption,
|
||||
} from './purchase-options.types';
|
||||
|
||||
export interface PurchaseOptionsState {
|
||||
shoppingCartId: number;
|
||||
|
||||
type: ActionType;
|
||||
|
||||
items: Item[];
|
||||
|
||||
availabilities: Availability[];
|
||||
|
||||
canAddResults: CanAdd[];
|
||||
|
||||
purchaseOption: PurchaseOption;
|
||||
|
||||
selectedItemIds: number[];
|
||||
|
||||
prices: { [itemId: number]: PriceDTO };
|
||||
|
||||
defaultBranch: Branch;
|
||||
|
||||
pickupBranch: Branch;
|
||||
|
||||
inStoreBranch: Branch;
|
||||
|
||||
customerFeatures: Record<string, string>;
|
||||
|
||||
fetchingAvailabilities: Array<FetchingAvailability>;
|
||||
|
||||
useRedemptionPoints: boolean;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,37 +1,56 @@
|
||||
import { ItemData as AvailabilityItemData } from '@domain/availability';
|
||||
import { ItemDTO } from '@generated/swagger/cat-search-api';
|
||||
import { AvailabilityDTO, BranchDTO, ItemPayload, ShoppingCartItemDTO } from '@generated/swagger/checkout-api';
|
||||
|
||||
export type ActionType = 'add' | 'update';
|
||||
|
||||
export type PurchaseOption =
|
||||
| 'delivery'
|
||||
| 'dig-delivery'
|
||||
| 'b2b-delivery'
|
||||
| 'pickup'
|
||||
| 'in-store'
|
||||
| 'download'
|
||||
| 'catalog';
|
||||
|
||||
export type OrderType = 'Rücklage' | 'Abholung' | 'Versand' | 'Download';
|
||||
|
||||
export type ItemDTOWithQuantity = ItemDTO & { quantity?: number };
|
||||
|
||||
export type Item = ItemDTOWithQuantity | ShoppingCartItemDTO;
|
||||
|
||||
export type Branch = BranchDTO;
|
||||
|
||||
export type Availability = {
|
||||
itemId: number;
|
||||
purchaseOption: PurchaseOption;
|
||||
data: AvailabilityDTO & { priceMaintained?: boolean; orderDeadline?: string; firstDayOfSale?: string };
|
||||
ean?: string;
|
||||
};
|
||||
|
||||
export type ItemData = AvailabilityItemData & { sourceId: number; quantity: number };
|
||||
|
||||
export type ItemPayloadWithSourceId = ItemPayload & { sourceId: number };
|
||||
|
||||
export type CanAdd = { itemId: number; purchaseOption: PurchaseOption; canAdd: boolean; message?: string };
|
||||
|
||||
export type FetchingAvailability = { id: string; itemId: number; purchaseOption?: PurchaseOption };
|
||||
import { ItemData as AvailabilityItemData } from '@domain/availability';
|
||||
import { ItemDTO } from '@generated/swagger/cat-search-api';
|
||||
import {
|
||||
AvailabilityDTO,
|
||||
BranchDTO,
|
||||
ItemPayload,
|
||||
ShoppingCartItemDTO,
|
||||
} from '@generated/swagger/checkout-api';
|
||||
|
||||
export type ActionType = 'add' | 'update';
|
||||
|
||||
export type PurchaseOption =
|
||||
| 'delivery'
|
||||
| 'dig-delivery'
|
||||
| 'b2b-delivery'
|
||||
| 'pickup'
|
||||
| 'in-store'
|
||||
| 'download'
|
||||
| 'catalog';
|
||||
|
||||
export type ItemDTOWithQuantity = ItemDTO & { quantity?: number };
|
||||
|
||||
export type Item = ItemDTOWithQuantity | ShoppingCartItemDTO;
|
||||
|
||||
export type Branch = BranchDTO;
|
||||
|
||||
export type Availability = {
|
||||
itemId: number;
|
||||
purchaseOption: PurchaseOption;
|
||||
data: AvailabilityDTO & {
|
||||
priceMaintained?: boolean;
|
||||
orderDeadline?: string;
|
||||
firstDayOfSale?: string;
|
||||
};
|
||||
ean?: string;
|
||||
};
|
||||
|
||||
export type ItemData = AvailabilityItemData & {
|
||||
sourceId: number;
|
||||
quantity: number;
|
||||
};
|
||||
|
||||
export type ItemPayloadWithSourceId = ItemPayload & { sourceId: number };
|
||||
|
||||
export type CanAdd = {
|
||||
itemId: number;
|
||||
purchaseOption: PurchaseOption;
|
||||
canAdd: boolean;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
export type FetchingAvailability = {
|
||||
id: string;
|
||||
itemId: number;
|
||||
purchaseOption?: PurchaseOption;
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -15,6 +15,14 @@ import { IconModule } from '@shared/components/icon';
|
||||
import { ArticleDetailsTextComponent } from './article-details-text/article-details-text.component';
|
||||
import { IconBadgeComponent } from 'apps/isa-app/src/shared/components/icon/badge/icon-badge.component';
|
||||
import { MatomoModule } from 'ngx-matomo-client';
|
||||
import {
|
||||
SelectedRewardShoppingCartResource,
|
||||
SelectedShoppingCartResource,
|
||||
} from '@isa/checkout/data-access';
|
||||
import {
|
||||
RewardSelectionService,
|
||||
RewardSelectionPopUpService,
|
||||
} from '@isa/checkout/shared/reward-selection-dialog';
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
@@ -35,5 +43,11 @@ import { MatomoModule } from 'ngx-matomo-client';
|
||||
],
|
||||
exports: [ArticleDetailsComponent, ArticleRecommendationsComponent],
|
||||
declarations: [ArticleDetailsComponent, ArticleRecommendationsComponent],
|
||||
providers: [
|
||||
SelectedShoppingCartResource,
|
||||
SelectedRewardShoppingCartResource,
|
||||
RewardSelectionService,
|
||||
RewardSelectionPopUpService,
|
||||
],
|
||||
})
|
||||
export class ArticleDetailsModule {}
|
||||
|
||||
@@ -13,8 +13,12 @@
|
||||
keinen Artikel hinzugefügt.
|
||||
</p>
|
||||
<div class="btn-wrapper">
|
||||
<a class="cta-primary" [routerLink]="productSearchBasePath">Artikel suchen</a>
|
||||
<button class="cta-secondary" (click)="openDummyModal({})">Neuanlage</button>
|
||||
<a class="cta-primary" [routerLink]="productSearchBasePath"
|
||||
>Artikel suchen</a
|
||||
>
|
||||
<button class="cta-secondary" (click)="openDummyModal({})">
|
||||
Neuanlage
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -24,11 +28,22 @@
|
||||
<div class="cta-print-wrapper">
|
||||
<button class="cta-print" (click)="openPrintModal()">Drucken</button>
|
||||
</div>
|
||||
<h1 class="header">Warenkorb</h1>
|
||||
<div class="header-container">
|
||||
<h1 class="header">Warenkorb</h1>
|
||||
@if (orderTypesExist$ | async) {
|
||||
<lib-reward-selection-trigger
|
||||
class="pb-2 desktop-large:pb-0"
|
||||
></lib-reward-selection-trigger>
|
||||
}
|
||||
</div>
|
||||
@if (!(isDesktop$ | async)) {
|
||||
<page-checkout-review-details></page-checkout-review-details>
|
||||
}
|
||||
@for (group of groupedItems$ | async; track trackByGroupedItems($index, group); let lastGroup = $last) {
|
||||
@for (
|
||||
group of groupedItems$ | async;
|
||||
track trackByGroupedItems($index, group);
|
||||
let lastGroup = $last
|
||||
) {
|
||||
@if (group?.orderType !== undefined) {
|
||||
<hr />
|
||||
<div class="row item-group-header bg-[#F5F7FA]">
|
||||
@@ -40,20 +55,31 @@
|
||||
></shared-icon>
|
||||
}
|
||||
<div class="label" [class.dummy]="group.orderType === 'Dummy'">
|
||||
{{ group.orderType !== 'Dummy' ? group.orderType : 'Manuelle Anlage / Dummy Bestellung' }}
|
||||
{{
|
||||
group.orderType !== 'Dummy'
|
||||
? group.orderType
|
||||
: 'Manuelle Anlage / Dummy Bestellung'
|
||||
}}
|
||||
@if (group.orderType === 'Dummy') {
|
||||
<button
|
||||
class="text-brand border-none font-bold text-p1 outline-none pl-4"
|
||||
(click)="openDummyModal({ changeDataFromCart: true })"
|
||||
>
|
||||
>
|
||||
Hinzufügen
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<div class="grow"></div>
|
||||
@if (group.orderType !== 'Download' && group.orderType !== 'Dummy') {
|
||||
@if (
|
||||
group.orderType !== 'Download' && group.orderType !== 'Dummy'
|
||||
) {
|
||||
<div class="pl-4">
|
||||
<button class="cta-edit" (click)="showPurchasingListModal(group.items)">Ändern</button>
|
||||
<button
|
||||
class="cta-edit"
|
||||
(click)="showPurchasingListModal(group.items)"
|
||||
>
|
||||
Ändern
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -62,20 +88,44 @@
|
||||
group.orderType === 'Versand' ||
|
||||
group.orderType === 'B2B-Versand' ||
|
||||
group.orderType === 'DIG-Versand'
|
||||
) {
|
||||
<hr
|
||||
/>
|
||||
) {
|
||||
<hr />
|
||||
}
|
||||
}
|
||||
@for (item of group.items; track trackByItemId(i, item); let lastItem = $last; let i = $index) {
|
||||
@if (group?.orderType !== undefined && (item.features?.orderType === 'Abholung' || item.features?.orderType === 'Rücklage')) {
|
||||
@for (
|
||||
item of group.items;
|
||||
track trackByItemId(i, item);
|
||||
let lastItem = $last;
|
||||
let i = $index
|
||||
) {
|
||||
@if (
|
||||
group?.orderType !== undefined &&
|
||||
(item.features?.orderType === 'Abholung' ||
|
||||
item.features?.orderType === 'Rücklage')
|
||||
) {
|
||||
@if (item?.destination?.data?.targetBranch?.data; as targetBranch) {
|
||||
@if (i === 0 || checkIfMultipleDestinationsForOrderTypeExist(targetBranch, group, i)) {
|
||||
@if (
|
||||
i === 0 ||
|
||||
checkIfMultipleDestinationsForOrderTypeExist(
|
||||
targetBranch,
|
||||
group,
|
||||
i
|
||||
)
|
||||
) {
|
||||
<div
|
||||
class="flex flex-row items-center px-5 pt-0 pb-[0.875rem] -mt-2 bg-[#F5F7FA]"
|
||||
[class.multiple-destinations]="checkIfMultipleDestinationsForOrderTypeExist(targetBranch, group, i)"
|
||||
[class.multiple-destinations]="
|
||||
checkIfMultipleDestinationsForOrderTypeExist(
|
||||
targetBranch,
|
||||
group,
|
||||
i
|
||||
)
|
||||
"
|
||||
>
|
||||
<span class="branch-name"
|
||||
>{{ targetBranch?.name }} |
|
||||
{{ targetBranch | branchAddress }}</span
|
||||
>
|
||||
<span class="branch-name">{{ targetBranch?.name }} | {{ targetBranch | branchAddress }}</span>
|
||||
</div>
|
||||
<hr />
|
||||
}
|
||||
@@ -85,7 +135,9 @@
|
||||
(changeItem)="changeItem($event)"
|
||||
(changeDummyItem)="changeDummyItem($event)"
|
||||
(changeQuantity)="updateItemQuantity($event)"
|
||||
[quantityError]="(quantityError$ | async)[item.product.catalogProductNumber]"
|
||||
[quantityError]="
|
||||
(quantityError$ | async)[item.product.catalogProductNumber]
|
||||
"
|
||||
[item]="item"
|
||||
[orderType]="group?.orderType"
|
||||
[loadingOnItemChangeById]="loadingOnItemChangeById$ | async"
|
||||
@@ -109,7 +161,11 @@
|
||||
}
|
||||
<div class="flex flex-col w-full">
|
||||
<strong class="total-value">
|
||||
Zwischensumme {{ shoppingCart?.total?.value | currency: shoppingCart?.total?.currency : 'code' }}
|
||||
Zwischensumme
|
||||
{{
|
||||
shoppingCart?.total?.value
|
||||
| currency: shoppingCart?.total?.currency : 'code'
|
||||
}}
|
||||
</strong>
|
||||
<span class="shipping-cost-info">ohne Versandkosten</span>
|
||||
</div>
|
||||
@@ -119,11 +175,13 @@
|
||||
(click)="order()"
|
||||
[disabled]="
|
||||
showOrderButtonSpinner ||
|
||||
((primaryCtaLabel$ | async) === 'Bestellen' && !(checkNotificationChannelControl$ | async)) ||
|
||||
((primaryCtaLabel$ | async) === 'Bestellen' &&
|
||||
!(checkNotificationChannelControl$ | async)) ||
|
||||
notificationsControl?.invalid ||
|
||||
((primaryCtaLabel$ | async) === 'Bestellen' && ((checkingOla$ | async) || (checkoutIsInValid$ | async)))
|
||||
((primaryCtaLabel$ | async) === 'Bestellen' &&
|
||||
((checkingOla$ | async) || (checkoutIsInValid$ | async)))
|
||||
"
|
||||
>
|
||||
>
|
||||
<ui-spinner [show]="showOrderButtonSpinner">
|
||||
{{ primaryCtaLabel$ | async }}
|
||||
</ui-spinner>
|
||||
@@ -137,4 +195,3 @@
|
||||
<ui-spinner [show]="true"></ui-spinner>
|
||||
</div>
|
||||
}
|
||||
|
||||
|
||||
@@ -72,8 +72,12 @@ button {
|
||||
@apply text-lg;
|
||||
}
|
||||
|
||||
.header-container {
|
||||
@apply flex flex-col items-center justify-center desktop-large:pb-10 -mt-2;
|
||||
}
|
||||
|
||||
.header {
|
||||
@apply text-center text-h2 desktop-large:pb-10 -mt-2;
|
||||
@apply text-center text-h2;
|
||||
}
|
||||
|
||||
hr {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -19,7 +19,11 @@ import { CheckoutReviewDetailsComponent } from './details/checkout-review-detail
|
||||
import { CheckoutReviewStore } from './checkout-review.store';
|
||||
import { IconModule } from '@shared/components/icon';
|
||||
import { TextFieldModule } from '@angular/cdk/text-field';
|
||||
import { LoaderComponent, SkeletonLoaderComponent } from '@shared/components/loader';
|
||||
import {
|
||||
LoaderComponent,
|
||||
SkeletonLoaderComponent,
|
||||
} from '@shared/components/loader';
|
||||
import { RewardSelectionTriggerComponent } from '@isa/checkout/shared/reward-selection-dialog';
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
@@ -40,6 +44,7 @@ import { LoaderComponent, SkeletonLoaderComponent } from '@shared/components/loa
|
||||
TextFieldModule,
|
||||
LoaderComponent,
|
||||
SkeletonLoaderComponent,
|
||||
RewardSelectionTriggerComponent,
|
||||
],
|
||||
exports: [CheckoutReviewComponent, CheckoutReviewDetailsComponent],
|
||||
declarations: [
|
||||
|
||||
@@ -1,185 +1,237 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { UntypedFormGroup } from '@angular/forms';
|
||||
import { ApplicationService } from '@core/application';
|
||||
import { DomainCheckoutService } from '@domain/checkout';
|
||||
import { ComponentStore } from '@ngrx/component-store';
|
||||
import { tapResponse } from '@ngrx/operators';
|
||||
|
||||
import { NotificationChannel, PayerDTO, ShoppingCartDTO, ShoppingCartItemDTO } from '@generated/swagger/checkout-api';
|
||||
import { UiErrorModalComponent, UiModalService } from '@ui/modal';
|
||||
import { BehaviorSubject, Subject } from 'rxjs';
|
||||
import { first, map, switchMap, takeUntil, tap, withLatestFrom } from 'rxjs/operators';
|
||||
|
||||
export interface CheckoutReviewState {
|
||||
payer: PayerDTO;
|
||||
shoppingCart: ShoppingCartDTO;
|
||||
shoppingCartItems: ShoppingCartItemDTO[];
|
||||
fetching: boolean;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class CheckoutReviewStore extends ComponentStore<CheckoutReviewState> {
|
||||
orderCompleted = new Subject<void>();
|
||||
|
||||
get shoppingCart() {
|
||||
return this.get((s) => s.shoppingCart);
|
||||
}
|
||||
set shoppingCart(shoppingCart: ShoppingCartDTO) {
|
||||
this.patchState({ shoppingCart });
|
||||
}
|
||||
readonly shoppingCart$ = this.select((s) => s.shoppingCart);
|
||||
|
||||
get shoppingCartItems() {
|
||||
return this.get((s) => s.shoppingCartItems);
|
||||
}
|
||||
set shoppingCartItems(shoppingCartItems: ShoppingCartItemDTO[]) {
|
||||
this.patchState({ shoppingCartItems });
|
||||
}
|
||||
readonly shoppingCartItems$ = this.select((s) => s.shoppingCartItems);
|
||||
|
||||
get fetching() {
|
||||
return this.get((s) => s.fetching);
|
||||
}
|
||||
set fetching(fetching: boolean) {
|
||||
this.patchState({ fetching });
|
||||
}
|
||||
readonly fetching$ = this.select((s) => s.fetching);
|
||||
|
||||
customerFeatures$ = this._application.activatedProcessId$.pipe(
|
||||
takeUntil(this.orderCompleted),
|
||||
switchMap((processId) => this._domainCheckoutService.getCustomerFeatures({ processId })),
|
||||
);
|
||||
|
||||
payer$ = this._application.activatedProcessId$.pipe(
|
||||
takeUntil(this.orderCompleted),
|
||||
switchMap((processId) => this._domainCheckoutService.getPayer({ processId })),
|
||||
);
|
||||
|
||||
buyer$ = this._application.activatedProcessId$.pipe(
|
||||
takeUntil(this.orderCompleted),
|
||||
switchMap((processId) => this._domainCheckoutService.getBuyer({ processId })),
|
||||
);
|
||||
|
||||
showBillingAddress$ = this.shoppingCartItems$.pipe(
|
||||
withLatestFrom(this.customerFeatures$),
|
||||
map(
|
||||
([items, customerFeatures]) =>
|
||||
items.some(
|
||||
(item) =>
|
||||
item.features?.orderType === 'Versand' ||
|
||||
item.features?.orderType === 'B2B-Versand' ||
|
||||
item.features?.orderType === 'DIG-Versand',
|
||||
) || !!customerFeatures?.b2b,
|
||||
),
|
||||
);
|
||||
|
||||
checkNotificationChannelControl$ = new BehaviorSubject<boolean>(true);
|
||||
|
||||
notificationChannelLoading$ = new Subject<boolean>();
|
||||
|
||||
notificationsControl: UntypedFormGroup;
|
||||
|
||||
constructor(
|
||||
private _domainCheckoutService: DomainCheckoutService,
|
||||
private _application: ApplicationService,
|
||||
private _uiModal: UiModalService,
|
||||
) {
|
||||
super({ payer: undefined, shoppingCart: undefined, shoppingCartItems: [], fetching: false });
|
||||
}
|
||||
|
||||
loadShoppingCart = this.effect(($) =>
|
||||
$.pipe(
|
||||
tap(() => (this.fetching = true)),
|
||||
withLatestFrom(this._application.activatedProcessId$),
|
||||
switchMap(([_, processId]) => {
|
||||
return this._domainCheckoutService.getShoppingCart({ processId, latest: true }).pipe(
|
||||
tapResponse(
|
||||
(shoppingCart) => {
|
||||
const shoppingCartItems = shoppingCart?.items?.map((item) => item.data) || [];
|
||||
this.patchState({
|
||||
shoppingCart,
|
||||
shoppingCartItems,
|
||||
});
|
||||
},
|
||||
(err) => {},
|
||||
() => {},
|
||||
),
|
||||
);
|
||||
}),
|
||||
tap(() => (this.fetching = false)),
|
||||
),
|
||||
);
|
||||
|
||||
async onNotificationChange(notificationChannels?: NotificationChannel[]) {
|
||||
this.notificationChannelLoading$.next(true);
|
||||
|
||||
try {
|
||||
const control = this.notificationsControl?.getRawValue();
|
||||
const notificationChannel = notificationChannels
|
||||
? (notificationChannels.reduce((val, current) => val | current, 0) as NotificationChannel)
|
||||
: control?.notificationChannel?.selected || 0;
|
||||
const processId = await this._application.activatedProcessId$.pipe(first()).toPromise();
|
||||
const email = control?.notificationChannel?.email;
|
||||
const mobile = control?.notificationChannel?.mobile;
|
||||
|
||||
// Check if E-Mail and Mobilnumber is available if E-Mail or SMS checkbox is active
|
||||
if (notificationChannel === 3 && (!email || !mobile)) {
|
||||
this.checkNotificationChannelControl$.next(false);
|
||||
} else if (notificationChannel === 2 && !mobile) {
|
||||
this.checkNotificationChannelControl$.next(false);
|
||||
} else if (notificationChannel === 1 && !email) {
|
||||
this.checkNotificationChannelControl$.next(false);
|
||||
} else {
|
||||
this.checkNotificationChannelControl$.next(true);
|
||||
}
|
||||
|
||||
// NotificationChannel nur speichern, wenn Haken und Value gesetzt
|
||||
let setNotificationChannel = 0;
|
||||
if ((notificationChannel & 1) === 1 && email) {
|
||||
setNotificationChannel += 1;
|
||||
}
|
||||
if ((notificationChannel & 2) === 2 && mobile) {
|
||||
setNotificationChannel += 2;
|
||||
}
|
||||
|
||||
if (notificationChannel > 0) {
|
||||
this.setCommunicationDetails({ processId, notificationChannel, email, mobile });
|
||||
}
|
||||
this._domainCheckoutService.setNotificationChannels({
|
||||
processId,
|
||||
notificationChannels: (setNotificationChannel as NotificationChannel) || 0,
|
||||
});
|
||||
} catch (error) {
|
||||
this._uiModal.open({
|
||||
content: UiErrorModalComponent,
|
||||
data: error,
|
||||
title: 'Fehler beim setzen des Benachrichtigungskanals',
|
||||
});
|
||||
}
|
||||
|
||||
this.notificationChannelLoading$.next(false);
|
||||
}
|
||||
|
||||
setCommunicationDetails({
|
||||
processId,
|
||||
notificationChannel,
|
||||
email,
|
||||
mobile,
|
||||
}: {
|
||||
processId: number;
|
||||
notificationChannel: number;
|
||||
email: string;
|
||||
mobile: string;
|
||||
}) {
|
||||
const emailValid = this.notificationsControl?.get('notificationChannel')?.get('email')?.valid;
|
||||
const mobileValid = this.notificationsControl?.get('notificationChannel')?.get('mobile')?.valid;
|
||||
|
||||
if (notificationChannel === 3 && emailValid && mobileValid) {
|
||||
this._domainCheckoutService.setBuyerCommunicationDetails({ processId, email, mobile });
|
||||
} else if (notificationChannel === 1 && emailValid) {
|
||||
this._domainCheckoutService.setBuyerCommunicationDetails({ processId, email });
|
||||
} else if (notificationChannel === 2 && mobileValid) {
|
||||
this._domainCheckoutService.setBuyerCommunicationDetails({ processId, mobile });
|
||||
}
|
||||
}
|
||||
}
|
||||
import { Injectable } from '@angular/core';
|
||||
import { UntypedFormGroup } from '@angular/forms';
|
||||
import { ApplicationService } from '@core/application';
|
||||
import { DomainCheckoutService } from '@domain/checkout';
|
||||
import { ComponentStore } from '@ngrx/component-store';
|
||||
import { tapResponse } from '@ngrx/operators';
|
||||
|
||||
import {
|
||||
NotificationChannel,
|
||||
PayerDTO,
|
||||
ShoppingCartDTO,
|
||||
ShoppingCartItemDTO,
|
||||
} from '@generated/swagger/checkout-api';
|
||||
import { UiErrorModalComponent, UiModalService } from '@ui/modal';
|
||||
import { BehaviorSubject, Subject } from 'rxjs';
|
||||
import {
|
||||
first,
|
||||
map,
|
||||
switchMap,
|
||||
takeUntil,
|
||||
tap,
|
||||
withLatestFrom,
|
||||
} from 'rxjs/operators';
|
||||
|
||||
export interface CheckoutReviewState {
|
||||
payer: PayerDTO;
|
||||
shoppingCart: ShoppingCartDTO;
|
||||
shoppingCartItems: ShoppingCartItemDTO[];
|
||||
fetching: boolean;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class CheckoutReviewStore extends ComponentStore<CheckoutReviewState> {
|
||||
orderCompleted = new Subject<void>();
|
||||
|
||||
get shoppingCart() {
|
||||
return this.get((s) => s.shoppingCart);
|
||||
}
|
||||
set shoppingCart(shoppingCart: ShoppingCartDTO) {
|
||||
this.patchState({ shoppingCart });
|
||||
}
|
||||
readonly shoppingCart$ = this.select((s) => s.shoppingCart);
|
||||
|
||||
get shoppingCartItems() {
|
||||
return this.get((s) => s.shoppingCartItems);
|
||||
}
|
||||
set shoppingCartItems(shoppingCartItems: ShoppingCartItemDTO[]) {
|
||||
this.patchState({ shoppingCartItems });
|
||||
}
|
||||
readonly shoppingCartItems$ = this.select((s) => s.shoppingCartItems);
|
||||
|
||||
get fetching() {
|
||||
return this.get((s) => s.fetching);
|
||||
}
|
||||
set fetching(fetching: boolean) {
|
||||
this.patchState({ fetching });
|
||||
}
|
||||
readonly fetching$ = this.select((s) => s.fetching);
|
||||
|
||||
customerFeatures$ = this._application.activatedProcessId$.pipe(
|
||||
takeUntil(this.orderCompleted),
|
||||
switchMap((processId) =>
|
||||
this._domainCheckoutService.getCustomerFeatures({ processId }),
|
||||
),
|
||||
);
|
||||
|
||||
payer$ = this._application.activatedProcessId$.pipe(
|
||||
takeUntil(this.orderCompleted),
|
||||
switchMap((processId) =>
|
||||
this._domainCheckoutService.getPayer({ processId }),
|
||||
),
|
||||
);
|
||||
|
||||
buyer$ = this._application.activatedProcessId$.pipe(
|
||||
takeUntil(this.orderCompleted),
|
||||
switchMap((processId) =>
|
||||
this._domainCheckoutService.getBuyer({ processId }),
|
||||
),
|
||||
);
|
||||
|
||||
showBillingAddress$ = this.shoppingCartItems$.pipe(
|
||||
withLatestFrom(this.customerFeatures$),
|
||||
map(
|
||||
([items, customerFeatures]) =>
|
||||
items.some(
|
||||
(item) =>
|
||||
item.features?.orderType === 'Versand' ||
|
||||
item.features?.orderType === 'B2B-Versand' ||
|
||||
item.features?.orderType === 'DIG-Versand',
|
||||
) || !!customerFeatures?.b2b,
|
||||
),
|
||||
);
|
||||
|
||||
checkNotificationChannelControl$ = new BehaviorSubject<boolean>(true);
|
||||
|
||||
notificationChannelLoading$ = new Subject<boolean>();
|
||||
|
||||
notificationsControl: UntypedFormGroup;
|
||||
|
||||
constructor(
|
||||
private _domainCheckoutService: DomainCheckoutService,
|
||||
private _application: ApplicationService,
|
||||
private _uiModal: UiModalService,
|
||||
) {
|
||||
super({
|
||||
payer: undefined,
|
||||
shoppingCart: undefined,
|
||||
shoppingCartItems: [],
|
||||
fetching: false,
|
||||
});
|
||||
}
|
||||
|
||||
loadShoppingCart = this.effect(($) =>
|
||||
$.pipe(
|
||||
tap(() => (this.fetching = true)),
|
||||
withLatestFrom(this._application.activatedProcessId$),
|
||||
switchMap(([_, processId]) => {
|
||||
return this._domainCheckoutService
|
||||
.getShoppingCart({ processId, latest: true })
|
||||
.pipe(
|
||||
tapResponse(
|
||||
(shoppingCart) => {
|
||||
console.log('Loaded shopping cart', { shoppingCart });
|
||||
const shoppingCartItems =
|
||||
shoppingCart?.items?.map((item) => item.data) || [];
|
||||
this.patchState({
|
||||
shoppingCart,
|
||||
shoppingCartItems,
|
||||
});
|
||||
},
|
||||
(err) => {},
|
||||
() => {},
|
||||
),
|
||||
);
|
||||
}),
|
||||
tap(() => (this.fetching = false)),
|
||||
),
|
||||
);
|
||||
|
||||
async onNotificationChange(notificationChannels?: NotificationChannel[]) {
|
||||
this.notificationChannelLoading$.next(true);
|
||||
|
||||
try {
|
||||
const control = this.notificationsControl?.getRawValue();
|
||||
const notificationChannel = notificationChannels
|
||||
? (notificationChannels.reduce(
|
||||
(val, current) => val | current,
|
||||
0,
|
||||
) as NotificationChannel)
|
||||
: control?.notificationChannel?.selected || 0;
|
||||
const processId = await this._application.activatedProcessId$
|
||||
.pipe(first())
|
||||
.toPromise();
|
||||
const email = control?.notificationChannel?.email;
|
||||
const mobile = control?.notificationChannel?.mobile;
|
||||
|
||||
// Check if E-Mail and Mobilnumber is available if E-Mail or SMS checkbox is active
|
||||
if (notificationChannel === 3 && (!email || !mobile)) {
|
||||
this.checkNotificationChannelControl$.next(false);
|
||||
} else if (notificationChannel === 2 && !mobile) {
|
||||
this.checkNotificationChannelControl$.next(false);
|
||||
} else if (notificationChannel === 1 && !email) {
|
||||
this.checkNotificationChannelControl$.next(false);
|
||||
} else {
|
||||
this.checkNotificationChannelControl$.next(true);
|
||||
}
|
||||
|
||||
// NotificationChannel nur speichern, wenn Haken und Value gesetzt
|
||||
let setNotificationChannel = 0;
|
||||
if ((notificationChannel & 1) === 1 && email) {
|
||||
setNotificationChannel += 1;
|
||||
}
|
||||
if ((notificationChannel & 2) === 2 && mobile) {
|
||||
setNotificationChannel += 2;
|
||||
}
|
||||
|
||||
if (notificationChannel > 0) {
|
||||
this.setCommunicationDetails({
|
||||
processId,
|
||||
notificationChannel,
|
||||
email,
|
||||
mobile,
|
||||
});
|
||||
}
|
||||
this._domainCheckoutService.setNotificationChannels({
|
||||
processId,
|
||||
notificationChannels:
|
||||
(setNotificationChannel as NotificationChannel) || 0,
|
||||
});
|
||||
} catch (error) {
|
||||
this._uiModal.open({
|
||||
content: UiErrorModalComponent,
|
||||
data: error,
|
||||
title: 'Fehler beim setzen des Benachrichtigungskanals',
|
||||
});
|
||||
}
|
||||
|
||||
this.notificationChannelLoading$.next(false);
|
||||
}
|
||||
|
||||
setCommunicationDetails({
|
||||
processId,
|
||||
notificationChannel,
|
||||
email,
|
||||
mobile,
|
||||
}: {
|
||||
processId: number;
|
||||
notificationChannel: number;
|
||||
email: string;
|
||||
mobile: string;
|
||||
}) {
|
||||
const emailValid = this.notificationsControl
|
||||
?.get('notificationChannel')
|
||||
?.get('email')?.valid;
|
||||
const mobileValid = this.notificationsControl
|
||||
?.get('notificationChannel')
|
||||
?.get('mobile')?.valid;
|
||||
|
||||
if (notificationChannel === 3 && emailValid && mobileValid) {
|
||||
this._domainCheckoutService.setBuyerCommunicationDetails({
|
||||
processId,
|
||||
email,
|
||||
mobile,
|
||||
});
|
||||
} else if (notificationChannel === 1 && emailValid) {
|
||||
this._domainCheckoutService.setBuyerCommunicationDetails({
|
||||
processId,
|
||||
email,
|
||||
});
|
||||
} else if (notificationChannel === 2 && mobileValid) {
|
||||
this._domainCheckoutService.setBuyerCommunicationDetails({
|
||||
processId,
|
||||
mobile,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,129 +1,169 @@
|
||||
<div class="item-thumbnail">
|
||||
<a [routerLink]="productSearchDetailsPath" [queryParams]="{ main_qs: item?.product?.ean }">
|
||||
@if (item?.product?.ean | productImage; as thumbnailUrl) {
|
||||
<img loading="lazy" [src]="thumbnailUrl" [alt]="item?.product?.name" />
|
||||
}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="item-contributors">
|
||||
@for (contributor of contributors$ | async; track contributor; let last = $last) {
|
||||
<a
|
||||
[routerLink]="productSearchResultsPath"
|
||||
[queryParams]="{ main_qs: contributor, main_author: 'author' }"
|
||||
(click)="$event?.stopPropagation()"
|
||||
>
|
||||
{{ contributor }}{{ last ? '' : ';' }}
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="item-title font-bold text-h2 mb-4"
|
||||
[class.text-h3]="item?.product?.name?.length >= 40 && isTablet"
|
||||
[class.text-p1]="item?.product?.name?.length >= 50 || !isTablet"
|
||||
[class.text-p2]="item?.product?.name?.length >= 60 && isTablet"
|
||||
[class.text-p3]="item?.product?.name?.length >= 100"
|
||||
>
|
||||
<a [routerLink]="productSearchDetailsPath" [queryParams]="{ main_qs: item?.product?.ean }">{{ item?.product?.name }}</a>
|
||||
</div>
|
||||
|
||||
@if (item?.product?.format && item?.product?.formatDetail) {
|
||||
<div class="item-format">
|
||||
@if (item?.product?.format !== '--') {
|
||||
<img
|
||||
src="assets/images/Icon_{{ item?.product?.format }}.svg"
|
||||
[alt]="item?.product?.formatDetail"
|
||||
/>
|
||||
}
|
||||
{{ item?.product?.formatDetail }}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="item-info text-p2">
|
||||
<div class="mb-1">{{ item?.product?.manufacturer | substr: 25 }} | {{ item?.product?.ean }}</div>
|
||||
<div class="mb-1">
|
||||
{{ item?.product?.volume }}
|
||||
@if (item?.product?.volume && item?.product?.publicationDate) {
|
||||
<span>|</span>
|
||||
}
|
||||
{{ item?.product?.publicationDate | date }}
|
||||
</div>
|
||||
@if (notAvailable$ | async) {
|
||||
<div>
|
||||
<span class="text-brand item-date">Nicht verfügbar</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (refreshingAvailabilit$ | async) {
|
||||
<shared-skeleton-loader class="w-40"></shared-skeleton-loader>
|
||||
} @else {
|
||||
@if (orderType === 'Abholung') {
|
||||
<div class="item-date" [class.availability-changed]="estimatedShippingDateChanged$ | async">
|
||||
Abholung ab {{ item?.availability?.estimatedShippingDate | date }}
|
||||
</div>
|
||||
}
|
||||
@if (orderType === 'Versand' || orderType === 'B2B-Versand' || orderType === 'DIG-Versand') {
|
||||
<div
|
||||
class="item-date"
|
||||
[class.availability-changed]="estimatedShippingDateChanged$ | async"
|
||||
>
|
||||
@if (item?.availability?.estimatedDelivery) {
|
||||
Zustellung zwischen {{ (item?.availability?.estimatedDelivery?.start | date: 'EEE, dd.MM.')?.replace('.', '') }}
|
||||
und
|
||||
{{ (item?.availability?.estimatedDelivery?.stop | date: 'EEE, dd.MM.')?.replace('.', '') }}
|
||||
} @else {
|
||||
Versand {{ item?.availability?.estimatedShippingDate | date }}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@if (olaError$ | async) {
|
||||
<div class="item-availability-message">Artikel nicht verfügbar</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="item-price-stock flex flex-col">
|
||||
<div class="text-p2 font-bold">{{ item?.availability?.price?.value?.value | currency: 'EUR' : 'code' }}</div>
|
||||
<div class="text-p2 font-normal">
|
||||
@if (!(isDummy$ | async)) {
|
||||
<ui-quantity-dropdown
|
||||
[ngModel]="item?.quantity"
|
||||
(ngModelChange)="onChangeQuantity($event)"
|
||||
[showSpinner]="(loadingOnQuantityChangeById$ | async) === item?.id"
|
||||
[disabled]="(loadingOnItemChangeById$ | async) === item?.id"
|
||||
[range]="quantityRange$ | async"
|
||||
></ui-quantity-dropdown>
|
||||
} @else {
|
||||
<div class="mt-2">{{ item?.quantity }}x</div>
|
||||
}
|
||||
</div>
|
||||
@if (quantityError) {
|
||||
<div class="quantity-error">
|
||||
{{ quantityError }}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (orderType !== 'Download') {
|
||||
<div class="actions">
|
||||
@if (!(hasOrderType$ | async)) {
|
||||
<button
|
||||
[disabled]="(loadingOnQuantityChangeById$ | async) === item?.id || (loadingOnItemChangeById$ | async) === item?.id"
|
||||
(click)="onChangeItem()"
|
||||
>
|
||||
<ui-spinner [show]="(loadingOnItemChangeById$ | async) === item?.id">Lieferweg auswählen</ui-spinner>
|
||||
</button>
|
||||
}
|
||||
@if (canEdit$ | async) {
|
||||
<button
|
||||
[disabled]="(loadingOnQuantityChangeById$ | async) === item?.id || (loadingOnItemChangeById$ | async) === item?.id"
|
||||
(click)="onChangeItem()"
|
||||
>
|
||||
<ui-spinner [show]="(loadingOnItemChangeById$ | async) === item?.id">Lieferweg ändern</ui-spinner>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<div class="item-thumbnail">
|
||||
<a
|
||||
[routerLink]="productSearchDetailsPath"
|
||||
[queryParams]="{ main_qs: item?.product?.ean }"
|
||||
>
|
||||
@if (item?.product?.ean | productImage; as thumbnailUrl) {
|
||||
<img loading="lazy" [src]="thumbnailUrl" [alt]="item?.product?.name" />
|
||||
}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="item-contributors">
|
||||
@for (
|
||||
contributor of contributors$ | async;
|
||||
track contributor;
|
||||
let last = $last
|
||||
) {
|
||||
<a
|
||||
[routerLink]="productSearchResultsPath"
|
||||
[queryParams]="{ main_qs: contributor, main_author: 'author' }"
|
||||
(click)="$event?.stopPropagation()"
|
||||
>
|
||||
{{ contributor }}{{ last ? '' : ';' }}
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="item-title font-bold text-h2 mb-4"
|
||||
[class.text-h3]="item?.product?.name?.length >= 40 && isTablet"
|
||||
[class.text-p1]="item?.product?.name?.length >= 50 || !isTablet"
|
||||
[class.text-p2]="item?.product?.name?.length >= 60 && isTablet"
|
||||
[class.text-p3]="item?.product?.name?.length >= 100"
|
||||
>
|
||||
<a
|
||||
[routerLink]="productSearchDetailsPath"
|
||||
[queryParams]="{ main_qs: item?.product?.ean }"
|
||||
>{{ item?.product?.name }}</a
|
||||
>
|
||||
</div>
|
||||
|
||||
@if (item?.product?.format && item?.product?.formatDetail) {
|
||||
<div class="item-format">
|
||||
@if (item?.product?.format !== '--') {
|
||||
<img
|
||||
src="assets/images/Icon_{{ item?.product?.format }}.svg"
|
||||
[alt]="item?.product?.formatDetail"
|
||||
/>
|
||||
}
|
||||
{{ item?.product?.formatDetail }}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="item-info text-p2">
|
||||
<div class="mb-1">
|
||||
{{ item?.product?.manufacturer | substr: 25 }} | {{ item?.product?.ean }}
|
||||
</div>
|
||||
<div class="mb-1">
|
||||
{{ item?.product?.volume }}
|
||||
@if (item?.product?.volume && item?.product?.publicationDate) {
|
||||
<span>|</span>
|
||||
}
|
||||
{{ item?.product?.publicationDate | date }}
|
||||
</div>
|
||||
@if (notAvailable$ | async) {
|
||||
<div>
|
||||
<span class="text-brand item-date">Nicht verfügbar</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (refreshingAvailabilit$ | async) {
|
||||
<shared-skeleton-loader class="w-40"></shared-skeleton-loader>
|
||||
} @else {
|
||||
@if (orderType === 'Abholung') {
|
||||
<div
|
||||
class="item-date"
|
||||
[class.availability-changed]="estimatedShippingDateChanged$ | async"
|
||||
>
|
||||
Abholung ab {{ item?.availability?.estimatedShippingDate | date }}
|
||||
</div>
|
||||
}
|
||||
@if (
|
||||
orderType === 'Versand' ||
|
||||
orderType === 'B2B-Versand' ||
|
||||
orderType === 'DIG-Versand'
|
||||
) {
|
||||
<div
|
||||
class="item-date"
|
||||
[class.availability-changed]="estimatedShippingDateChanged$ | async"
|
||||
>
|
||||
@if (item?.availability?.estimatedDelivery) {
|
||||
Zustellung zwischen
|
||||
{{
|
||||
(
|
||||
item?.availability?.estimatedDelivery?.start | date: 'EEE, dd.MM.'
|
||||
)?.replace('.', '')
|
||||
}}
|
||||
und
|
||||
{{
|
||||
(
|
||||
item?.availability?.estimatedDelivery?.stop | date: 'EEE, dd.MM.'
|
||||
)?.replace('.', '')
|
||||
}}
|
||||
} @else {
|
||||
Versand {{ item?.availability?.estimatedShippingDate | date }}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@if (olaError$ | async) {
|
||||
<div class="item-availability-message">Artikel nicht verfügbar</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="item-price-stock flex flex-col">
|
||||
<div class="text-p2 font-bold">
|
||||
{{ item?.availability?.price?.value?.value | currency: 'EUR' : 'code' }}
|
||||
</div>
|
||||
<div class="text-p2 font-normal">
|
||||
@if (!(isDummy$ | async)) {
|
||||
<ui-quantity-dropdown
|
||||
[ngModel]="item?.quantity"
|
||||
(ngModelChange)="onChangeQuantity($event)"
|
||||
[showSpinner]="(loadingOnQuantityChangeById$ | async) === item?.id"
|
||||
[disabled]="(loadingOnItemChangeById$ | async) === item?.id"
|
||||
[range]="quantityRange$ | async"
|
||||
></ui-quantity-dropdown>
|
||||
} @else {
|
||||
<div class="mt-2">{{ item?.quantity }}x</div>
|
||||
}
|
||||
</div>
|
||||
@if (quantityError) {
|
||||
<div class="quantity-error">
|
||||
{{ quantityError }}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (orderType !== 'Download') {
|
||||
<div class="actions">
|
||||
@if (!(hasOrderType$ | async)) {
|
||||
<button
|
||||
[disabled]="
|
||||
(loadingOnQuantityChangeById$ | async) === item?.id ||
|
||||
(loadingOnItemChangeById$ | async) === item?.id
|
||||
"
|
||||
(click)="onChangeItem()"
|
||||
>
|
||||
<ui-spinner [show]="(loadingOnItemChangeById$ | async) === item?.id"
|
||||
>Lieferweg auswählen</ui-spinner
|
||||
>
|
||||
</button>
|
||||
}
|
||||
@if (canEdit$ | async) {
|
||||
<button
|
||||
[disabled]="
|
||||
(loadingOnQuantityChangeById$ | async) === item?.id ||
|
||||
(loadingOnItemChangeById$ | async) === item?.id
|
||||
"
|
||||
(click)="onChangeItem()"
|
||||
>
|
||||
<ui-spinner [show]="(loadingOnItemChangeById$ | async) === item?.id"
|
||||
>Lieferweg ändern</ui-spinner
|
||||
>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -1,255 +1,298 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
ChangeDetectorRef,
|
||||
Component,
|
||||
EventEmitter,
|
||||
Input,
|
||||
NgZone,
|
||||
OnInit,
|
||||
Output,
|
||||
inject,
|
||||
} from '@angular/core';
|
||||
import { ApplicationService } from '@core/application';
|
||||
import { EnvironmentService } from '@core/environment';
|
||||
import { DomainAvailabilityService } from '@domain/availability';
|
||||
import { DomainCheckoutService } from '@domain/checkout';
|
||||
import { ComponentStore } from '@ngrx/component-store';
|
||||
import { ProductCatalogNavigationService } from '@shared/services/navigation';
|
||||
import { ItemType, ShoppingCartItemDTO } from '@generated/swagger/checkout-api';
|
||||
import { cloneDeep } from 'lodash';
|
||||
import moment from 'moment';
|
||||
import { combineLatest } from 'rxjs';
|
||||
import { filter, first, map, shareReplay, switchMap } from 'rxjs/operators';
|
||||
|
||||
export interface ShoppingCartItemComponentState {
|
||||
item: ShoppingCartItemDTO;
|
||||
orderType: string;
|
||||
loadingOnItemChangeById?: number;
|
||||
loadingOnQuantityChangeById?: number;
|
||||
refreshingAvailability: boolean;
|
||||
sscChanged: boolean;
|
||||
sscTextChanged: boolean;
|
||||
estimatedShippingDateChanged: boolean;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'page-shopping-cart-item',
|
||||
templateUrl: 'shopping-cart-item.component.html',
|
||||
styleUrls: ['shopping-cart-item.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
standalone: false,
|
||||
})
|
||||
export class ShoppingCartItemComponent extends ComponentStore<ShoppingCartItemComponentState> implements OnInit {
|
||||
private _zone = inject(NgZone);
|
||||
|
||||
@Output() changeItem = new EventEmitter<{ shoppingCartItem: ShoppingCartItemDTO }>();
|
||||
@Output() changeDummyItem = new EventEmitter<{ shoppingCartItem: ShoppingCartItemDTO }>();
|
||||
@Output() changeQuantity = new EventEmitter<{ shoppingCartItem: ShoppingCartItemDTO; quantity: number }>();
|
||||
|
||||
@Input()
|
||||
get item() {
|
||||
return this.get((s) => s.item);
|
||||
}
|
||||
set item(item: ShoppingCartItemDTO) {
|
||||
if (this.item !== item) {
|
||||
this.patchState({ item });
|
||||
}
|
||||
}
|
||||
readonly item$ = this.select((s) => s.item);
|
||||
|
||||
readonly contributors$ = this.item$.pipe(
|
||||
map((item) => item?.product?.contributors?.split(';').map((val) => val.trim())),
|
||||
);
|
||||
|
||||
@Input()
|
||||
get orderType() {
|
||||
return this.get((s) => s.orderType);
|
||||
}
|
||||
set orderType(orderType: string) {
|
||||
if (this.orderType !== orderType) {
|
||||
this.patchState({ orderType });
|
||||
}
|
||||
}
|
||||
readonly orderType$ = this.select((s) => s.orderType);
|
||||
|
||||
@Input()
|
||||
get loadingOnItemChangeById() {
|
||||
return this.get((s) => s.loadingOnItemChangeById);
|
||||
}
|
||||
set loadingOnItemChangeById(loadingOnItemChangeById: number) {
|
||||
if (this.loadingOnItemChangeById !== loadingOnItemChangeById) {
|
||||
this.patchState({ loadingOnItemChangeById });
|
||||
}
|
||||
}
|
||||
readonly loadingOnItemChangeById$ = this.select((s) => s.loadingOnItemChangeById).pipe(shareReplay());
|
||||
|
||||
@Input()
|
||||
get loadingOnQuantityChangeById() {
|
||||
return this.get((s) => s.loadingOnQuantityChangeById);
|
||||
}
|
||||
set loadingOnQuantityChangeById(loadingOnQuantityChangeById: number) {
|
||||
if (this.loadingOnQuantityChangeById !== loadingOnQuantityChangeById) {
|
||||
this.patchState({ loadingOnQuantityChangeById });
|
||||
}
|
||||
}
|
||||
readonly loadingOnQuantityChangeById$ = this.select((s) => s.loadingOnQuantityChangeById).pipe(shareReplay());
|
||||
|
||||
@Input()
|
||||
quantityError: string;
|
||||
|
||||
isDummy$ = this.item$.pipe(
|
||||
map((item) => item?.availability?.supplyChannel === 'MANUALLY'),
|
||||
shareReplay(),
|
||||
);
|
||||
hasOrderType$ = this.orderType$.pipe(
|
||||
map((orderType) => orderType !== undefined),
|
||||
shareReplay(),
|
||||
);
|
||||
|
||||
canEdit$ = combineLatest([this.isDummy$, this.hasOrderType$, this.item$]).pipe(
|
||||
map(([isDummy, hasOrderType, item]) => {
|
||||
if (item.itemType === (66560 as ItemType)) {
|
||||
return false;
|
||||
}
|
||||
return isDummy || hasOrderType;
|
||||
}),
|
||||
);
|
||||
|
||||
quantityRange$ = combineLatest([this.orderType$, this.item$]).pipe(
|
||||
map(([orderType, item]) => (orderType === 'Rücklage' ? item.availability?.inStock : 999)),
|
||||
);
|
||||
|
||||
isDownloadAvailable$ = combineLatest([this.item$, this.orderType$]).pipe(
|
||||
filter(([_, orderType]) => orderType === 'Download'),
|
||||
switchMap(([item]) =>
|
||||
this.availabilityService.getDownloadAvailability({
|
||||
item: { ean: item.product.ean, price: item.availability.price, itemId: +item.product.catalogProductNumber },
|
||||
}),
|
||||
),
|
||||
map((availability) => availability && this.availabilityService.isAvailable({ availability })),
|
||||
);
|
||||
|
||||
olaError$ = this.checkoutService
|
||||
.getOlaErrors({ processId: this.application.activatedProcessId })
|
||||
.pipe(map((ids) => ids?.find((id) => id === this.item.id)));
|
||||
|
||||
get productSearchResultsPath() {
|
||||
return this._productNavigationService.getArticleSearchResultsPath(this.application.activatedProcessId).path;
|
||||
}
|
||||
|
||||
get productSearchDetailsPath() {
|
||||
return this._productNavigationService.getArticleDetailsPathByEan({
|
||||
processId: this.application.activatedProcessId,
|
||||
ean: this.item?.product?.ean,
|
||||
}).path;
|
||||
}
|
||||
|
||||
get isTablet() {
|
||||
return this._environment.matchTablet();
|
||||
}
|
||||
|
||||
refreshingAvailabilit$ = this.select((s) => s.refreshingAvailability);
|
||||
|
||||
sscChanged$ = this.select((s) => s.sscChanged || s.sscTextChanged);
|
||||
|
||||
estimatedShippingDateChanged$ = this.select((s) => s.estimatedShippingDateChanged);
|
||||
|
||||
notAvailable$ = this.item$.pipe(
|
||||
map((item) => {
|
||||
const availability = item?.availability;
|
||||
|
||||
if (availability.availabilityType === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (availability.inStock && item.quantity > availability.inStock) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return !this.availabilityService.isAvailable({ availability });
|
||||
}),
|
||||
);
|
||||
|
||||
constructor(
|
||||
private availabilityService: DomainAvailabilityService,
|
||||
private checkoutService: DomainCheckoutService,
|
||||
public application: ApplicationService,
|
||||
private _productNavigationService: ProductCatalogNavigationService,
|
||||
private _environment: EnvironmentService,
|
||||
private _cdr: ChangeDetectorRef,
|
||||
) {
|
||||
super({
|
||||
item: undefined,
|
||||
orderType: '',
|
||||
refreshingAvailability: false,
|
||||
sscChanged: false,
|
||||
sscTextChanged: false,
|
||||
estimatedShippingDateChanged: false,
|
||||
});
|
||||
}
|
||||
|
||||
ngOnInit() {}
|
||||
|
||||
async onChangeItem() {
|
||||
const isDummy = await this.isDummy$.pipe(first()).toPromise();
|
||||
isDummy
|
||||
? this.changeDummyItem.emit({ shoppingCartItem: this.item })
|
||||
: this.changeItem.emit({ shoppingCartItem: this.item });
|
||||
}
|
||||
|
||||
onChangeQuantity(quantity: number) {
|
||||
this.changeQuantity.emit({ shoppingCartItem: this.item, quantity });
|
||||
}
|
||||
|
||||
async refreshAvailability() {
|
||||
const currentAvailability = cloneDeep(this.item.availability);
|
||||
|
||||
try {
|
||||
this.patchRefreshingAvailability(true);
|
||||
this._cdr.markForCheck();
|
||||
const availability = await this.checkoutService.refreshAvailability({
|
||||
processId: this.application.activatedProcessId,
|
||||
shoppingCartItemId: this.item.id,
|
||||
});
|
||||
|
||||
if (currentAvailability.ssc !== availability.ssc) {
|
||||
this.sscChanged();
|
||||
}
|
||||
if (currentAvailability.sscText !== availability.sscText) {
|
||||
this.ssctextChanged();
|
||||
}
|
||||
if (
|
||||
moment(currentAvailability.estimatedShippingDate)
|
||||
.startOf('day')
|
||||
.diff(moment(availability.estimatedShippingDate).startOf('day'))
|
||||
) {
|
||||
this.estimatedShippingDateChanged();
|
||||
}
|
||||
} catch (error) {}
|
||||
|
||||
this.patchRefreshingAvailability(false);
|
||||
this._cdr.markForCheck();
|
||||
}
|
||||
|
||||
patchRefreshingAvailability(value: boolean) {
|
||||
this._zone.run(() => {
|
||||
this.patchState({ refreshingAvailability: value });
|
||||
this._cdr.markForCheck();
|
||||
});
|
||||
}
|
||||
|
||||
ssctextChanged() {
|
||||
this.patchState({ sscTextChanged: true });
|
||||
this._cdr.markForCheck();
|
||||
}
|
||||
|
||||
sscChanged() {
|
||||
this.patchState({ sscChanged: true });
|
||||
this._cdr.markForCheck();
|
||||
}
|
||||
|
||||
estimatedShippingDateChanged() {
|
||||
this.patchState({ estimatedShippingDateChanged: true });
|
||||
this._cdr.markForCheck();
|
||||
}
|
||||
}
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
ChangeDetectorRef,
|
||||
Component,
|
||||
EventEmitter,
|
||||
Input,
|
||||
NgZone,
|
||||
OnInit,
|
||||
Output,
|
||||
inject,
|
||||
} from '@angular/core';
|
||||
import { ApplicationService } from '@core/application';
|
||||
import { EnvironmentService } from '@core/environment';
|
||||
import { DomainAvailabilityService } from '@domain/availability';
|
||||
import { DomainCheckoutService } from '@domain/checkout';
|
||||
import { ComponentStore } from '@ngrx/component-store';
|
||||
import { ProductCatalogNavigationService } from '@shared/services/navigation';
|
||||
import { ItemType, ShoppingCartItemDTO } from '@generated/swagger/checkout-api';
|
||||
import { cloneDeep } from 'lodash';
|
||||
import moment from 'moment';
|
||||
import { combineLatest } from 'rxjs';
|
||||
import { filter, first, map, shareReplay, switchMap } from 'rxjs/operators';
|
||||
|
||||
export interface ShoppingCartItemComponentState {
|
||||
item: ShoppingCartItemDTO;
|
||||
orderType: string;
|
||||
loadingOnItemChangeById?: number;
|
||||
loadingOnQuantityChangeById?: number;
|
||||
refreshingAvailability: boolean;
|
||||
sscChanged: boolean;
|
||||
sscTextChanged: boolean;
|
||||
estimatedShippingDateChanged: boolean;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'page-shopping-cart-item',
|
||||
templateUrl: 'shopping-cart-item.component.html',
|
||||
styleUrls: ['shopping-cart-item.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
standalone: false,
|
||||
})
|
||||
export class ShoppingCartItemComponent
|
||||
extends ComponentStore<ShoppingCartItemComponentState>
|
||||
implements OnInit
|
||||
{
|
||||
private _zone = inject(NgZone);
|
||||
|
||||
@Output() changeItem = new EventEmitter<{
|
||||
shoppingCartItem: ShoppingCartItemDTO;
|
||||
}>();
|
||||
@Output() changeDummyItem = new EventEmitter<{
|
||||
shoppingCartItem: ShoppingCartItemDTO;
|
||||
}>();
|
||||
@Output() changeQuantity = new EventEmitter<{
|
||||
shoppingCartItem: ShoppingCartItemDTO;
|
||||
quantity: number;
|
||||
}>();
|
||||
|
||||
@Input()
|
||||
get item() {
|
||||
return this.get((s) => s.item);
|
||||
}
|
||||
set item(item: ShoppingCartItemDTO) {
|
||||
if (this.item !== item) {
|
||||
this.patchState({ item });
|
||||
}
|
||||
}
|
||||
readonly item$ = this.select((s) => s.item);
|
||||
|
||||
readonly contributors$ = this.item$.pipe(
|
||||
map((item) =>
|
||||
item?.product?.contributors?.split(';').map((val) => val.trim()),
|
||||
),
|
||||
);
|
||||
|
||||
get showLoyaltyValue() {
|
||||
return this.item?.loyalty?.value > 0;
|
||||
}
|
||||
|
||||
@Input()
|
||||
get orderType() {
|
||||
return this.get((s) => s.orderType);
|
||||
}
|
||||
set orderType(orderType: string) {
|
||||
if (this.orderType !== orderType) {
|
||||
this.patchState({ orderType });
|
||||
}
|
||||
}
|
||||
readonly orderType$ = this.select((s) => s.orderType);
|
||||
|
||||
@Input()
|
||||
get loadingOnItemChangeById() {
|
||||
return this.get((s) => s.loadingOnItemChangeById);
|
||||
}
|
||||
set loadingOnItemChangeById(loadingOnItemChangeById: number) {
|
||||
if (this.loadingOnItemChangeById !== loadingOnItemChangeById) {
|
||||
this.patchState({ loadingOnItemChangeById });
|
||||
}
|
||||
}
|
||||
readonly loadingOnItemChangeById$ = this.select(
|
||||
(s) => s.loadingOnItemChangeById,
|
||||
).pipe(shareReplay());
|
||||
|
||||
@Input()
|
||||
get loadingOnQuantityChangeById() {
|
||||
return this.get((s) => s.loadingOnQuantityChangeById);
|
||||
}
|
||||
set loadingOnQuantityChangeById(loadingOnQuantityChangeById: number) {
|
||||
if (this.loadingOnQuantityChangeById !== loadingOnQuantityChangeById) {
|
||||
this.patchState({ loadingOnQuantityChangeById });
|
||||
}
|
||||
}
|
||||
readonly loadingOnQuantityChangeById$ = this.select(
|
||||
(s) => s.loadingOnQuantityChangeById,
|
||||
).pipe(shareReplay());
|
||||
|
||||
@Input()
|
||||
quantityError: string;
|
||||
|
||||
isDummy$ = this.item$.pipe(
|
||||
map((item) => item?.availability?.supplyChannel === 'MANUALLY'),
|
||||
shareReplay(),
|
||||
);
|
||||
hasOrderType$ = this.orderType$.pipe(
|
||||
map((orderType) => orderType !== undefined),
|
||||
shareReplay(),
|
||||
);
|
||||
|
||||
canEdit$ = combineLatest([
|
||||
this.isDummy$,
|
||||
this.hasOrderType$,
|
||||
this.item$,
|
||||
]).pipe(
|
||||
map(([isDummy, hasOrderType, item]) => {
|
||||
if (item.itemType === (66560 as ItemType)) {
|
||||
return false;
|
||||
}
|
||||
return isDummy || hasOrderType;
|
||||
}),
|
||||
);
|
||||
|
||||
quantityRange$ = combineLatest([this.orderType$, this.item$]).pipe(
|
||||
map(([orderType, item]) =>
|
||||
orderType === 'Rücklage' ? item.availability?.inStock : 999,
|
||||
),
|
||||
);
|
||||
|
||||
isDownloadAvailable$ = combineLatest([this.item$, this.orderType$]).pipe(
|
||||
filter(([, orderType]) => orderType === 'Download'),
|
||||
switchMap(([item]) =>
|
||||
this.availabilityService.getDownloadAvailability({
|
||||
item: {
|
||||
ean: item.product.ean,
|
||||
price: item.availability.price,
|
||||
itemId: +item.product.catalogProductNumber,
|
||||
},
|
||||
}),
|
||||
),
|
||||
map(
|
||||
(availability) =>
|
||||
availability && this.availabilityService.isAvailable({ availability }),
|
||||
),
|
||||
);
|
||||
|
||||
olaError$ = this.checkoutService
|
||||
.getOlaErrors({ processId: this.application.activatedProcessId })
|
||||
.pipe(map((ids) => ids?.find((id) => id === this.item.id)));
|
||||
|
||||
get productSearchResultsPath() {
|
||||
return this._productNavigationService.getArticleSearchResultsPath(
|
||||
this.application.activatedProcessId,
|
||||
).path;
|
||||
}
|
||||
|
||||
get productSearchDetailsPath() {
|
||||
return this._productNavigationService.getArticleDetailsPathByEan({
|
||||
processId: this.application.activatedProcessId,
|
||||
ean: this.item?.product?.ean,
|
||||
}).path;
|
||||
}
|
||||
|
||||
get isTablet() {
|
||||
return this._environment.matchTablet();
|
||||
}
|
||||
|
||||
refreshingAvailabilit$ = this.select((s) => s.refreshingAvailability);
|
||||
|
||||
sscChanged$ = this.select((s) => s.sscChanged || s.sscTextChanged);
|
||||
|
||||
estimatedShippingDateChanged$ = this.select(
|
||||
(s) => s.estimatedShippingDateChanged,
|
||||
);
|
||||
|
||||
notAvailable$ = this.item$.pipe(
|
||||
map((item) => {
|
||||
const availability = item?.availability;
|
||||
|
||||
if (availability.availabilityType === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (availability.inStock && item.quantity > availability.inStock) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return !this.availabilityService.isAvailable({ availability });
|
||||
}),
|
||||
);
|
||||
|
||||
constructor(
|
||||
private availabilityService: DomainAvailabilityService,
|
||||
private checkoutService: DomainCheckoutService,
|
||||
public application: ApplicationService,
|
||||
private _productNavigationService: ProductCatalogNavigationService,
|
||||
private _environment: EnvironmentService,
|
||||
private _cdr: ChangeDetectorRef,
|
||||
) {
|
||||
super({
|
||||
item: undefined,
|
||||
orderType: '',
|
||||
refreshingAvailability: false,
|
||||
sscChanged: false,
|
||||
sscTextChanged: false,
|
||||
estimatedShippingDateChanged: false,
|
||||
});
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
// Component initialization
|
||||
}
|
||||
|
||||
async onChangeItem() {
|
||||
const isDummy = await this.isDummy$.pipe(first()).toPromise();
|
||||
if (isDummy) {
|
||||
this.changeDummyItem.emit({ shoppingCartItem: this.item });
|
||||
} else {
|
||||
this.changeItem.emit({ shoppingCartItem: this.item });
|
||||
}
|
||||
}
|
||||
|
||||
onChangeQuantity(quantity: number) {
|
||||
this.changeQuantity.emit({ shoppingCartItem: this.item, quantity });
|
||||
}
|
||||
|
||||
async refreshAvailability() {
|
||||
const currentAvailability = cloneDeep(this.item.availability);
|
||||
|
||||
try {
|
||||
this.patchRefreshingAvailability(true);
|
||||
this._cdr.markForCheck();
|
||||
const availability = await this.checkoutService.refreshAvailability({
|
||||
processId: this.application.activatedProcessId,
|
||||
shoppingCartItemId: this.item.id,
|
||||
});
|
||||
|
||||
if (currentAvailability.ssc !== availability.ssc) {
|
||||
this.sscChanged();
|
||||
}
|
||||
if (currentAvailability.sscText !== availability.sscText) {
|
||||
this.ssctextChanged();
|
||||
}
|
||||
if (
|
||||
moment(currentAvailability.estimatedShippingDate)
|
||||
.startOf('day')
|
||||
.diff(moment(availability.estimatedShippingDate).startOf('day'))
|
||||
) {
|
||||
this.estimatedShippingDateChanged();
|
||||
}
|
||||
} catch {
|
||||
// Error handling for availability refresh
|
||||
}
|
||||
|
||||
this.patchRefreshingAvailability(false);
|
||||
this._cdr.markForCheck();
|
||||
}
|
||||
|
||||
patchRefreshingAvailability(value: boolean) {
|
||||
this._zone.run(() => {
|
||||
this.patchState({ refreshingAvailability: value });
|
||||
this._cdr.markForCheck();
|
||||
});
|
||||
}
|
||||
|
||||
ssctextChanged() {
|
||||
this.patchState({ sscTextChanged: true });
|
||||
this._cdr.markForCheck();
|
||||
}
|
||||
|
||||
sscChanged() {
|
||||
this.patchState({ sscChanged: true });
|
||||
this._cdr.markForCheck();
|
||||
}
|
||||
|
||||
estimatedShippingDateChanged() {
|
||||
this.patchState({ estimatedShippingDateChanged: true });
|
||||
this._cdr.markForCheck();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,14 @@ import { MainSideViewModule } from './main-side-view/main-side-view.module';
|
||||
import { OrderDetailsSideViewComponent } from './order-details-side-view/order-details-side-view.component';
|
||||
import { CustomerMainViewComponent } from './main-view/main-view.component';
|
||||
import { SharedSplitscreenComponent } from '@shared/components/splitscreen';
|
||||
import {
|
||||
SelectedShoppingCartResource,
|
||||
SelectedRewardShoppingCartResource,
|
||||
} from '@isa/checkout/data-access';
|
||||
import {
|
||||
RewardSelectionService,
|
||||
RewardSelectionPopUpService,
|
||||
} from '@isa/checkout/shared/reward-selection-dialog';
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
@@ -29,5 +37,11 @@ import { SharedSplitscreenComponent } from '@shared/components/splitscreen';
|
||||
],
|
||||
exports: [CustomerSearchComponent],
|
||||
declarations: [CustomerSearchComponent],
|
||||
providers: [
|
||||
SelectedShoppingCartResource,
|
||||
SelectedRewardShoppingCartResource,
|
||||
RewardSelectionService,
|
||||
RewardSelectionPopUpService,
|
||||
],
|
||||
})
|
||||
export class CustomerSearchModule {}
|
||||
|
||||
@@ -1,218 +1,245 @@
|
||||
import { Component, ChangeDetectionStrategy, OnInit, OnDestroy, Host, inject } from '@angular/core';
|
||||
import { CustomerSearchStore } from '../../store';
|
||||
import { CrmCustomerService } from '@domain/crm';
|
||||
import { debounceTime, map, switchMap, takeUntil } from 'rxjs/operators';
|
||||
import { Observable, Subject, combineLatest } from 'rxjs';
|
||||
import { AssignedPayerDTO, CustomerDTO, ListResponseArgsOfAssignedPayerDTO } from '@generated/swagger/crm-api';
|
||||
import { AsyncPipe } from '@angular/common';
|
||||
import { CustomerPipesModule } from '@shared/pipes/customer';
|
||||
import { ComponentStore } from '@ngrx/component-store';
|
||||
import { tapResponse } from '@ngrx/operators';
|
||||
|
||||
import { UiModalService } from '@ui/modal';
|
||||
import { CustomerSearchNavigation } from '@shared/services/navigation';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { CustomerDetailsViewMainComponent } from '../details-main-view.component';
|
||||
import { PayerDTO } from '@generated/swagger/checkout-api';
|
||||
|
||||
interface DetailsMainViewBillingAddressesComponentState {
|
||||
assignedPayers: AssignedPayerDTO[];
|
||||
selectedPayer: AssignedPayerDTO;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'page-details-main-view-billing-addresses',
|
||||
templateUrl: 'details-main-view-billing-addresses.component.html',
|
||||
styleUrls: ['details-main-view-billing-addresses.component.css'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
host: { class: 'page-details-main-view-billing-addresses' },
|
||||
imports: [AsyncPipe, CustomerPipesModule, RouterLink],
|
||||
})
|
||||
export class DetailsMainViewBillingAddressesComponent
|
||||
extends ComponentStore<DetailsMainViewBillingAddressesComponentState>
|
||||
implements OnInit, OnDestroy
|
||||
{
|
||||
private _host = inject(CustomerDetailsViewMainComponent, { host: true });
|
||||
private _store = inject(CustomerSearchStore);
|
||||
private _customerService = inject(CrmCustomerService);
|
||||
private _modal = inject(UiModalService);
|
||||
private _navigation = inject(CustomerSearchNavigation);
|
||||
|
||||
assignedPayers$ = this.select((state) => state.assignedPayers);
|
||||
|
||||
selectedPayer$ = this.select((state) => state.selectedPayer);
|
||||
|
||||
isNotBusinessKonto$ = this._store.isBusinessKonto$.pipe(map((isBusinessKonto) => !isBusinessKonto));
|
||||
|
||||
showCustomerAddress$ = combineLatest([
|
||||
this._store.isBusinessKonto$,
|
||||
this._store.isMitarbeiter$,
|
||||
this._store.isKundenkarte$,
|
||||
]).pipe(map(([isBusinessKonto, isMitarbeiter, isKundenkarte]) => isBusinessKonto || isMitarbeiter || isKundenkarte));
|
||||
|
||||
get showCustomerAddress() {
|
||||
return this._store.isBusinessKonto || this._store.isMitarbeiter;
|
||||
}
|
||||
|
||||
canAddNewAddress$ = combineLatest([
|
||||
this._store.isOnlinekonto$,
|
||||
this._store.isOnlineKontoMitKundenkarte$,
|
||||
this._store.isKundenkarte$,
|
||||
]).pipe(
|
||||
map(
|
||||
([isOnlinekonto, isOnlineKontoMitKundenkarte, isKundenkarte]) =>
|
||||
isOnlinekonto || isOnlineKontoMitKundenkarte || isKundenkarte,
|
||||
),
|
||||
);
|
||||
|
||||
canEditAddress$ = combineLatest([this._store.isKundenkarte$]).pipe(map(([isKundenkarte]) => isKundenkarte));
|
||||
|
||||
customer$ = this._store.customer$;
|
||||
|
||||
private _onDestroy$ = new Subject<void>();
|
||||
|
||||
editRoute$ = combineLatest([this._store.processId$, this._store.customerId$, this._store.isBusinessKonto$]).pipe(
|
||||
map(([processId, customerId, isB2b]) => this._navigation.editRoute({ processId, customerId, isB2b })),
|
||||
);
|
||||
|
||||
addBillingAddressRoute$ = combineLatest([
|
||||
this.canAddNewAddress$,
|
||||
this._store.processId$,
|
||||
this._store.customerId$,
|
||||
]).pipe(
|
||||
map(([canAddNewAddress, processId, customerId]) =>
|
||||
canAddNewAddress ? this._navigation.addBillingAddressRoute({ processId, customerId }) : undefined,
|
||||
),
|
||||
);
|
||||
|
||||
constructor() {
|
||||
super({
|
||||
assignedPayers: [],
|
||||
selectedPayer: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
editRoute(payerId: number) {
|
||||
return this._navigation.editBillingAddressRoute({
|
||||
customerId: this._store.customerId,
|
||||
payerId,
|
||||
processId: this._store.processId,
|
||||
});
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
combineLatest([this._store.customerId$, this._store.isMitarbeiter$])
|
||||
.pipe(takeUntil(this._onDestroy$), debounceTime(250))
|
||||
.subscribe(([customerId, isMitarbeiter]) => {
|
||||
this.resetStore();
|
||||
// #4715 Hier erfolgt ein Check auf Mitarbeiter, da Mitarbeiter keine zusätzlichen Rechnungsadressen haben sollen
|
||||
if (customerId && !isMitarbeiter) {
|
||||
this.loadAssignedPayers(customerId);
|
||||
}
|
||||
});
|
||||
|
||||
combineLatest([this.selectedPayer$, this._store.customer$])
|
||||
.pipe(takeUntil(this._onDestroy$))
|
||||
.subscribe(([selectedPayer, customer]) => {
|
||||
if (selectedPayer) {
|
||||
this._host.setPayer(this._createPayerFromCrmPayerDTO(selectedPayer));
|
||||
} else if (this.showCustomerAddress) {
|
||||
this._host.setPayer(this._createPayerFormCustomer(customer));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
_createPayerFromCrmPayerDTO(assignedPayer: AssignedPayerDTO): PayerDTO {
|
||||
const payer = assignedPayer.payer.data;
|
||||
return {
|
||||
reference: { id: payer.id },
|
||||
payerType: payer.payerType as any,
|
||||
payerNumber: payer.payerNumber,
|
||||
payerStatus: payer.payerStatus,
|
||||
gender: payer.gender,
|
||||
title: payer.title,
|
||||
firstName: payer.firstName,
|
||||
lastName: payer.lastName,
|
||||
communicationDetails: payer.communicationDetails ? { ...payer.communicationDetails } : undefined,
|
||||
organisation: payer.organisation ? { ...payer.organisation } : undefined,
|
||||
address: payer.address ? { ...payer.address } : undefined,
|
||||
source: payer.id,
|
||||
};
|
||||
}
|
||||
|
||||
_createPayerFormCustomer(customer: CustomerDTO): PayerDTO {
|
||||
return {
|
||||
reference: { id: customer.id },
|
||||
payerType: customer.customerType as any,
|
||||
payerNumber: customer.customerNumber,
|
||||
payerStatus: 0,
|
||||
gender: customer.gender,
|
||||
title: customer.title,
|
||||
firstName: customer.firstName,
|
||||
lastName: customer.lastName,
|
||||
communicationDetails: customer.communicationDetails ? { ...customer.communicationDetails } : undefined,
|
||||
organisation: customer.organisation ? { ...customer.organisation } : undefined,
|
||||
address: customer.address ? { ...customer.address } : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this._onDestroy$.next();
|
||||
this._onDestroy$.complete();
|
||||
}
|
||||
|
||||
loadAssignedPayers = this.effect((customerId$: Observable<number>) =>
|
||||
customerId$.pipe(
|
||||
switchMap((customerId) =>
|
||||
this._customerService
|
||||
.getAssignedPayers({ customerId })
|
||||
.pipe(tapResponse(this.handleLoadAssignedPayersResponse, this.handleLoadAssignedPayersError)),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
handleLoadAssignedPayersResponse = (response: ListResponseArgsOfAssignedPayerDTO) => {
|
||||
const selectedPayer = response.result.reduce<AssignedPayerDTO>((prev, curr) => {
|
||||
if (!prev) {
|
||||
return curr;
|
||||
}
|
||||
|
||||
const prevDate = new Date(prev?.isDefault ?? 0);
|
||||
const currDate = new Date(curr?.isDefault ?? 0);
|
||||
|
||||
if (prevDate > currDate) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
return curr;
|
||||
}, undefined);
|
||||
|
||||
this.patchState({
|
||||
assignedPayers: response.result,
|
||||
selectedPayer,
|
||||
});
|
||||
};
|
||||
|
||||
handleLoadAssignedPayersError = (err: any) => {
|
||||
this._modal.error('Laden der Rechnungsadressen fehlgeschlagen', err);
|
||||
};
|
||||
|
||||
resetStore() {
|
||||
this.patchState({
|
||||
assignedPayers: [],
|
||||
selectedPayer: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
selectPayer(payer: AssignedPayerDTO) {
|
||||
this.patchState({
|
||||
selectedPayer: payer,
|
||||
});
|
||||
}
|
||||
|
||||
selectCustomerAddress() {
|
||||
this.patchState({
|
||||
selectedPayer: undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
import {
|
||||
Component,
|
||||
ChangeDetectionStrategy,
|
||||
OnInit,
|
||||
OnDestroy,
|
||||
inject,
|
||||
} from '@angular/core';
|
||||
import { CustomerSearchStore } from '../../store';
|
||||
import { CrmCustomerService } from '@domain/crm';
|
||||
import { debounceTime, map, switchMap, takeUntil } from 'rxjs/operators';
|
||||
import { Observable, Subject, combineLatest } from 'rxjs';
|
||||
import {
|
||||
AssignedPayerDTO,
|
||||
ListResponseArgsOfAssignedPayerDTO,
|
||||
} from '@generated/swagger/crm-api';
|
||||
import { AsyncPipe } from '@angular/common';
|
||||
import { CustomerPipesModule } from '@shared/pipes/customer';
|
||||
import { ComponentStore } from '@ngrx/component-store';
|
||||
import { tapResponse } from '@ngrx/operators';
|
||||
|
||||
import { UiModalService } from '@ui/modal';
|
||||
import { CustomerSearchNavigation } from '@shared/services/navigation';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { CustomerDetailsViewMainComponent } from '../details-main-view.component';
|
||||
import {
|
||||
AssignedPayer,
|
||||
CrmTabMetadataService,
|
||||
Customer,
|
||||
} from '@isa/crm/data-access';
|
||||
import { injectTabId } from '@isa/core/tabs';
|
||||
import { CustomerAdapter } from '@isa/checkout/data-access';
|
||||
|
||||
interface DetailsMainViewBillingAddressesComponentState {
|
||||
assignedPayers: AssignedPayerDTO[];
|
||||
selectedPayer: AssignedPayerDTO;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'page-details-main-view-billing-addresses',
|
||||
templateUrl: 'details-main-view-billing-addresses.component.html',
|
||||
styleUrls: ['details-main-view-billing-addresses.component.css'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
host: { class: 'page-details-main-view-billing-addresses' },
|
||||
imports: [AsyncPipe, CustomerPipesModule, RouterLink],
|
||||
})
|
||||
export class DetailsMainViewBillingAddressesComponent
|
||||
extends ComponentStore<DetailsMainViewBillingAddressesComponentState>
|
||||
implements OnInit, OnDestroy
|
||||
{
|
||||
tabId = injectTabId();
|
||||
crmTabMetadataService = inject(CrmTabMetadataService);
|
||||
|
||||
private _host = inject(CustomerDetailsViewMainComponent, { host: true });
|
||||
private _store = inject(CustomerSearchStore);
|
||||
private _customerService = inject(CrmCustomerService);
|
||||
private _modal = inject(UiModalService);
|
||||
private _navigation = inject(CustomerSearchNavigation);
|
||||
|
||||
assignedPayers$ = this.select((state) => state.assignedPayers);
|
||||
|
||||
selectedPayer$ = this.select((state) => state.selectedPayer);
|
||||
|
||||
isNotBusinessKonto$ = this._store.isBusinessKonto$.pipe(
|
||||
map((isBusinessKonto) => !isBusinessKonto),
|
||||
);
|
||||
|
||||
showCustomerAddress$ = combineLatest([
|
||||
this._store.isBusinessKonto$,
|
||||
this._store.isMitarbeiter$,
|
||||
this._store.isKundenkarte$,
|
||||
]).pipe(
|
||||
map(
|
||||
([isBusinessKonto, isMitarbeiter, isKundenkarte]) =>
|
||||
isBusinessKonto || isMitarbeiter || isKundenkarte,
|
||||
),
|
||||
);
|
||||
|
||||
get showCustomerAddress() {
|
||||
return this._store.isBusinessKonto || this._store.isMitarbeiter;
|
||||
}
|
||||
|
||||
canAddNewAddress$ = combineLatest([
|
||||
this._store.isOnlinekonto$,
|
||||
this._store.isOnlineKontoMitKundenkarte$,
|
||||
this._store.isKundenkarte$,
|
||||
]).pipe(
|
||||
map(
|
||||
([isOnlinekonto, isOnlineKontoMitKundenkarte, isKundenkarte]) =>
|
||||
isOnlinekonto || isOnlineKontoMitKundenkarte || isKundenkarte,
|
||||
),
|
||||
);
|
||||
|
||||
canEditAddress$ = combineLatest([this._store.isKundenkarte$]).pipe(
|
||||
map(([isKundenkarte]) => isKundenkarte),
|
||||
);
|
||||
|
||||
customer$ = this._store.customer$;
|
||||
|
||||
private _onDestroy$ = new Subject<void>();
|
||||
|
||||
editRoute$ = combineLatest([
|
||||
this._store.processId$,
|
||||
this._store.customerId$,
|
||||
this._store.isBusinessKonto$,
|
||||
]).pipe(
|
||||
map(([processId, customerId, isB2b]) =>
|
||||
this._navigation.editRoute({ processId, customerId, isB2b }),
|
||||
),
|
||||
);
|
||||
|
||||
addBillingAddressRoute$ = combineLatest([
|
||||
this.canAddNewAddress$,
|
||||
this._store.processId$,
|
||||
this._store.customerId$,
|
||||
]).pipe(
|
||||
map(([canAddNewAddress, processId, customerId]) =>
|
||||
canAddNewAddress
|
||||
? this._navigation.addBillingAddressRoute({ processId, customerId })
|
||||
: undefined,
|
||||
),
|
||||
);
|
||||
|
||||
constructor() {
|
||||
super({
|
||||
assignedPayers: [],
|
||||
selectedPayer: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
editRoute(payerId: number) {
|
||||
return this._navigation.editBillingAddressRoute({
|
||||
customerId: this._store.customerId,
|
||||
payerId,
|
||||
processId: this._store.processId,
|
||||
});
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
combineLatest([this._store.customerId$, this._store.isMitarbeiter$])
|
||||
.pipe(takeUntil(this._onDestroy$), debounceTime(250))
|
||||
.subscribe(([customerId, isMitarbeiter]) => {
|
||||
this.resetStore();
|
||||
// #4715 Hier erfolgt ein Check auf Mitarbeiter, da Mitarbeiter keine zusätzlichen Rechnungsadressen haben sollen
|
||||
if (customerId && !isMitarbeiter) {
|
||||
this.loadAssignedPayers(customerId);
|
||||
}
|
||||
});
|
||||
|
||||
combineLatest([this.selectedPayer$, this._store.customer$])
|
||||
.pipe(takeUntil(this._onDestroy$))
|
||||
.subscribe(([selectedPayer, customer]) => {
|
||||
if (selectedPayer) {
|
||||
const payer = CustomerAdapter.toPayerFromAssignedPayer(
|
||||
selectedPayer as AssignedPayer,
|
||||
);
|
||||
if (payer) {
|
||||
this._host.setPayer(payer);
|
||||
this.crmTabMetadataService.setSelectedPayerId(
|
||||
this.tabId(),
|
||||
selectedPayer?.payer?.id,
|
||||
);
|
||||
}
|
||||
} else if (this.showCustomerAddress) {
|
||||
this._host.setPayer(
|
||||
CustomerAdapter.toPayerFromCustomer(
|
||||
customer as unknown as Customer,
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this._onDestroy$.next();
|
||||
this._onDestroy$.complete();
|
||||
}
|
||||
|
||||
loadAssignedPayers = this.effect((customerId$: Observable<number>) =>
|
||||
customerId$.pipe(
|
||||
switchMap((customerId) =>
|
||||
this._customerService
|
||||
.getAssignedPayers({ customerId })
|
||||
.pipe(
|
||||
tapResponse(
|
||||
this.handleLoadAssignedPayersResponse,
|
||||
this.handleLoadAssignedPayersError,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
handleLoadAssignedPayersResponse = (
|
||||
response: ListResponseArgsOfAssignedPayerDTO,
|
||||
) => {
|
||||
const selectedPayer = response.result.reduce<AssignedPayerDTO>(
|
||||
(prev, curr) => {
|
||||
if (!prev) {
|
||||
return curr;
|
||||
}
|
||||
|
||||
const prevDate = new Date(prev?.isDefault ?? 0);
|
||||
const currDate = new Date(curr?.isDefault ?? 0);
|
||||
|
||||
if (prevDate > currDate) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
return curr;
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
|
||||
this.patchState({
|
||||
assignedPayers: response.result,
|
||||
selectedPayer,
|
||||
});
|
||||
};
|
||||
|
||||
handleLoadAssignedPayersError = (err: unknown) => {
|
||||
this._modal.error(
|
||||
'Laden der Rechnungsadressen fehlgeschlagen',
|
||||
err as Error,
|
||||
);
|
||||
};
|
||||
|
||||
resetStore() {
|
||||
this.patchState({
|
||||
assignedPayers: [],
|
||||
selectedPayer: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
selectPayer(payer: AssignedPayerDTO) {
|
||||
this.patchState({
|
||||
selectedPayer: payer,
|
||||
});
|
||||
}
|
||||
|
||||
selectCustomerAddress() {
|
||||
this.patchState({
|
||||
selectedPayer: undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,217 +1,267 @@
|
||||
import { Component, ChangeDetectionStrategy, OnInit, OnDestroy, Host, inject } from '@angular/core';
|
||||
import { CustomerSearchStore } from '../../store';
|
||||
import { CrmCustomerService } from '@domain/crm';
|
||||
import { map, switchMap, takeUntil } from 'rxjs/operators';
|
||||
import { Observable, Subject, combineLatest } from 'rxjs';
|
||||
import { CustomerDTO, ListResponseArgsOfAssignedPayerDTO, ShippingAddressDTO } from '@generated/swagger/crm-api';
|
||||
import { AsyncPipe } from '@angular/common';
|
||||
import { CustomerPipesModule } from '@shared/pipes/customer';
|
||||
import { ComponentStore } from '@ngrx/component-store';
|
||||
import { tapResponse } from '@ngrx/operators';
|
||||
|
||||
import { UiModalService } from '@ui/modal';
|
||||
import { CustomerSearchNavigation } from '@shared/services/navigation';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { CustomerDetailsViewMainComponent } from '../details-main-view.component';
|
||||
|
||||
interface DetailsMainViewDeliveryAddressesComponentState {
|
||||
shippingAddresses: ShippingAddressDTO[];
|
||||
selectedShippingAddress: ShippingAddressDTO;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'page-details-main-view-delivery-addresses',
|
||||
templateUrl: 'details-main-view-delivery-addresses.component.html',
|
||||
styleUrls: ['details-main-view-delivery-addresses.component.css'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
host: { class: 'page-details-main-view-delivery-addresses' },
|
||||
imports: [AsyncPipe, CustomerPipesModule, RouterLink],
|
||||
})
|
||||
export class DetailsMainViewDeliveryAddressesComponent
|
||||
extends ComponentStore<DetailsMainViewDeliveryAddressesComponentState>
|
||||
implements OnInit, OnDestroy
|
||||
{
|
||||
private _host = inject(CustomerDetailsViewMainComponent, { host: true });
|
||||
private _store = inject(CustomerSearchStore);
|
||||
private _customerService = inject(CrmCustomerService);
|
||||
private _modal = inject(UiModalService);
|
||||
private _navigation = inject(CustomerSearchNavigation);
|
||||
|
||||
shippingAddresses$ = this.select((state) => state.shippingAddresses);
|
||||
|
||||
selectedShippingAddress$ = this.select((state) => state.selectedShippingAddress);
|
||||
|
||||
get selectedShippingAddress() {
|
||||
return this.get((s) => s.selectedShippingAddress);
|
||||
}
|
||||
|
||||
private _onDestroy$ = new Subject<void>();
|
||||
|
||||
showCustomerAddress$ = combineLatest([
|
||||
this._store.isBusinessKonto$,
|
||||
this._store.isMitarbeiter$,
|
||||
this._store.isKundenkarte$,
|
||||
]).pipe(map(([isBusinessKonto, isMitarbeiter, isKundenkarte]) => isBusinessKonto || isMitarbeiter || isKundenkarte));
|
||||
|
||||
get showCustomerAddress() {
|
||||
return this._store.isBusinessKonto || this._store.isMitarbeiter;
|
||||
}
|
||||
|
||||
customer$ = this._store.customer$;
|
||||
|
||||
canAddNewAddress$ = combineLatest([
|
||||
this._store.isOnlinekonto$,
|
||||
this._store.isOnlineKontoMitKundenkarte$,
|
||||
this._store.isKundenkarte$,
|
||||
this._store.isBusinessKonto$,
|
||||
this._store.isMitarbeiter$,
|
||||
]).pipe(
|
||||
map(
|
||||
([isOnlinekonto, isOnlineKontoMitKundenkarte, isKundenkarte, isBusinessKonto, isMitarbeiter]) =>
|
||||
isOnlinekonto || isOnlineKontoMitKundenkarte || isKundenkarte || isBusinessKonto || isMitarbeiter,
|
||||
),
|
||||
);
|
||||
|
||||
editRoute$ = combineLatest([this._store.processId$, this._store.customerId$, this._store.isBusinessKonto$]).pipe(
|
||||
map(([processId, customerId, isB2b]) => this._navigation.editRoute({ processId, customerId, isB2b })),
|
||||
);
|
||||
|
||||
addShippingAddressRoute$ = combineLatest([
|
||||
this.canAddNewAddress$,
|
||||
this._store.processId$,
|
||||
this._store.customerId$,
|
||||
]).pipe(
|
||||
map(([canAddNewAddress, processId, customerId]) =>
|
||||
canAddNewAddress ? this._navigation.addShippingAddressRoute({ processId, customerId }) : undefined,
|
||||
),
|
||||
);
|
||||
|
||||
editShippingAddressRoute$ = (shippingAddressId: number) =>
|
||||
combineLatest([this.canEditAddress$, this._store.processId$, this._store.customerId$]).pipe(
|
||||
map(([canEditAddress, processId, customerId]) => {
|
||||
if (canEditAddress) {
|
||||
return this._navigation.editShippingAddressRoute({ processId, customerId, shippingAddressId });
|
||||
}
|
||||
return undefined;
|
||||
}),
|
||||
);
|
||||
|
||||
canEditAddress$ = combineLatest([
|
||||
this._store.isKundenkarte$,
|
||||
this._store.isBusinessKonto$,
|
||||
this._store.isMitarbeiter$,
|
||||
]).pipe(map(([isKundenkarte, isBusinessKonto, isMitarbeiter]) => isKundenkarte || isBusinessKonto || isMitarbeiter));
|
||||
|
||||
constructor() {
|
||||
super({
|
||||
shippingAddresses: [],
|
||||
selectedShippingAddress: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
this._store.customerId$.pipe(takeUntil(this._onDestroy$)).subscribe((customerId) => {
|
||||
this.resetStore();
|
||||
if (customerId) {
|
||||
this.loadShippingAddresses(customerId);
|
||||
}
|
||||
});
|
||||
|
||||
combineLatest([this.selectedShippingAddress$, this._store.customer$])
|
||||
.pipe(takeUntil(this._onDestroy$))
|
||||
.subscribe(([selectedShippingAddress, customer]) => {
|
||||
if (selectedShippingAddress) {
|
||||
this._host.setShippingAddress(this._createShippingAddressFromShippingAddress(selectedShippingAddress));
|
||||
} else if (this.showCustomerAddress) {
|
||||
this._host.setShippingAddress(this._createShippingAddressFromCustomer(customer));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this._onDestroy$.next();
|
||||
this._onDestroy$.complete();
|
||||
}
|
||||
|
||||
_createShippingAddressFromCustomer(customer: CustomerDTO) {
|
||||
return {
|
||||
reference: { id: customer.id },
|
||||
gender: customer?.gender,
|
||||
title: customer?.title,
|
||||
firstName: customer?.firstName,
|
||||
lastName: customer?.lastName,
|
||||
communicationDetails: customer?.communicationDetails ? { ...customer?.communicationDetails } : undefined,
|
||||
organisation: customer?.organisation ? { ...customer?.organisation } : undefined,
|
||||
address: customer?.address ? { ...customer?.address } : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
_createShippingAddressFromShippingAddress(address: ShippingAddressDTO) {
|
||||
return {
|
||||
reference: { id: address.id },
|
||||
gender: address.gender,
|
||||
title: address.title,
|
||||
firstName: address.firstName,
|
||||
lastName: address.lastName,
|
||||
communicationDetails: address.communicationDetails ? { ...address.communicationDetails } : undefined,
|
||||
organisation: address.organisation ? { ...address.organisation } : undefined,
|
||||
address: address.address ? { ...address.address } : undefined,
|
||||
source: address.id,
|
||||
};
|
||||
}
|
||||
|
||||
loadShippingAddresses = this.effect((customerId$: Observable<number>) =>
|
||||
customerId$.pipe(
|
||||
switchMap((customerId) =>
|
||||
this._customerService
|
||||
.getShippingAddresses({ customerId })
|
||||
.pipe(tapResponse(this.handleLoadShippingAddressesResponse, this.handleLoadAssignedPayersError)),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
handleLoadShippingAddressesResponse = (response: ListResponseArgsOfAssignedPayerDTO) => {
|
||||
const selectedShippingAddress = response.result.reduce<ShippingAddressDTO>((prev, curr) => {
|
||||
if (!this.showCustomerAddress && !prev) {
|
||||
return curr;
|
||||
}
|
||||
|
||||
const prevDate = new Date(prev?.isDefault ?? 0);
|
||||
const currDate = new Date(curr?.isDefault ?? 0);
|
||||
|
||||
if (prevDate > currDate) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
return curr;
|
||||
}, undefined);
|
||||
|
||||
this.patchState({
|
||||
shippingAddresses: response.result,
|
||||
selectedShippingAddress,
|
||||
});
|
||||
};
|
||||
|
||||
handleLoadAssignedPayersError = (err: any) => {
|
||||
this._modal.error('Laden der Lieferadressen fehlgeschlagen', err);
|
||||
};
|
||||
|
||||
resetStore() {
|
||||
this.patchState({
|
||||
shippingAddresses: [],
|
||||
selectedShippingAddress: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
selectShippingAddress(shippingAddress: ShippingAddressDTO) {
|
||||
this.patchState({
|
||||
selectedShippingAddress: shippingAddress,
|
||||
});
|
||||
}
|
||||
|
||||
selectCustomerAddress() {
|
||||
this.patchState({
|
||||
selectedShippingAddress: undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
import {
|
||||
Component,
|
||||
ChangeDetectionStrategy,
|
||||
OnInit,
|
||||
OnDestroy,
|
||||
inject,
|
||||
} from '@angular/core';
|
||||
import { CustomerSearchStore } from '../../store';
|
||||
import { CrmCustomerService } from '@domain/crm';
|
||||
import { map, switchMap, takeUntil } from 'rxjs/operators';
|
||||
import { Observable, Subject, combineLatest } from 'rxjs';
|
||||
import {
|
||||
ListResponseArgsOfAssignedPayerDTO,
|
||||
ShippingAddressDTO,
|
||||
} from '@generated/swagger/crm-api';
|
||||
import { AsyncPipe } from '@angular/common';
|
||||
import { CustomerPipesModule } from '@shared/pipes/customer';
|
||||
import { ComponentStore } from '@ngrx/component-store';
|
||||
import { tapResponse } from '@ngrx/operators';
|
||||
|
||||
import { UiModalService } from '@ui/modal';
|
||||
import { CustomerSearchNavigation } from '@shared/services/navigation';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { CustomerDetailsViewMainComponent } from '../details-main-view.component';
|
||||
import { CrmTabMetadataService, Customer } from '@isa/crm/data-access';
|
||||
import { injectTabId } from '@isa/core/tabs';
|
||||
import { ShippingAddressAdapter } from '@isa/checkout/data-access';
|
||||
|
||||
interface DetailsMainViewDeliveryAddressesComponentState {
|
||||
shippingAddresses: ShippingAddressDTO[];
|
||||
selectedShippingAddress: ShippingAddressDTO;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'page-details-main-view-delivery-addresses',
|
||||
templateUrl: 'details-main-view-delivery-addresses.component.html',
|
||||
styleUrls: ['details-main-view-delivery-addresses.component.css'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
host: { class: 'page-details-main-view-delivery-addresses' },
|
||||
imports: [AsyncPipe, CustomerPipesModule, RouterLink],
|
||||
})
|
||||
export class DetailsMainViewDeliveryAddressesComponent
|
||||
extends ComponentStore<DetailsMainViewDeliveryAddressesComponentState>
|
||||
implements OnInit, OnDestroy
|
||||
{
|
||||
tabId = injectTabId();
|
||||
crmTabMetadataService = inject(CrmTabMetadataService);
|
||||
|
||||
private _host = inject(CustomerDetailsViewMainComponent, { host: true });
|
||||
private _store = inject(CustomerSearchStore);
|
||||
private _customerService = inject(CrmCustomerService);
|
||||
private _modal = inject(UiModalService);
|
||||
private _navigation = inject(CustomerSearchNavigation);
|
||||
|
||||
shippingAddresses$ = this.select((state) => state.shippingAddresses);
|
||||
|
||||
selectedShippingAddress$ = this.select(
|
||||
(state) => state.selectedShippingAddress,
|
||||
);
|
||||
|
||||
get selectedShippingAddress() {
|
||||
return this.get((s) => s.selectedShippingAddress);
|
||||
}
|
||||
|
||||
private _onDestroy$ = new Subject<void>();
|
||||
|
||||
showCustomerAddress$ = combineLatest([
|
||||
this._store.isBusinessKonto$,
|
||||
this._store.isMitarbeiter$,
|
||||
this._store.isKundenkarte$,
|
||||
]).pipe(
|
||||
map(
|
||||
([isBusinessKonto, isMitarbeiter, isKundenkarte]) =>
|
||||
isBusinessKonto || isMitarbeiter || isKundenkarte,
|
||||
),
|
||||
);
|
||||
|
||||
get showCustomerAddress() {
|
||||
return this._store.isBusinessKonto || this._store.isMitarbeiter;
|
||||
}
|
||||
|
||||
customer$ = this._store.customer$;
|
||||
|
||||
canAddNewAddress$ = combineLatest([
|
||||
this._store.isOnlinekonto$,
|
||||
this._store.isOnlineKontoMitKundenkarte$,
|
||||
this._store.isKundenkarte$,
|
||||
this._store.isBusinessKonto$,
|
||||
this._store.isMitarbeiter$,
|
||||
]).pipe(
|
||||
map(
|
||||
([
|
||||
isOnlinekonto,
|
||||
isOnlineKontoMitKundenkarte,
|
||||
isKundenkarte,
|
||||
isBusinessKonto,
|
||||
isMitarbeiter,
|
||||
]) =>
|
||||
isOnlinekonto ||
|
||||
isOnlineKontoMitKundenkarte ||
|
||||
isKundenkarte ||
|
||||
isBusinessKonto ||
|
||||
isMitarbeiter,
|
||||
),
|
||||
);
|
||||
|
||||
editRoute$ = combineLatest([
|
||||
this._store.processId$,
|
||||
this._store.customerId$,
|
||||
this._store.isBusinessKonto$,
|
||||
]).pipe(
|
||||
map(([processId, customerId, isB2b]) =>
|
||||
this._navigation.editRoute({ processId, customerId, isB2b }),
|
||||
),
|
||||
);
|
||||
|
||||
addShippingAddressRoute$ = combineLatest([
|
||||
this.canAddNewAddress$,
|
||||
this._store.processId$,
|
||||
this._store.customerId$,
|
||||
]).pipe(
|
||||
map(([canAddNewAddress, processId, customerId]) =>
|
||||
canAddNewAddress
|
||||
? this._navigation.addShippingAddressRoute({ processId, customerId })
|
||||
: undefined,
|
||||
),
|
||||
);
|
||||
|
||||
editShippingAddressRoute$ = (shippingAddressId: number) =>
|
||||
combineLatest([
|
||||
this.canEditAddress$,
|
||||
this._store.processId$,
|
||||
this._store.customerId$,
|
||||
]).pipe(
|
||||
map(([canEditAddress, processId, customerId]) => {
|
||||
if (canEditAddress) {
|
||||
return this._navigation.editShippingAddressRoute({
|
||||
processId,
|
||||
customerId,
|
||||
shippingAddressId,
|
||||
});
|
||||
}
|
||||
return undefined;
|
||||
}),
|
||||
);
|
||||
|
||||
canEditAddress$ = combineLatest([
|
||||
this._store.isKundenkarte$,
|
||||
this._store.isBusinessKonto$,
|
||||
this._store.isMitarbeiter$,
|
||||
]).pipe(
|
||||
map(
|
||||
([isKundenkarte, isBusinessKonto, isMitarbeiter]) =>
|
||||
isKundenkarte || isBusinessKonto || isMitarbeiter,
|
||||
),
|
||||
);
|
||||
|
||||
constructor() {
|
||||
super({
|
||||
shippingAddresses: [],
|
||||
selectedShippingAddress: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
this._store.customerId$
|
||||
.pipe(takeUntil(this._onDestroy$))
|
||||
.subscribe((customerId) => {
|
||||
this.resetStore();
|
||||
if (customerId) {
|
||||
this.loadShippingAddresses(customerId);
|
||||
}
|
||||
});
|
||||
|
||||
combineLatest([this.selectedShippingAddress$, this._store.customer$])
|
||||
.pipe(takeUntil(this._onDestroy$))
|
||||
.subscribe(([selectedShippingAddress, customer]) => {
|
||||
if (selectedShippingAddress) {
|
||||
this._host.setShippingAddress(
|
||||
ShippingAddressAdapter.fromCrmShippingAddress(
|
||||
selectedShippingAddress,
|
||||
),
|
||||
);
|
||||
this.crmTabMetadataService.setSelectedShippingAddressId(
|
||||
this.tabId(),
|
||||
selectedShippingAddress?.id,
|
||||
);
|
||||
} else if (this.showCustomerAddress) {
|
||||
this._host.setShippingAddress(
|
||||
ShippingAddressAdapter.fromCustomer(
|
||||
customer as unknown as Customer,
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this._onDestroy$.next();
|
||||
this._onDestroy$.complete();
|
||||
}
|
||||
|
||||
loadShippingAddresses = this.effect((customerId$: Observable<number>) =>
|
||||
customerId$.pipe(
|
||||
switchMap((customerId) =>
|
||||
this._customerService
|
||||
.getShippingAddresses({ customerId })
|
||||
.pipe(
|
||||
tapResponse(
|
||||
this.handleLoadShippingAddressesResponse,
|
||||
this.handleLoadAssignedPayersError,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
handleLoadShippingAddressesResponse = (
|
||||
response: ListResponseArgsOfAssignedPayerDTO,
|
||||
) => {
|
||||
const selectedShippingAddress = response.result.reduce<ShippingAddressDTO>(
|
||||
(prev, curr) => {
|
||||
if (!this.showCustomerAddress && !prev) {
|
||||
return curr;
|
||||
}
|
||||
|
||||
const prevDate = new Date(prev?.isDefault ?? 0);
|
||||
const currDate = new Date(curr?.isDefault ?? 0);
|
||||
|
||||
if (prevDate > currDate) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
return curr;
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
|
||||
this.patchState({
|
||||
shippingAddresses: response.result,
|
||||
selectedShippingAddress,
|
||||
});
|
||||
};
|
||||
|
||||
handleLoadAssignedPayersError = (err: unknown) => {
|
||||
this._modal.error('Laden der Lieferadressen fehlgeschlagen', err as Error);
|
||||
};
|
||||
|
||||
resetStore() {
|
||||
this.patchState({
|
||||
shippingAddresses: [],
|
||||
selectedShippingAddress: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
selectShippingAddress(shippingAddress: ShippingAddressDTO) {
|
||||
this.patchState({
|
||||
selectedShippingAddress: shippingAddress,
|
||||
});
|
||||
}
|
||||
|
||||
selectCustomerAddress() {
|
||||
this.patchState({
|
||||
selectedShippingAddress: undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,213 +1,213 @@
|
||||
<shared-loader [loading]="fetching$ | async" background="true" spinnerSize="32">
|
||||
<div class="overflow-scroll max-h-[calc(100vh-15rem)]">
|
||||
<div class="customer-details-header grid grid-flow-row pb-6">
|
||||
<div
|
||||
class="customer-details-header-actions flex flex-row justify-end pt-4 px-4"
|
||||
>
|
||||
<page-customer-menu
|
||||
[customerId]="customerId$ | async"
|
||||
[processId]="processId$ | async"
|
||||
[hasCustomerCard]="hasKundenkarte$ | async"
|
||||
[showCustomerDetails]="false"
|
||||
></page-customer-menu>
|
||||
</div>
|
||||
<div class="customer-details-header-body text-center -mt-3">
|
||||
<h1 class="text-[1.625rem] font-bold">
|
||||
{{ (isBusinessKonto$ | async) ? 'Firmendetails' : 'Kundendetails' }}
|
||||
</h1>
|
||||
<p>Sind Ihre Kundendaten korrekt?</p>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="customer-details-customer-type flex flex-row justify-between items-center bg-surface-2 text-surface-2-content h-14"
|
||||
>
|
||||
<div
|
||||
class="pl-4 font-bold grid grid-flow-col justify-start items-center gap-2"
|
||||
>
|
||||
<shared-icon [icon]="customerType$ | async"></shared-icon>
|
||||
<span>
|
||||
{{ customerType$ | async }}
|
||||
</span>
|
||||
</div>
|
||||
@if (showEditButton$ | async) {
|
||||
@if (editRoute$ | async; as editRoute) {
|
||||
<a
|
||||
[routerLink]="editRoute.path"
|
||||
[queryParams]="editRoute.queryParams"
|
||||
[queryParamsHandling]="'merge'"
|
||||
class="btn btn-label font-bold text-brand"
|
||||
>
|
||||
Bearbeiten
|
||||
</a>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="customer-details-customer-main-data px-5 py-3 grid grid-flow-row gap-3"
|
||||
>
|
||||
<div class="flex flex-row">
|
||||
<div class="data-label">Erstellungsdatum</div>
|
||||
@if (created$ | async; as created) {
|
||||
<div class="data-value">
|
||||
{{ created | date: 'dd.MM.yyyy' }} |
|
||||
{{ created | date: 'HH:mm' }} Uhr
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="flex flex-row">
|
||||
<div class="data-label">Kundennummer</div>
|
||||
<div class="data-value">{{ customerNumber$ | async }}</div>
|
||||
</div>
|
||||
@if (customerNumberDig$ | async; as customerNumberDig) {
|
||||
<div class="flex flex-row">
|
||||
<div class="data-label">Kundennummer-DIG</div>
|
||||
<div class="data-value">{{ customerNumberDig }}</div>
|
||||
</div>
|
||||
}
|
||||
@if (customerNumberBeeline$ | async; as customerNumberBeeline) {
|
||||
<div class="flex flex-row">
|
||||
<div class="data-label">Kundennummer-BEELINE</div>
|
||||
<div class="data-value">{{ customerNumberBeeline }}</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@if (isBusinessKonto$ | async) {
|
||||
<div class="customer-details-customer-main-row">
|
||||
<div class="data-label">Firmenname</div>
|
||||
<div class="data-value">{{ organisationName$ | async }}</div>
|
||||
</div>
|
||||
<div class="customer-details-customer-main-row">
|
||||
<div class="data-label">Abteilung</div>
|
||||
<div class="data-value">{{ department$ | async }}</div>
|
||||
</div>
|
||||
<div class="customer-details-customer-main-row">
|
||||
<div class="data-label">USt-ID</div>
|
||||
<div class="data-value">{{ vatId$ | async }}</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="customer-details-customer-main-row">
|
||||
<div class="data-label">Anrede</div>
|
||||
<div class="data-value">{{ gender$ | async }}</div>
|
||||
</div>
|
||||
<div class="customer-details-customer-main-row">
|
||||
<div class="data-label">Titel</div>
|
||||
<div class="data-value">{{ title$ | async }}</div>
|
||||
</div>
|
||||
<div class="customer-details-customer-main-row">
|
||||
<div class="data-label">Nachname</div>
|
||||
<div class="data-value">{{ lastName$ | async }}</div>
|
||||
</div>
|
||||
<div class="customer-details-customer-main-row">
|
||||
<div class="data-label">Vorname</div>
|
||||
<div class="data-value">{{ firstName$ | async }}</div>
|
||||
</div>
|
||||
<div class="customer-details-customer-main-row">
|
||||
<div class="data-label">E-Mail</div>
|
||||
<div class="data-value">{{ email$ | async }}</div>
|
||||
</div>
|
||||
<div class="customer-details-customer-main-row">
|
||||
<div class="data-label">Straße</div>
|
||||
<div class="data-value">{{ street$ | async }}</div>
|
||||
</div>
|
||||
<div class="customer-details-customer-main-row">
|
||||
<div class="data-label">Hausnr.</div>
|
||||
<div class="data-value">{{ streetNumber$ | async }}</div>
|
||||
</div>
|
||||
<div class="customer-details-customer-main-row">
|
||||
<div class="data-label">PLZ</div>
|
||||
<div class="data-value">{{ zipCode$ | async }}</div>
|
||||
</div>
|
||||
<div class="customer-details-customer-main-row">
|
||||
<div class="data-label">Ort</div>
|
||||
<div class="data-value">{{ city$ | async }}</div>
|
||||
</div>
|
||||
<div class="customer-details-customer-main-row">
|
||||
<div class="data-label">Adresszusatz</div>
|
||||
<div class="data-value">{{ info$ | async }}</div>
|
||||
</div>
|
||||
<div class="customer-details-customer-main-row">
|
||||
<div class="data-label">Land</div>
|
||||
@if (country$ | async; as country) {
|
||||
<div class="data-value">{{ country | country }}</div>
|
||||
}
|
||||
</div>
|
||||
<div class="customer-details-customer-main-row">
|
||||
<div class="data-label">Festnetznr.</div>
|
||||
<div class="data-value">{{ landline$ | async }}</div>
|
||||
</div>
|
||||
<div class="customer-details-customer-main-row">
|
||||
<div class="data-label">Mobilnr.</div>
|
||||
<div class="data-value">{{ mobile$ | async }}</div>
|
||||
</div>
|
||||
|
||||
@if (!(isBusinessKonto$ | async)) {
|
||||
<div class="customer-details-customer-main-row">
|
||||
<div class="data-label">Geburtstag</div>
|
||||
<div class="data-value">
|
||||
{{ dateOfBirth$ | async | date: 'dd.MM.yyyy' }}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@if (!(isBusinessKonto$ | async) && (organisationName$ | async)) {
|
||||
<div class="customer-details-customer-main-row">
|
||||
<div class="data-label">Firmenname</div>
|
||||
<div class="data-value">{{ organisationName$ | async }}</div>
|
||||
</div>
|
||||
@if (!(isOnlineOrCustomerCardUser$ | async)) {
|
||||
<div class="customer-details-customer-main-row">
|
||||
<div class="data-label">Abteilung</div>
|
||||
<div class="data-value">{{ department$ | async }}</div>
|
||||
</div>
|
||||
<div class="customer-details-customer-main-row">
|
||||
<div class="data-label">USt-ID</div>
|
||||
<div class="data-value">{{ vatId$ | async }}</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
<page-details-main-view-billing-addresses></page-details-main-view-billing-addresses>
|
||||
<page-details-main-view-delivery-addresses></page-details-main-view-delivery-addresses>
|
||||
<div class="h-24"></div>
|
||||
</div>
|
||||
</shared-loader>
|
||||
|
||||
@if (!isRewardTab()) {
|
||||
@if (shoppingCartHasNoItems$ | async) {
|
||||
<button
|
||||
type="button"
|
||||
(click)="continue()"
|
||||
class="text-white text-lg bg-brand rounded-full px-5 py-3 absolute top-[calc(100vh-19.375rem)] left-1/2 -translate-x-1/2 font-bold disabled:bg-inactive-branch"
|
||||
[disabled]="showLoader$ | async"
|
||||
>
|
||||
<shared-loader [loading]="showLoader$ | async" spinnerSize="32"
|
||||
>Weiter zur Artikelsuche</shared-loader
|
||||
>
|
||||
</button>
|
||||
}
|
||||
|
||||
@if (shoppingCartHasItems$ | async) {
|
||||
<button
|
||||
type="button"
|
||||
(click)="continue()"
|
||||
class="text-white text-lg bg-brand rounded-full px-5 py-3 absolute top-[calc(100vh-19.375rem)] left-1/2 -translate-x-1/2 font-bold disabled:bg-inactive-branch"
|
||||
[disabled]="showLoader$ | async"
|
||||
>
|
||||
<shared-loader [loading]="showLoader$ | async" spinnerSize="32"
|
||||
>Weiter zum Warenkorb</shared-loader
|
||||
>
|
||||
</button>
|
||||
}
|
||||
} @else {
|
||||
<button
|
||||
type="button"
|
||||
(click)="continue()"
|
||||
class="w-60 text-white text-lg bg-brand rounded-full px-5 py-3 absolute top-[calc(100vh-19.375rem)] left-1/2 -translate-x-1/2 font-bold disabled:bg-inactive-branch"
|
||||
[disabled]="!(hasKundenkarte$ | async)"
|
||||
>
|
||||
<shared-loader [loading]="showLoader$ | async" spinnerSize="32"
|
||||
>Auswählen</shared-loader
|
||||
>
|
||||
</button>
|
||||
}
|
||||
<shared-loader [loading]="fetching$ | async" background="true" spinnerSize="32">
|
||||
<div class="overflow-scroll max-h-[calc(100vh-15rem)]">
|
||||
<div class="customer-details-header grid grid-flow-row pb-6">
|
||||
<div
|
||||
class="customer-details-header-actions flex flex-row justify-end pt-4 px-4"
|
||||
>
|
||||
<page-customer-menu
|
||||
[customerId]="customerId$ | async"
|
||||
[processId]="processId$ | async"
|
||||
[hasCustomerCard]="hasKundenkarte$ | async"
|
||||
[showCustomerDetails]="false"
|
||||
></page-customer-menu>
|
||||
</div>
|
||||
<div class="customer-details-header-body text-center -mt-3">
|
||||
<h1 class="text-[1.625rem] font-bold">
|
||||
{{ (isBusinessKonto$ | async) ? 'Firmendetails' : 'Kundendetails' }}
|
||||
</h1>
|
||||
<p>Sind Ihre Kundendaten korrekt?</p>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="customer-details-customer-type flex flex-row justify-between items-center bg-surface-2 text-surface-2-content h-14"
|
||||
>
|
||||
<div
|
||||
class="pl-4 font-bold grid grid-flow-col justify-start items-center gap-2"
|
||||
>
|
||||
<shared-icon [icon]="customerType$ | async"></shared-icon>
|
||||
<span>
|
||||
{{ customerType$ | async }}
|
||||
</span>
|
||||
</div>
|
||||
@if (showEditButton$ | async) {
|
||||
@if (editRoute$ | async; as editRoute) {
|
||||
<a
|
||||
[routerLink]="editRoute.path"
|
||||
[queryParams]="editRoute.queryParams"
|
||||
[queryParamsHandling]="'merge'"
|
||||
class="btn btn-label font-bold text-brand"
|
||||
>
|
||||
Bearbeiten
|
||||
</a>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="customer-details-customer-main-data px-5 py-3 grid grid-flow-row gap-3"
|
||||
>
|
||||
<div class="flex flex-row">
|
||||
<div class="data-label">Erstellungsdatum</div>
|
||||
@if (created$ | async; as created) {
|
||||
<div class="data-value">
|
||||
{{ created | date: 'dd.MM.yyyy' }} |
|
||||
{{ created | date: 'HH:mm' }} Uhr
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="flex flex-row">
|
||||
<div class="data-label">Kundennummer</div>
|
||||
<div class="data-value">{{ customerNumber$ | async }}</div>
|
||||
</div>
|
||||
@if (customerNumberDig$ | async; as customerNumberDig) {
|
||||
<div class="flex flex-row">
|
||||
<div class="data-label">Kundennummer-DIG</div>
|
||||
<div class="data-value">{{ customerNumberDig }}</div>
|
||||
</div>
|
||||
}
|
||||
@if (customerNumberBeeline$ | async; as customerNumberBeeline) {
|
||||
<div class="flex flex-row">
|
||||
<div class="data-label">Kundennummer-BEELINE</div>
|
||||
<div class="data-value">{{ customerNumberBeeline }}</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@if (isBusinessKonto$ | async) {
|
||||
<div class="customer-details-customer-main-row">
|
||||
<div class="data-label">Firmenname</div>
|
||||
<div class="data-value">{{ organisationName$ | async }}</div>
|
||||
</div>
|
||||
<div class="customer-details-customer-main-row">
|
||||
<div class="data-label">Abteilung</div>
|
||||
<div class="data-value">{{ department$ | async }}</div>
|
||||
</div>
|
||||
<div class="customer-details-customer-main-row">
|
||||
<div class="data-label">USt-ID</div>
|
||||
<div class="data-value">{{ vatId$ | async }}</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="customer-details-customer-main-row">
|
||||
<div class="data-label">Anrede</div>
|
||||
<div class="data-value">{{ gender$ | async }}</div>
|
||||
</div>
|
||||
<div class="customer-details-customer-main-row">
|
||||
<div class="data-label">Titel</div>
|
||||
<div class="data-value">{{ title$ | async }}</div>
|
||||
</div>
|
||||
<div class="customer-details-customer-main-row">
|
||||
<div class="data-label">Nachname</div>
|
||||
<div class="data-value">{{ lastName$ | async }}</div>
|
||||
</div>
|
||||
<div class="customer-details-customer-main-row">
|
||||
<div class="data-label">Vorname</div>
|
||||
<div class="data-value">{{ firstName$ | async }}</div>
|
||||
</div>
|
||||
<div class="customer-details-customer-main-row">
|
||||
<div class="data-label">E-Mail</div>
|
||||
<div class="data-value">{{ email$ | async }}</div>
|
||||
</div>
|
||||
<div class="customer-details-customer-main-row">
|
||||
<div class="data-label">Straße</div>
|
||||
<div class="data-value">{{ street$ | async }}</div>
|
||||
</div>
|
||||
<div class="customer-details-customer-main-row">
|
||||
<div class="data-label">Hausnr.</div>
|
||||
<div class="data-value">{{ streetNumber$ | async }}</div>
|
||||
</div>
|
||||
<div class="customer-details-customer-main-row">
|
||||
<div class="data-label">PLZ</div>
|
||||
<div class="data-value">{{ zipCode$ | async }}</div>
|
||||
</div>
|
||||
<div class="customer-details-customer-main-row">
|
||||
<div class="data-label">Ort</div>
|
||||
<div class="data-value">{{ city$ | async }}</div>
|
||||
</div>
|
||||
<div class="customer-details-customer-main-row">
|
||||
<div class="data-label">Adresszusatz</div>
|
||||
<div class="data-value">{{ info$ | async }}</div>
|
||||
</div>
|
||||
<div class="customer-details-customer-main-row">
|
||||
<div class="data-label">Land</div>
|
||||
@if (country$ | async; as country) {
|
||||
<div class="data-value">{{ country | country }}</div>
|
||||
}
|
||||
</div>
|
||||
<div class="customer-details-customer-main-row">
|
||||
<div class="data-label">Festnetznr.</div>
|
||||
<div class="data-value">{{ landline$ | async }}</div>
|
||||
</div>
|
||||
<div class="customer-details-customer-main-row">
|
||||
<div class="data-label">Mobilnr.</div>
|
||||
<div class="data-value">{{ mobile$ | async }}</div>
|
||||
</div>
|
||||
|
||||
@if (!(isBusinessKonto$ | async)) {
|
||||
<div class="customer-details-customer-main-row">
|
||||
<div class="data-label">Geburtstag</div>
|
||||
<div class="data-value">
|
||||
{{ dateOfBirth$ | async | date: 'dd.MM.yyyy' }}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@if (!(isBusinessKonto$ | async) && (organisationName$ | async)) {
|
||||
<div class="customer-details-customer-main-row">
|
||||
<div class="data-label">Firmenname</div>
|
||||
<div class="data-value">{{ organisationName$ | async }}</div>
|
||||
</div>
|
||||
@if (!(isOnlineOrCustomerCardUser$ | async)) {
|
||||
<div class="customer-details-customer-main-row">
|
||||
<div class="data-label">Abteilung</div>
|
||||
<div class="data-value">{{ department$ | async }}</div>
|
||||
</div>
|
||||
<div class="customer-details-customer-main-row">
|
||||
<div class="data-label">USt-ID</div>
|
||||
<div class="data-value">{{ vatId$ | async }}</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
<page-details-main-view-billing-addresses></page-details-main-view-billing-addresses>
|
||||
<page-details-main-view-delivery-addresses></page-details-main-view-delivery-addresses>
|
||||
<div class="h-24"></div>
|
||||
</div>
|
||||
</shared-loader>
|
||||
|
||||
@if (hasReturnUrl()) {
|
||||
<button
|
||||
type="button"
|
||||
(click)="continueReward()"
|
||||
class="w-60 text-white text-lg bg-brand rounded-full px-5 py-3 absolute top-[calc(100vh-19.375rem)] left-1/2 -translate-x-1/2 font-bold disabled:bg-inactive-branch"
|
||||
[disabled]="!(hasKundenkarte$ | async)"
|
||||
>
|
||||
<shared-loader [loading]="showLoader$ | async" spinnerSize="32"
|
||||
>Auswählen</shared-loader
|
||||
>
|
||||
</button>
|
||||
} @else {
|
||||
@if (shoppingCartHasNoItems$ | async) {
|
||||
<button
|
||||
type="button"
|
||||
(click)="continue()"
|
||||
class="text-white text-lg bg-brand rounded-full px-5 py-3 absolute top-[calc(100vh-19.375rem)] left-1/2 -translate-x-1/2 font-bold disabled:bg-inactive-branch"
|
||||
[disabled]="showLoader$ | async"
|
||||
>
|
||||
<shared-loader [loading]="showLoader$ | async" spinnerSize="32"
|
||||
>Weiter zur Artikelsuche</shared-loader
|
||||
>
|
||||
</button>
|
||||
}
|
||||
|
||||
@if (shoppingCartHasItems$ | async) {
|
||||
<button
|
||||
type="button"
|
||||
(click)="continue()"
|
||||
class="text-white text-lg bg-brand rounded-full px-5 py-3 absolute top-[calc(100vh-19.375rem)] left-1/2 -translate-x-1/2 font-bold disabled:bg-inactive-branch"
|
||||
[disabled]="showLoader$ | async"
|
||||
>
|
||||
<shared-loader [loading]="showLoader$ | async" spinnerSize="32"
|
||||
>Weiter zum Warenkorb</shared-loader
|
||||
>
|
||||
</button>
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -16,13 +16,34 @@
|
||||
[deltaEnd]="150"
|
||||
[itemLength]="itemLength$ | async"
|
||||
[containerHeight]="24.5"
|
||||
>
|
||||
@for (bueryNumberGroup of items$ | async | groupBy: byBuyerNumberFn; track bueryNumberGroup) {
|
||||
>
|
||||
@for (
|
||||
bueryNumberGroup of items$ | async | groupBy: byBuyerNumberFn;
|
||||
track bueryNumberGroup
|
||||
) {
|
||||
<shared-goods-in-out-order-group>
|
||||
@for (orderNumberGroup of bueryNumberGroup.items | groupBy: byOrderNumberFn; track orderNumberGroup; let lastOrderNumber = $last) {
|
||||
@for (processingStatusGroup of orderNumberGroup.items | groupBy: byProcessingStatusFn; track processingStatusGroup; let lastProcessingStatus = $last) {
|
||||
@for (compartmentCodeGroup of processingStatusGroup.items | groupBy: byCompartmentCodeFn; track compartmentCodeGroup; let lastCompartmentCode = $last) {
|
||||
@for (item of compartmentCodeGroup.items; track item; let firstItem = $first) {
|
||||
@for (
|
||||
orderNumberGroup of bueryNumberGroup.items | groupBy: byOrderNumberFn;
|
||||
track orderNumberGroup;
|
||||
let lastOrderNumber = $last
|
||||
) {
|
||||
@for (
|
||||
processingStatusGroup of orderNumberGroup.items
|
||||
| groupBy: byProcessingStatusFn;
|
||||
track processingStatusGroup;
|
||||
let lastProcessingStatus = $last
|
||||
) {
|
||||
@for (
|
||||
compartmentCodeGroup of processingStatusGroup.items
|
||||
| groupBy: byCompartmentCodeFn;
|
||||
track compartmentCodeGroup;
|
||||
let lastCompartmentCode = $last
|
||||
) {
|
||||
@for (
|
||||
item of compartmentCodeGroup.items;
|
||||
track item;
|
||||
let firstItem = $first
|
||||
) {
|
||||
<shared-goods-in-out-order-group-item
|
||||
[item]="item"
|
||||
[showCompartmentCode]="firstItem"
|
||||
@@ -49,7 +70,6 @@
|
||||
<div class="empty-message">Es sind im Moment keine Artikel vorhanden</div>
|
||||
}
|
||||
|
||||
|
||||
<div class="actions">
|
||||
@if (actions$ | async; as actions) {
|
||||
@for (action of actions; track action) {
|
||||
@@ -57,19 +77,27 @@
|
||||
[disabled]="(changeActionLoader$ | async) || (loading$ | async)"
|
||||
class="cta-action cta-action-primary"
|
||||
(click)="handleAction(action)"
|
||||
>
|
||||
<ui-spinner
|
||||
[show]="(changeActionLoader$ | async) || (loading$ | async)"
|
||||
>{{ action.label }}</ui-spinner
|
||||
>
|
||||
<ui-spinner [show]="(changeActionLoader$ | async) || (loading$ | async)">{{ action.label }}</ui-spinner>
|
||||
</button>
|
||||
}
|
||||
}
|
||||
|
||||
@if (listEmpty$ | async) {
|
||||
<a class="cta-action cta-action-secondary" [routerLink]="['/filiale', 'goods', 'in']">
|
||||
<a
|
||||
class="cta-action cta-action-secondary"
|
||||
[routerLink]="['/filiale', 'goods', 'in']"
|
||||
>
|
||||
Zur Bestellpostensuche
|
||||
</a>
|
||||
}
|
||||
|
||||
@if (listEmpty$ | async) {
|
||||
<a class="cta-action cta-action-primary" [routerLink]="['/filiale', 'remission']">Zur Remission</a>
|
||||
<a class="cta-action cta-action-primary" [routerLink]="remissionPath()"
|
||||
>Zur Remission</a
|
||||
>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -1,200 +1,254 @@
|
||||
import { ChangeDetectionStrategy, Component, OnDestroy, OnInit, ViewChild, inject } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { BreadcrumbService } from '@core/breadcrumb';
|
||||
import { KeyValueDTOOfStringAndString, OrderItemListItemDTO } from '@generated/swagger/oms-api';
|
||||
import { UiErrorModalComponent, UiModalService } from '@ui/modal';
|
||||
import { UiScrollContainerComponent } from '@ui/scroll-container';
|
||||
import { BehaviorSubject, combineLatest, Subject } from 'rxjs';
|
||||
import { first, map, shareReplay, takeUntil } from 'rxjs/operators';
|
||||
import { GoodsInRemissionPreviewStore } from './goods-in-remission-preview.store';
|
||||
import { Config } from '@core/config';
|
||||
import { ToasterService } from '@shared/shell';
|
||||
import { PickupShelfInNavigationService } from '@shared/services/navigation';
|
||||
import { CacheService } from '@core/cache';
|
||||
|
||||
@Component({
|
||||
selector: 'page-goods-in-remission-preview',
|
||||
templateUrl: 'goods-in-remission-preview.component.html',
|
||||
styleUrls: ['goods-in-remission-preview.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
providers: [GoodsInRemissionPreviewStore],
|
||||
standalone: false,
|
||||
})
|
||||
export class GoodsInRemissionPreviewComponent implements OnInit, OnDestroy {
|
||||
private _pickupShelfInNavigationService = inject(PickupShelfInNavigationService);
|
||||
@ViewChild(UiScrollContainerComponent) scrollContainer: UiScrollContainerComponent;
|
||||
|
||||
items$ = this._store.results$;
|
||||
|
||||
itemLength$ = this.items$.pipe(map((items) => items?.length));
|
||||
|
||||
hits$ = this._store.hits$;
|
||||
|
||||
loading$ = this._store.fetching$.pipe(shareReplay());
|
||||
|
||||
changeActionLoader$ = new BehaviorSubject<boolean>(false);
|
||||
|
||||
listEmpty$ = combineLatest([this.loading$, this.hits$]).pipe(
|
||||
map(([loading, hits]) => !loading && hits === 0),
|
||||
shareReplay(),
|
||||
);
|
||||
|
||||
actions$ = this.items$.pipe(map((items) => items[0]?.actions));
|
||||
|
||||
private _onDestroy$ = new Subject<void>();
|
||||
|
||||
byBuyerNumberFn = (item: OrderItemListItemDTO) => item.buyerNumber;
|
||||
|
||||
byOrderNumberFn = (item: OrderItemListItemDTO) => item.orderNumber;
|
||||
|
||||
byProcessingStatusFn = (item: OrderItemListItemDTO) => item.processingStatus;
|
||||
|
||||
byCompartmentCodeFn = (item: OrderItemListItemDTO) =>
|
||||
item.compartmentInfo ? `${item.compartmentCode}_${item.compartmentInfo}` : item.compartmentCode;
|
||||
|
||||
private readonly SCROLL_POSITION_TOKEN = 'REMISSION_PREVIEW_SCROLL_POSITION';
|
||||
|
||||
constructor(
|
||||
private _breadcrumb: BreadcrumbService,
|
||||
private _store: GoodsInRemissionPreviewStore,
|
||||
private _router: Router,
|
||||
private _modal: UiModalService,
|
||||
private _config: Config,
|
||||
private _toast: ToasterService,
|
||||
private _cache: CacheService,
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.initInitialSearch();
|
||||
this.createBreadcrumb();
|
||||
this.removeBreadcrumbs();
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this._onDestroy$.next();
|
||||
this._onDestroy$.complete();
|
||||
this._addScrollPositionToCache();
|
||||
this.updateBreadcrumb();
|
||||
}
|
||||
|
||||
private _removeScrollPositionFromCache(): void {
|
||||
this._cache.delete({ processId: this._config.get('process.ids.goodsIn'), token: this.SCROLL_POSITION_TOKEN });
|
||||
}
|
||||
|
||||
private _addScrollPositionToCache(): void {
|
||||
this._cache.set<number>(
|
||||
{ processId: this._config.get('process.ids.goodsIn'), token: this.SCROLL_POSITION_TOKEN },
|
||||
this.scrollContainer?.scrollPos,
|
||||
);
|
||||
}
|
||||
|
||||
private async _getScrollPositionFromCache(): Promise<number> {
|
||||
return await this._cache.get<number>({
|
||||
processId: this._config.get('process.ids.goodsIn'),
|
||||
token: this.SCROLL_POSITION_TOKEN,
|
||||
});
|
||||
}
|
||||
|
||||
async createBreadcrumb() {
|
||||
await this._breadcrumb.addOrUpdateBreadcrumbIfNotExists({
|
||||
key: this._config.get('process.ids.goodsIn'),
|
||||
name: 'Abholfachremissionsvorschau',
|
||||
path: '/filiale/goods/in/preview',
|
||||
section: 'branch',
|
||||
params: { view: 'remission' },
|
||||
tags: ['goods-in', 'preview'],
|
||||
});
|
||||
}
|
||||
|
||||
async updateBreadcrumb() {
|
||||
const crumbs = await this._breadcrumb
|
||||
.getBreadcrumbsByKeyAndTags$(this._config.get('process.ids.goodsIn'), ['goods-in', 'preview'])
|
||||
.pipe(first())
|
||||
.toPromise();
|
||||
for (const crumb of crumbs) {
|
||||
this._breadcrumb.patchBreadcrumb(crumb.id, {
|
||||
name: crumb.name,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async removeBreadcrumbs() {
|
||||
let breadcrumbsToDelete = await this._breadcrumb
|
||||
.getBreadcrumbsByKeyAndTags$(this._config.get('process.ids.goodsIn'), ['goods-in'])
|
||||
.pipe(first())
|
||||
.toPromise();
|
||||
|
||||
breadcrumbsToDelete = breadcrumbsToDelete.filter(
|
||||
(crumb) => !crumb.tags.includes('preview') && !crumb.tags.includes('main'),
|
||||
);
|
||||
|
||||
breadcrumbsToDelete.forEach((crumb) => {
|
||||
this._breadcrumb.removeBreadcrumb(crumb.id, true);
|
||||
});
|
||||
|
||||
const detailsCrumbs = await this._breadcrumb
|
||||
.getBreadcrumbsByKeyAndTags$(this._config.get('process.ids.goodsIn'), ['goods-in', 'details'])
|
||||
.pipe(first())
|
||||
.toPromise();
|
||||
const editCrumbs = await this._breadcrumb
|
||||
.getBreadcrumbsByKeyAndTags$(this._config.get('process.ids.goodsIn'), ['goods-in', 'edit'])
|
||||
.pipe(first())
|
||||
.toPromise();
|
||||
|
||||
detailsCrumbs.forEach((crumb) => {
|
||||
this._breadcrumb.removeBreadcrumb(crumb.id, true);
|
||||
});
|
||||
|
||||
editCrumbs.forEach((crumb) => {
|
||||
this._breadcrumb.removeBreadcrumb(crumb.id, true);
|
||||
});
|
||||
}
|
||||
|
||||
initInitialSearch() {
|
||||
if (this._store.hits === 0) {
|
||||
this._store.searchResult$.pipe(takeUntil(this._onDestroy$)).subscribe(async (result) => {
|
||||
await this.createBreadcrumb();
|
||||
|
||||
this.scrollContainer?.scrollTo((await this._getScrollPositionFromCache()) ?? 0);
|
||||
this._removeScrollPositionFromCache();
|
||||
});
|
||||
}
|
||||
|
||||
this._store.search();
|
||||
}
|
||||
|
||||
async navigateToRemission() {
|
||||
await this._router.navigate(['/filiale/remission']);
|
||||
}
|
||||
|
||||
navigateToDetails(orderItem: OrderItemListItemDTO) {
|
||||
const nav = this._pickupShelfInNavigationService.detailRoute({ item: orderItem, side: false });
|
||||
|
||||
this._router.navigate(nav.path, { queryParams: { ...nav.queryParams, view: 'remission' } });
|
||||
}
|
||||
|
||||
async handleAction(action: KeyValueDTOOfStringAndString) {
|
||||
this.changeActionLoader$.next(true);
|
||||
|
||||
try {
|
||||
const response = await this._store.createRemissionFromPreview().pipe(first()).toPromise();
|
||||
|
||||
if (!response?.dialog) {
|
||||
this._toast.open({
|
||||
title: 'Abholfachremission',
|
||||
message: response?.message,
|
||||
});
|
||||
}
|
||||
|
||||
await this.navigateToRemission();
|
||||
} catch (error) {
|
||||
this._modal.open({
|
||||
content: UiErrorModalComponent,
|
||||
data: error,
|
||||
});
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
this.changeActionLoader$.next(false);
|
||||
}
|
||||
}
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
OnDestroy,
|
||||
OnInit,
|
||||
ViewChild,
|
||||
inject,
|
||||
linkedSignal,
|
||||
} from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { BreadcrumbService } from '@core/breadcrumb';
|
||||
import {
|
||||
KeyValueDTOOfStringAndString,
|
||||
OrderItemListItemDTO,
|
||||
} from '@generated/swagger/oms-api';
|
||||
import { UiErrorModalComponent, UiModalService } from '@ui/modal';
|
||||
import { UiScrollContainerComponent } from '@ui/scroll-container';
|
||||
import { BehaviorSubject, combineLatest, Subject } from 'rxjs';
|
||||
import { first, map, shareReplay, takeUntil } from 'rxjs/operators';
|
||||
import { GoodsInRemissionPreviewStore } from './goods-in-remission-preview.store';
|
||||
import { Config } from '@core/config';
|
||||
import { ToasterService } from '@shared/shell';
|
||||
import { PickupShelfInNavigationService } from '@shared/services/navigation';
|
||||
import { CacheService } from '@core/cache';
|
||||
import { TabService } from '@isa/core/tabs';
|
||||
|
||||
@Component({
|
||||
selector: 'page-goods-in-remission-preview',
|
||||
templateUrl: 'goods-in-remission-preview.component.html',
|
||||
styleUrls: ['goods-in-remission-preview.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
providers: [GoodsInRemissionPreviewStore],
|
||||
standalone: false,
|
||||
})
|
||||
export class GoodsInRemissionPreviewComponent implements OnInit, OnDestroy {
|
||||
tabService = inject(TabService);
|
||||
private _pickupShelfInNavigationService = inject(
|
||||
PickupShelfInNavigationService,
|
||||
);
|
||||
@ViewChild(UiScrollContainerComponent)
|
||||
scrollContainer: UiScrollContainerComponent;
|
||||
|
||||
items$ = this._store.results$;
|
||||
|
||||
itemLength$ = this.items$.pipe(map((items) => items?.length));
|
||||
|
||||
hits$ = this._store.hits$;
|
||||
|
||||
loading$ = this._store.fetching$.pipe(shareReplay());
|
||||
|
||||
changeActionLoader$ = new BehaviorSubject<boolean>(false);
|
||||
|
||||
listEmpty$ = combineLatest([this.loading$, this.hits$]).pipe(
|
||||
map(([loading, hits]) => !loading && hits === 0),
|
||||
shareReplay(),
|
||||
);
|
||||
|
||||
actions$ = this.items$.pipe(map((items) => items[0]?.actions));
|
||||
|
||||
private _onDestroy$ = new Subject<void>();
|
||||
|
||||
byBuyerNumberFn = (item: OrderItemListItemDTO) => item.buyerNumber;
|
||||
|
||||
byOrderNumberFn = (item: OrderItemListItemDTO) => item.orderNumber;
|
||||
|
||||
byProcessingStatusFn = (item: OrderItemListItemDTO) => item.processingStatus;
|
||||
|
||||
byCompartmentCodeFn = (item: OrderItemListItemDTO) =>
|
||||
item.compartmentInfo
|
||||
? `${item.compartmentCode}_${item.compartmentInfo}`
|
||||
: item.compartmentCode;
|
||||
|
||||
private readonly SCROLL_POSITION_TOKEN = 'REMISSION_PREVIEW_SCROLL_POSITION';
|
||||
|
||||
remissionPath = linkedSignal(() => [
|
||||
'/',
|
||||
this.tabService.activatedTab()?.id || Date.now(),
|
||||
'remission',
|
||||
]);
|
||||
|
||||
constructor(
|
||||
private _breadcrumb: BreadcrumbService,
|
||||
private _store: GoodsInRemissionPreviewStore,
|
||||
private _router: Router,
|
||||
private _modal: UiModalService,
|
||||
private _config: Config,
|
||||
private _toast: ToasterService,
|
||||
private _cache: CacheService,
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.initInitialSearch();
|
||||
this.createBreadcrumb();
|
||||
this.removeBreadcrumbs();
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this._onDestroy$.next();
|
||||
this._onDestroy$.complete();
|
||||
this._addScrollPositionToCache();
|
||||
this.updateBreadcrumb();
|
||||
}
|
||||
|
||||
private _removeScrollPositionFromCache(): void {
|
||||
this._cache.delete({
|
||||
processId: this._config.get('process.ids.goodsIn'),
|
||||
token: this.SCROLL_POSITION_TOKEN,
|
||||
});
|
||||
}
|
||||
|
||||
private _addScrollPositionToCache(): void {
|
||||
this._cache.set<number>(
|
||||
{
|
||||
processId: this._config.get('process.ids.goodsIn'),
|
||||
token: this.SCROLL_POSITION_TOKEN,
|
||||
},
|
||||
this.scrollContainer?.scrollPos,
|
||||
);
|
||||
}
|
||||
|
||||
private async _getScrollPositionFromCache(): Promise<number> {
|
||||
return await this._cache.get<number>({
|
||||
processId: this._config.get('process.ids.goodsIn'),
|
||||
token: this.SCROLL_POSITION_TOKEN,
|
||||
});
|
||||
}
|
||||
|
||||
async createBreadcrumb() {
|
||||
await this._breadcrumb.addOrUpdateBreadcrumbIfNotExists({
|
||||
key: this._config.get('process.ids.goodsIn'),
|
||||
name: 'Abholfachremissionsvorschau',
|
||||
path: '/filiale/goods/in/preview',
|
||||
section: 'branch',
|
||||
params: { view: 'remission' },
|
||||
tags: ['goods-in', 'preview'],
|
||||
});
|
||||
}
|
||||
|
||||
async updateBreadcrumb() {
|
||||
const crumbs = await this._breadcrumb
|
||||
.getBreadcrumbsByKeyAndTags$(this._config.get('process.ids.goodsIn'), [
|
||||
'goods-in',
|
||||
'preview',
|
||||
])
|
||||
.pipe(first())
|
||||
.toPromise();
|
||||
for (const crumb of crumbs) {
|
||||
this._breadcrumb.patchBreadcrumb(crumb.id, {
|
||||
name: crumb.name,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async removeBreadcrumbs() {
|
||||
let breadcrumbsToDelete = await this._breadcrumb
|
||||
.getBreadcrumbsByKeyAndTags$(this._config.get('process.ids.goodsIn'), [
|
||||
'goods-in',
|
||||
])
|
||||
.pipe(first())
|
||||
.toPromise();
|
||||
|
||||
breadcrumbsToDelete = breadcrumbsToDelete.filter(
|
||||
(crumb) =>
|
||||
!crumb.tags.includes('preview') && !crumb.tags.includes('main'),
|
||||
);
|
||||
|
||||
breadcrumbsToDelete.forEach((crumb) => {
|
||||
this._breadcrumb.removeBreadcrumb(crumb.id, true);
|
||||
});
|
||||
|
||||
const detailsCrumbs = await this._breadcrumb
|
||||
.getBreadcrumbsByKeyAndTags$(this._config.get('process.ids.goodsIn'), [
|
||||
'goods-in',
|
||||
'details',
|
||||
])
|
||||
.pipe(first())
|
||||
.toPromise();
|
||||
const editCrumbs = await this._breadcrumb
|
||||
.getBreadcrumbsByKeyAndTags$(this._config.get('process.ids.goodsIn'), [
|
||||
'goods-in',
|
||||
'edit',
|
||||
])
|
||||
.pipe(first())
|
||||
.toPromise();
|
||||
|
||||
detailsCrumbs.forEach((crumb) => {
|
||||
this._breadcrumb.removeBreadcrumb(crumb.id, true);
|
||||
});
|
||||
|
||||
editCrumbs.forEach((crumb) => {
|
||||
this._breadcrumb.removeBreadcrumb(crumb.id, true);
|
||||
});
|
||||
}
|
||||
|
||||
initInitialSearch() {
|
||||
if (this._store.hits === 0) {
|
||||
this._store.searchResult$
|
||||
.pipe(takeUntil(this._onDestroy$))
|
||||
.subscribe(async (result) => {
|
||||
await this.createBreadcrumb();
|
||||
|
||||
this.scrollContainer?.scrollTo(
|
||||
(await this._getScrollPositionFromCache()) ?? 0,
|
||||
);
|
||||
this._removeScrollPositionFromCache();
|
||||
});
|
||||
}
|
||||
|
||||
this._store.search();
|
||||
}
|
||||
|
||||
async navigateToRemission() {
|
||||
await this._router.navigate(this.remissionPath());
|
||||
}
|
||||
|
||||
navigateToDetails(orderItem: OrderItemListItemDTO) {
|
||||
const nav = this._pickupShelfInNavigationService.detailRoute({
|
||||
item: orderItem,
|
||||
side: false,
|
||||
});
|
||||
|
||||
this._router.navigate(nav.path, {
|
||||
queryParams: { ...nav.queryParams, view: 'remission' },
|
||||
});
|
||||
}
|
||||
|
||||
async handleAction(action: KeyValueDTOOfStringAndString) {
|
||||
this.changeActionLoader$.next(true);
|
||||
|
||||
try {
|
||||
const response = await this._store
|
||||
.createRemissionFromPreview()
|
||||
.pipe(first())
|
||||
.toPromise();
|
||||
|
||||
if (!response?.dialog) {
|
||||
this._toast.open({
|
||||
title: 'Abholfachremission',
|
||||
message: response?.message,
|
||||
});
|
||||
}
|
||||
|
||||
await this.navigateToRemission();
|
||||
} catch (error) {
|
||||
this._modal.open({
|
||||
content: UiErrorModalComponent,
|
||||
data: error,
|
||||
});
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
this.changeActionLoader$.next(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,275 +1,328 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { AuthService } from '@core/auth';
|
||||
import { DomainAvailabilityService } from '@domain/availability';
|
||||
import { OpenStreetMap, OpenStreetMapParams, PlaceDto } from '@external/openstreetmap';
|
||||
import { ComponentStore } from '@ngrx/component-store';
|
||||
import { tapResponse } from '@ngrx/operators';
|
||||
|
||||
import { BranchDTO, BranchType } from '@generated/swagger/checkout-api';
|
||||
import { UiErrorModalComponent, UiModalService } from '@ui/modal';
|
||||
import { geoDistance, GeoLocation } from '@utils/common';
|
||||
import { debounceTime, switchMap, tap, withLatestFrom } from 'rxjs/operators';
|
||||
|
||||
export interface BranchSelectorState {
|
||||
query: string;
|
||||
fetching: boolean;
|
||||
branches: BranchDTO[];
|
||||
filteredBranches: BranchDTO[];
|
||||
selectedBranch?: BranchDTO;
|
||||
online?: boolean;
|
||||
orderingEnabled?: boolean;
|
||||
shippingEnabled?: boolean;
|
||||
filterCurrentBranch?: boolean;
|
||||
currentBranchNumber?: string;
|
||||
orderBy?: 'name' | 'distance';
|
||||
branchType?: number;
|
||||
}
|
||||
|
||||
function branchSorterFn(a: BranchDTO, b: BranchDTO, userBranch: BranchDTO) {
|
||||
return (
|
||||
geoDistance(userBranch?.address?.geoLocation, a?.address?.geoLocation) -
|
||||
geoDistance(userBranch?.address?.geoLocation, b?.address?.geoLocation)
|
||||
);
|
||||
}
|
||||
|
||||
function selectBranches(state: BranchSelectorState) {
|
||||
if (!state?.branches) {
|
||||
return [];
|
||||
}
|
||||
|
||||
let branches = state.branches;
|
||||
|
||||
if (typeof state.online === 'boolean') {
|
||||
branches = branches.filter((branch) => !!branch?.isOnline === state.online);
|
||||
}
|
||||
|
||||
if (typeof state.orderingEnabled === 'boolean') {
|
||||
branches = branches.filter((branch) => !!branch?.isOrderingEnabled === state.orderingEnabled);
|
||||
}
|
||||
|
||||
if (typeof state.shippingEnabled === 'boolean') {
|
||||
branches = branches.filter((branch) => !!branch?.isShippingEnabled === state.shippingEnabled);
|
||||
}
|
||||
|
||||
if (typeof state.filterCurrentBranch === 'boolean' && typeof state.currentBranchNumber === 'string') {
|
||||
branches = branches.filter((branch) => branch?.branchNumber !== state.currentBranchNumber);
|
||||
}
|
||||
|
||||
if (typeof state.orderBy === 'string' && typeof state.currentBranchNumber === 'string') {
|
||||
switch (state.orderBy) {
|
||||
case 'name':
|
||||
branches?.sort((branchA, branchB) => branchA?.name?.localeCompare(branchB?.name));
|
||||
break;
|
||||
case 'distance':
|
||||
const currentBranch = state.branches?.find((b) => b?.branchNumber === state.currentBranchNumber);
|
||||
branches?.sort((a: BranchDTO, b: BranchDTO) => branchSorterFn(a, b, currentBranch));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof state.branchType === 'number') {
|
||||
branches = branches.filter((branch) => branch?.branchType === state.branchType);
|
||||
}
|
||||
|
||||
return branches;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class BranchSelectorStore extends ComponentStore<BranchSelectorState> {
|
||||
get query() {
|
||||
return this.get((s) => s.query);
|
||||
}
|
||||
|
||||
readonly query$ = this.select((s) => s.query);
|
||||
|
||||
get fetching() {
|
||||
return this.get((s) => s.fetching);
|
||||
}
|
||||
|
||||
readonly fetching$ = this.select((s) => s.fetching);
|
||||
|
||||
get branches() {
|
||||
return this.get(selectBranches);
|
||||
}
|
||||
|
||||
readonly branches$ = this.select(selectBranches);
|
||||
|
||||
get filteredBranches() {
|
||||
return this.get((s) => s.filteredBranches);
|
||||
}
|
||||
|
||||
readonly filteredBranches$ = this.select((s) => s.filteredBranches);
|
||||
|
||||
get selectedBranch() {
|
||||
return this.get((s) => s.selectedBranch);
|
||||
}
|
||||
|
||||
readonly selectedBranch$ = this.select((s) => s.selectedBranch);
|
||||
|
||||
constructor(
|
||||
private _availabilityService: DomainAvailabilityService,
|
||||
private _uiModal: UiModalService,
|
||||
private _openStreetMap: OpenStreetMap,
|
||||
auth: AuthService,
|
||||
) {
|
||||
super({
|
||||
query: '',
|
||||
fetching: false,
|
||||
filteredBranches: [],
|
||||
branches: [],
|
||||
online: true,
|
||||
orderingEnabled: true,
|
||||
shippingEnabled: true,
|
||||
filterCurrentBranch: undefined,
|
||||
currentBranchNumber: auth.getClaimByKey('branch_no'),
|
||||
orderBy: 'name',
|
||||
branchType: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
loadBranches = this.effect(($) =>
|
||||
$.pipe(
|
||||
tap((_) => this.setFetching(true)),
|
||||
switchMap(() =>
|
||||
this._availabilityService.getBranches().pipe(
|
||||
withLatestFrom(this.selectedBranch$),
|
||||
tapResponse(
|
||||
([response, selectedBranch]) => this.loadBranchesResponseFn({ response, selectedBranch }),
|
||||
(error: Error) => this.loadBranchesErrorFn(error),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
perimeterSearch = this.effect(($) =>
|
||||
$.pipe(
|
||||
tap((_) => this.beforePerimeterSearch()),
|
||||
debounceTime(500),
|
||||
switchMap(() => {
|
||||
const queryToken = {
|
||||
country: 'Germany',
|
||||
postalcode: this.query,
|
||||
limit: 1,
|
||||
} as OpenStreetMapParams.Query;
|
||||
return this._openStreetMap.query(queryToken).pipe(
|
||||
withLatestFrom(this.branches$),
|
||||
tapResponse(
|
||||
([response, branches]) => this.perimeterSearchResponseFn({ response, branches }),
|
||||
(error: Error) => this.perimeterSearchErrorFn(error),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
beforePerimeterSearch = () => {
|
||||
this.setFilteredBranches([]);
|
||||
this.setFetching(true);
|
||||
};
|
||||
|
||||
perimeterSearchResponseFn = ({ response, branches }: { response: PlaceDto[]; branches: BranchDTO[] }) => {
|
||||
const place = response?.find((_) => true);
|
||||
const branch = this._findNearestBranchByPlace({ place, branches });
|
||||
const filteredBranches = [...branches]
|
||||
?.sort((a: BranchDTO, b: BranchDTO) => branchSorterFn(a, b, branch))
|
||||
?.slice(0, 10);
|
||||
this.setFilteredBranches(filteredBranches ?? []);
|
||||
};
|
||||
|
||||
perimeterSearchErrorFn = (error: Error) => {
|
||||
this.setFilteredBranches([]);
|
||||
console.error('OpenStreetMap Request Failed! ', error);
|
||||
};
|
||||
|
||||
loadBranchesResponseFn = ({ response, selectedBranch }: { response: BranchDTO[]; selectedBranch?: BranchDTO }) => {
|
||||
this.setBranches(response ?? []);
|
||||
if (selectedBranch) {
|
||||
this.setSelectedBranch(selectedBranch);
|
||||
}
|
||||
this.setFetching(false);
|
||||
};
|
||||
|
||||
loadBranchesErrorFn = (error: Error) => {
|
||||
this.setBranches([]);
|
||||
this._uiModal.open({
|
||||
title: 'Fehler beim Laden der Filialen',
|
||||
content: UiErrorModalComponent,
|
||||
data: error,
|
||||
config: { showScrollbarY: false },
|
||||
});
|
||||
};
|
||||
|
||||
setBranches(branches: BranchDTO[]) {
|
||||
this.patchState({ branches });
|
||||
}
|
||||
|
||||
setFilteredBranches(filteredBranches: BranchDTO[]) {
|
||||
this.patchState({ filteredBranches });
|
||||
}
|
||||
|
||||
setSelectedBranch(selectedBranch?: BranchDTO) {
|
||||
if (selectedBranch) {
|
||||
this.patchState({
|
||||
selectedBranch,
|
||||
query: this.formatBranch(selectedBranch),
|
||||
});
|
||||
} else {
|
||||
this.patchState({
|
||||
selectedBranch,
|
||||
query: '',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setQuery(query: string) {
|
||||
this.patchState({ query });
|
||||
}
|
||||
|
||||
setFetching(fetching: boolean) {
|
||||
this.patchState({ fetching });
|
||||
}
|
||||
|
||||
formatBranch(branch?: BranchDTO) {
|
||||
return branch ? (branch.key ? branch.key + ' - ' + branch.name : branch.name) : '';
|
||||
}
|
||||
|
||||
private _findNearestBranchByPlace({ place, branches }: { place: PlaceDto; branches: BranchDTO[] }): BranchDTO {
|
||||
const placeGeoLocation = { longitude: Number(place?.lon), latitude: Number(place?.lat) } as GeoLocation;
|
||||
return (
|
||||
branches?.reduce((a, b) =>
|
||||
geoDistance(placeGeoLocation, a.address.geoLocation) > geoDistance(placeGeoLocation, b.address.geoLocation)
|
||||
? b
|
||||
: a,
|
||||
) ?? {}
|
||||
);
|
||||
}
|
||||
|
||||
getBranchById(id: number): BranchDTO {
|
||||
return this.branches.find((branch) => branch.id === id);
|
||||
}
|
||||
|
||||
setOnline(online: boolean) {
|
||||
this.patchState({ online });
|
||||
}
|
||||
|
||||
setOrderingEnabled(orderingEnabled: boolean) {
|
||||
this.patchState({ orderingEnabled });
|
||||
}
|
||||
|
||||
setShippingEnabled(shippingEnabled: boolean) {
|
||||
this.patchState({ shippingEnabled });
|
||||
}
|
||||
|
||||
setFilterCurrentBranch(filterCurrentBranch: boolean) {
|
||||
this.patchState({ filterCurrentBranch });
|
||||
}
|
||||
|
||||
setOrderBy(orderBy: 'name' | 'distance') {
|
||||
this.patchState({ orderBy });
|
||||
}
|
||||
|
||||
setBranchType(branchType: BranchType) {
|
||||
this.patchState({ branchType });
|
||||
}
|
||||
}
|
||||
import { Injectable } from '@angular/core';
|
||||
import { AuthService } from '@core/auth';
|
||||
import { DomainAvailabilityService } from '@domain/availability';
|
||||
import {
|
||||
OpenStreetMap,
|
||||
OpenStreetMapParams,
|
||||
PlaceDto,
|
||||
} from '@external/openstreetmap';
|
||||
import { ComponentStore } from '@ngrx/component-store';
|
||||
import { tapResponse } from '@ngrx/operators';
|
||||
|
||||
import { BranchDTO, BranchType } from '@generated/swagger/checkout-api';
|
||||
import { UiErrorModalComponent, UiModalService } from '@ui/modal';
|
||||
import { geoDistance, GeoLocation } from '@utils/common';
|
||||
import { debounceTime, switchMap, tap, withLatestFrom } from 'rxjs/operators';
|
||||
|
||||
export interface BranchSelectorState {
|
||||
query: string;
|
||||
fetching: boolean;
|
||||
branches: BranchDTO[];
|
||||
filteredBranches: BranchDTO[];
|
||||
selectedBranch?: BranchDTO;
|
||||
online?: boolean;
|
||||
orderingEnabled?: boolean;
|
||||
shippingEnabled?: boolean;
|
||||
filterCurrentBranch?: boolean;
|
||||
currentBranchNumber?: string;
|
||||
orderBy?: 'name' | 'distance';
|
||||
branchType?: number;
|
||||
}
|
||||
|
||||
function branchSorterFn(a: BranchDTO, b: BranchDTO, userBranch: BranchDTO) {
|
||||
return (
|
||||
geoDistance(userBranch?.address?.geoLocation, a?.address?.geoLocation) -
|
||||
geoDistance(userBranch?.address?.geoLocation, b?.address?.geoLocation)
|
||||
);
|
||||
}
|
||||
|
||||
function selectBranches(state: BranchSelectorState) {
|
||||
if (!state?.branches) {
|
||||
return [];
|
||||
}
|
||||
|
||||
let branches = state.branches;
|
||||
|
||||
if (typeof state.online === 'boolean') {
|
||||
branches = branches.filter((branch) => !!branch?.isOnline === state.online);
|
||||
}
|
||||
|
||||
if (typeof state.orderingEnabled === 'boolean') {
|
||||
branches = branches.filter(
|
||||
(branch) => !!branch?.isOrderingEnabled === state.orderingEnabled,
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof state.shippingEnabled === 'boolean') {
|
||||
branches = branches.filter(
|
||||
(branch) => !!branch?.isShippingEnabled === state.shippingEnabled,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
typeof state.filterCurrentBranch === 'boolean' &&
|
||||
typeof state.currentBranchNumber === 'string'
|
||||
) {
|
||||
branches = branches.filter(
|
||||
(branch) => branch?.branchNumber !== state.currentBranchNumber,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
typeof state.orderBy === 'string' &&
|
||||
typeof state.currentBranchNumber === 'string'
|
||||
) {
|
||||
switch (state.orderBy) {
|
||||
case 'name':
|
||||
branches?.sort((branchA, branchB) =>
|
||||
branchA?.name?.localeCompare(branchB?.name),
|
||||
);
|
||||
break;
|
||||
case 'distance': {
|
||||
const currentBranch = state.branches?.find(
|
||||
(b) => b?.branchNumber === state.currentBranchNumber,
|
||||
);
|
||||
branches?.sort((a: BranchDTO, b: BranchDTO) =>
|
||||
branchSorterFn(a, b, currentBranch),
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof state.branchType === 'number') {
|
||||
branches = branches.filter(
|
||||
(branch) => branch?.branchType === state.branchType,
|
||||
);
|
||||
}
|
||||
|
||||
return branches;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class BranchSelectorStore extends ComponentStore<BranchSelectorState> {
|
||||
get query() {
|
||||
return this.get((s) => s.query);
|
||||
}
|
||||
|
||||
readonly query$ = this.select((s) => s.query);
|
||||
|
||||
get fetching() {
|
||||
return this.get((s) => s.fetching);
|
||||
}
|
||||
|
||||
readonly fetching$ = this.select((s) => s.fetching);
|
||||
|
||||
get branches() {
|
||||
return this.get(selectBranches);
|
||||
}
|
||||
|
||||
readonly branches$ = this.select(selectBranches);
|
||||
|
||||
get filteredBranches() {
|
||||
return this.get((s) => s.filteredBranches);
|
||||
}
|
||||
|
||||
readonly filteredBranches$ = this.select((s) => s.filteredBranches);
|
||||
|
||||
get selectedBranch() {
|
||||
return this.get((s) => s.selectedBranch);
|
||||
}
|
||||
|
||||
readonly selectedBranch$ = this.select((s) => s.selectedBranch);
|
||||
|
||||
constructor(
|
||||
private _availabilityService: DomainAvailabilityService,
|
||||
private _uiModal: UiModalService,
|
||||
private _openStreetMap: OpenStreetMap,
|
||||
auth: AuthService,
|
||||
) {
|
||||
super({
|
||||
query: '',
|
||||
fetching: false,
|
||||
filteredBranches: [],
|
||||
branches: [],
|
||||
online: true,
|
||||
orderingEnabled: true,
|
||||
shippingEnabled: true,
|
||||
filterCurrentBranch: undefined,
|
||||
currentBranchNumber: auth.getClaimByKey('branch_no'),
|
||||
orderBy: 'name',
|
||||
branchType: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
loadBranches = this.effect(($) =>
|
||||
$.pipe(
|
||||
tap(() => this.setFetching(true)),
|
||||
switchMap(() =>
|
||||
this._availabilityService.getBranches().pipe(
|
||||
withLatestFrom(this.selectedBranch$),
|
||||
tapResponse(
|
||||
([response, selectedBranch]) =>
|
||||
this.loadBranchesResponseFn({ response, selectedBranch }),
|
||||
(error: Error) => this.loadBranchesErrorFn(error),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
perimeterSearch = this.effect(($) =>
|
||||
$.pipe(
|
||||
tap(() => this.beforePerimeterSearch()),
|
||||
debounceTime(500),
|
||||
switchMap(() => {
|
||||
const queryToken = {
|
||||
country: 'Germany',
|
||||
zipCode: this.query,
|
||||
limit: 1,
|
||||
} as OpenStreetMapParams.Query;
|
||||
return this._openStreetMap.query(queryToken).pipe(
|
||||
withLatestFrom(this.branches$),
|
||||
tapResponse(
|
||||
([response, branches]) =>
|
||||
this.perimeterSearchResponseFn({ response, branches }),
|
||||
(error: Error) => this.perimeterSearchErrorFn(error),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
beforePerimeterSearch = () => {
|
||||
this.setFilteredBranches([]);
|
||||
this.setFetching(true);
|
||||
};
|
||||
|
||||
perimeterSearchResponseFn = ({
|
||||
response,
|
||||
branches,
|
||||
}: {
|
||||
response: PlaceDto[];
|
||||
branches: BranchDTO[];
|
||||
}) => {
|
||||
const place = response?.[0];
|
||||
const branch = this._findNearestBranchByPlace({ place, branches });
|
||||
const filteredBranches = [...branches]
|
||||
?.sort((a: BranchDTO, b: BranchDTO) => branchSorterFn(a, b, branch))
|
||||
?.slice(0, 10);
|
||||
this.setFilteredBranches(filteredBranches ?? []);
|
||||
};
|
||||
|
||||
perimeterSearchErrorFn = (error: Error) => {
|
||||
this.setFilteredBranches([]);
|
||||
console.error('OpenStreetMap Request Failed! ', error);
|
||||
};
|
||||
|
||||
loadBranchesResponseFn = ({
|
||||
response,
|
||||
selectedBranch,
|
||||
}: {
|
||||
response: BranchDTO[];
|
||||
selectedBranch?: BranchDTO;
|
||||
}) => {
|
||||
this.setBranches(response ?? []);
|
||||
if (selectedBranch) {
|
||||
this.setSelectedBranch(selectedBranch);
|
||||
}
|
||||
this.setFetching(false);
|
||||
};
|
||||
|
||||
loadBranchesErrorFn = (error: Error) => {
|
||||
this.setBranches([]);
|
||||
this._uiModal.open({
|
||||
title: 'Fehler beim Laden der Filialen',
|
||||
content: UiErrorModalComponent,
|
||||
data: error,
|
||||
config: { showScrollbarY: false },
|
||||
});
|
||||
};
|
||||
|
||||
setBranches(branches: BranchDTO[]) {
|
||||
this.patchState({ branches });
|
||||
}
|
||||
|
||||
setFilteredBranches(filteredBranches: BranchDTO[]) {
|
||||
this.patchState({ filteredBranches });
|
||||
}
|
||||
|
||||
setSelectedBranch(selectedBranch?: BranchDTO) {
|
||||
if (selectedBranch) {
|
||||
this.patchState({
|
||||
selectedBranch,
|
||||
query: this.formatBranch(selectedBranch),
|
||||
});
|
||||
} else {
|
||||
this.patchState({
|
||||
selectedBranch,
|
||||
query: '',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setQuery(query: string) {
|
||||
this.patchState({ query });
|
||||
}
|
||||
|
||||
setFetching(fetching: boolean) {
|
||||
this.patchState({ fetching });
|
||||
}
|
||||
|
||||
formatBranch(branch?: BranchDTO) {
|
||||
return branch
|
||||
? branch.key
|
||||
? branch.key + ' - ' + branch.name
|
||||
: branch.name
|
||||
: '';
|
||||
}
|
||||
|
||||
private _findNearestBranchByPlace({
|
||||
place,
|
||||
branches,
|
||||
}: {
|
||||
place: PlaceDto;
|
||||
branches: BranchDTO[];
|
||||
}): BranchDTO {
|
||||
const placeGeoLocation = {
|
||||
longitude: Number(place?.lon),
|
||||
latitude: Number(place?.lat),
|
||||
} as GeoLocation;
|
||||
return (
|
||||
branches?.reduce((a, b) =>
|
||||
geoDistance(placeGeoLocation, a.address.geoLocation) >
|
||||
geoDistance(placeGeoLocation, b.address.geoLocation)
|
||||
? b
|
||||
: a,
|
||||
) ?? {}
|
||||
);
|
||||
}
|
||||
|
||||
getBranchById(id: number): BranchDTO {
|
||||
return this.branches.find((branch) => branch.id === id);
|
||||
}
|
||||
|
||||
setOnline(online: boolean) {
|
||||
this.patchState({ online });
|
||||
}
|
||||
|
||||
setOrderingEnabled(orderingEnabled: boolean) {
|
||||
this.patchState({ orderingEnabled });
|
||||
}
|
||||
|
||||
setShippingEnabled(shippingEnabled: boolean) {
|
||||
this.patchState({ shippingEnabled });
|
||||
}
|
||||
|
||||
setFilterCurrentBranch(filterCurrentBranch: boolean) {
|
||||
this.patchState({ filterCurrentBranch });
|
||||
}
|
||||
|
||||
setOrderBy(orderBy: 'name' | 'distance') {
|
||||
this.patchState({ orderBy });
|
||||
}
|
||||
|
||||
setBranchType(branchType: BranchType) {
|
||||
this.patchState({ branchType });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,73 +1,84 @@
|
||||
<div class="searchbox-input-wrapper">
|
||||
<div class="searchbox-hint-wrapper">
|
||||
<input
|
||||
id="searchbox"
|
||||
class="searchbox-input"
|
||||
autocomplete="off"
|
||||
#input
|
||||
type="text"
|
||||
[placeholder]="placeholder"
|
||||
[(ngModel)]="query"
|
||||
(ngModelChange)="setQuery($event, true, true)"
|
||||
(focus)="clearHint(); focused.emit(true)"
|
||||
(blur)="focused.emit(false)"
|
||||
(keyup)="onKeyup($event)"
|
||||
(keyup.enter)="
|
||||
tracker.trackEvent({ action: 'keyup enter', name: 'search' })
|
||||
"
|
||||
matomoTracker
|
||||
#tracker="matomo"
|
||||
matomoCategory="searchbox"
|
||||
/>
|
||||
@if (showHint) {
|
||||
<div class="searchbox-hint" (click)="focus()">
|
||||
{{ hint }}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@if (input.value) {
|
||||
<button
|
||||
(click)="clear(); focus()"
|
||||
tabindex="-1"
|
||||
class="searchbox-clear-btn"
|
||||
type="button"
|
||||
>
|
||||
<shared-icon icon="close" [size]="32"></shared-icon>
|
||||
</button>
|
||||
}
|
||||
@if (!loading) {
|
||||
@if (!showScannerButton) {
|
||||
<button
|
||||
tabindex="0"
|
||||
class="searchbox-search-btn"
|
||||
type="button"
|
||||
(click)="emitSearch()"
|
||||
[disabled]="completeValue !== query"
|
||||
matomoClickAction="click"
|
||||
matomoClickCategory="searchbox"
|
||||
matomoClickName="search"
|
||||
>
|
||||
<ui-icon icon="search" size="1.5rem"></ui-icon>
|
||||
</button>
|
||||
}
|
||||
@if (showScannerButton) {
|
||||
<button
|
||||
tabindex="0"
|
||||
class="searchbox-scan-btn"
|
||||
type="button"
|
||||
(click)="startScan()"
|
||||
matomoClickAction="open"
|
||||
matomoClickCategory="searchbox"
|
||||
matomoClickName="scanner"
|
||||
>
|
||||
<shared-icon icon="barcode-scan" [size]="32"></shared-icon>
|
||||
</button>
|
||||
}
|
||||
}
|
||||
@if (loading) {
|
||||
<div class="searchbox-load-indicator">
|
||||
<ui-icon icon="spinner" size="32px"></ui-icon>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<ng-content select="ui-autocomplete"></ng-content>
|
||||
<div class="searchbox-input-wrapper" role="search">
|
||||
<div class="searchbox-hint-wrapper">
|
||||
<input
|
||||
id="searchbox"
|
||||
class="searchbox-input"
|
||||
autocomplete="off"
|
||||
#input
|
||||
type="text"
|
||||
[placeholder]="placeholder"
|
||||
[(ngModel)]="query"
|
||||
(ngModelChange)="setQuery($event, true, true)"
|
||||
(focus)="clearHint(); focused.emit(true)"
|
||||
(blur)="focused.emit(false)"
|
||||
(keyup)="onKeyup($event)"
|
||||
(keyup.enter)="
|
||||
tracker.trackEvent({ action: 'keyup enter', name: 'search' })
|
||||
"
|
||||
matomoTracker
|
||||
#tracker="matomo"
|
||||
matomoCategory="searchbox"
|
||||
aria-label="Search input"
|
||||
aria-autocomplete="list"
|
||||
[attr.aria-expanded]="autocomplete?.opend || null"
|
||||
[attr.aria-controls]="autocomplete?.opend ? 'searchbox-autocomplete' : null"
|
||||
[attr.aria-activedescendant]="autocomplete?.activeItem ? 'searchbox-item-' + autocomplete?.listKeyManager?.activeItemIndex : null"
|
||||
[attr.aria-busy]="loading || null"
|
||||
[attr.aria-describedby]="showHint ? 'searchbox-hint' : null"
|
||||
/>
|
||||
@if (showHint) {
|
||||
<div id="searchbox-hint" class="searchbox-hint" (click)="focus()" aria-hidden="true">
|
||||
{{ hint }}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@if (input.value) {
|
||||
<button
|
||||
(click)="clear(); focus()"
|
||||
tabindex="-1"
|
||||
class="searchbox-clear-btn"
|
||||
type="button"
|
||||
aria-label="Clear"
|
||||
>
|
||||
<shared-icon icon="close" [size]="32" aria-hidden="true"></shared-icon>
|
||||
</button>
|
||||
}
|
||||
@if (!loading) {
|
||||
@if (!showScannerButton) {
|
||||
<button
|
||||
tabindex="0"
|
||||
class="searchbox-search-btn"
|
||||
type="button"
|
||||
(click)="emitSearch()"
|
||||
[disabled]="completeValue !== query"
|
||||
[attr.aria-disabled]="completeValue !== query || null"
|
||||
matomoClickAction="click"
|
||||
matomoClickCategory="searchbox"
|
||||
matomoClickName="search"
|
||||
aria-label="Search"
|
||||
>
|
||||
<ui-icon icon="search" size="1.5rem" aria-hidden="true"></ui-icon>
|
||||
</button>
|
||||
}
|
||||
@if (showScannerButton) {
|
||||
<button
|
||||
tabindex="0"
|
||||
class="searchbox-scan-btn"
|
||||
type="button"
|
||||
(click)="startScan()"
|
||||
matomoClickAction="open"
|
||||
matomoClickCategory="searchbox"
|
||||
matomoClickName="scanner"
|
||||
aria-label="Scan barcode"
|
||||
>
|
||||
<shared-icon icon="barcode-scan" [size]="32" aria-hidden="true"></shared-icon>
|
||||
</button>
|
||||
}
|
||||
}
|
||||
@if (loading) {
|
||||
<div class="searchbox-load-indicator" role="status" aria-live="polite" aria-label="Loading search results">
|
||||
<ui-icon icon="spinner" size="32px" aria-hidden="true"></ui-icon>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<ng-content select="ui-autocomplete"></ng-content>
|
||||
|
||||
@@ -21,9 +21,7 @@
|
||||
(click)="$event?.preventDefault(); $event?.stopPropagation()"
|
||||
>
|
||||
<shared-icon icon="shopping-cart-bold" [size]="22"></shared-icon>
|
||||
<span class="shopping-cart-count-label ml-2">{{
|
||||
cartItemCount$ | async
|
||||
}}</span>
|
||||
<span class="shopping-cart-count-label ml-2">{{ cartCount() }}</span>
|
||||
</button>
|
||||
}
|
||||
</a>
|
||||
|
||||
@@ -25,8 +25,9 @@ import {
|
||||
combineLatest,
|
||||
isObservable,
|
||||
} from 'rxjs';
|
||||
import { map, switchMap, tap } from 'rxjs/operators';
|
||||
import { map, tap } from 'rxjs/operators';
|
||||
import { TabService } from '@isa/core/tabs';
|
||||
import { CheckoutMetadataService } from '@isa/checkout/data-access';
|
||||
|
||||
@Component({
|
||||
selector: 'shell-process-bar-item',
|
||||
@@ -39,9 +40,14 @@ export class ShellProcessBarItemComponent
|
||||
implements OnInit, OnDestroy, OnChanges
|
||||
{
|
||||
#tabService = inject(TabService);
|
||||
#checkoutMetadataService = inject(CheckoutMetadataService);
|
||||
|
||||
tab = computed(() => this.#tabService.entityMap()[this.process().id]);
|
||||
|
||||
shoppingCartId = computed(() => {
|
||||
return this.#checkoutMetadataService.getShoppingCartId(this.process().id);
|
||||
});
|
||||
|
||||
private _process$ = new BehaviorSubject<ApplicationProcess>(undefined);
|
||||
|
||||
process$ = this._process$.asObservable();
|
||||
@@ -63,6 +69,14 @@ export class ShellProcessBarItemComponent
|
||||
return 'count' in pdata;
|
||||
});
|
||||
|
||||
cartCount = computed(() => {
|
||||
const tab = this.tab();
|
||||
|
||||
const pdata = tab.metadata?.process_data as { count?: number };
|
||||
|
||||
return pdata?.count ?? 0;
|
||||
});
|
||||
|
||||
currentLocationUrlTree = computed(() => {
|
||||
const tab = this.tab();
|
||||
const current = tab.location.locations[tab.location.current];
|
||||
@@ -83,7 +97,7 @@ export class ShellProcessBarItemComponent
|
||||
|
||||
latestBreadcrumb$: Observable<Breadcrumb> = NEVER;
|
||||
|
||||
routerLink$: Observable<string[] | any[]> = NEVER;
|
||||
routerLink$: Observable<string[] | unknown[]> = NEVER;
|
||||
|
||||
queryParams$: Observable<object> = NEVER;
|
||||
|
||||
@@ -112,7 +126,6 @@ export class ShellProcessBarItemComponent
|
||||
this.initQueryParams$();
|
||||
this.initIsActive$();
|
||||
this.initShowCloseButton$();
|
||||
this.initCartItemCount$();
|
||||
}
|
||||
|
||||
scrollIntoView() {
|
||||
@@ -171,15 +184,6 @@ export class ShellProcessBarItemComponent
|
||||
}
|
||||
}
|
||||
|
||||
initCartItemCount$() {
|
||||
this.cartItemCount$ = this.process$.pipe(
|
||||
switchMap((process) =>
|
||||
this._checkout?.getShoppingCart({ processId: process?.id }),
|
||||
),
|
||||
map((cart) => cart?.items?.length ?? 0),
|
||||
);
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this._process$.complete();
|
||||
}
|
||||
|
||||
@@ -156,26 +156,7 @@ export class ShellProcessBarComponent implements OnInit {
|
||||
|
||||
processes = await this.processes$.pipe(delay(1), first()).toPromise();
|
||||
|
||||
if (processes.length === 0) {
|
||||
this._router.navigate(['/kunde', 'dashboard']);
|
||||
} else {
|
||||
const lastest = processes.reduce(
|
||||
(prev, current) =>
|
||||
prev.activated > current.activated ? prev : current,
|
||||
processes[0],
|
||||
);
|
||||
const crumb = await this._breadcrumb
|
||||
.getLastActivatedBreadcrumbByKey$(lastest.id)
|
||||
.pipe(first())
|
||||
.toPromise();
|
||||
if (crumb) {
|
||||
this._router.navigate(coerceArray(crumb.path), {
|
||||
queryParams: crumb.params,
|
||||
});
|
||||
} else {
|
||||
this._router.navigate(['/kunde', lastest.id, 'product']);
|
||||
}
|
||||
}
|
||||
this._router.navigate(['/kunde', 'dashboard']);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -111,7 +111,7 @@
|
||||
*ifRole="'Store'"
|
||||
class="side-menu-group-item"
|
||||
(click)="closeSideMenu(); focusSearchBox()"
|
||||
[routerLink]="['/', tabService.activatedTab()?.id || nextId(), 'return']"
|
||||
[routerLink]="['/', tabId(), 'return']"
|
||||
(isActiveChange)="focusSearchBox()"
|
||||
>
|
||||
<span class="side-menu-group-item-icon w-[2.375rem] h-12">
|
||||
@@ -268,42 +268,13 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (remissionNavigation$ | async; as remissionNavigation) {
|
||||
<a
|
||||
class="side-menu-group-item"
|
||||
(click)="closeSideMenu()"
|
||||
[routerLink]="remissionNavigation.path"
|
||||
[queryParams]="remissionNavigation.queryParams"
|
||||
routerLinkActive="active"
|
||||
>
|
||||
<span class="side-menu-group-item-icon">
|
||||
<shared-icon icon="assignment-return"></shared-icon>
|
||||
</span>
|
||||
<span class="side-menu-group-item-label">Remission</span>
|
||||
</a>
|
||||
}
|
||||
|
||||
@if (packageInspectionNavigation$ | async; as packageInspectionNavigation) {
|
||||
<a
|
||||
class="side-menu-group-item"
|
||||
(click)="closeSideMenu(); fetchAndOpenPackages()"
|
||||
[routerLink]="packageInspectionNavigation.path"
|
||||
[queryParams]="packageInspectionNavigation.queryParams"
|
||||
routerLinkActive="active"
|
||||
>
|
||||
<span class="side-menu-group-item-icon">
|
||||
<shared-icon icon="clipboard-check-outline"></shared-icon>
|
||||
</span>
|
||||
<span class="side-menu-group-item-label">Wareneingang</span>
|
||||
</a>
|
||||
}
|
||||
<div class="side-menu-group-sub-item-wrapper">
|
||||
<a
|
||||
class="side-menu-group-item"
|
||||
(click)="closeSideMenu(); focusSearchBox()"
|
||||
[routerLink]="[
|
||||
'/',
|
||||
tabService.activatedTab()?.id || nextId(),
|
||||
tabId(),
|
||||
'remission',
|
||||
]"
|
||||
(isActiveChange)="focusSearchBox(); remissionExpanded.set($event)"
|
||||
@@ -353,5 +324,20 @@
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (packageInspectionNavigation$ | async; as packageInspectionNavigation) {
|
||||
<a
|
||||
class="side-menu-group-item"
|
||||
(click)="closeSideMenu(); fetchAndOpenPackages()"
|
||||
[routerLink]="packageInspectionNavigation.path"
|
||||
[queryParams]="packageInspectionNavigation.queryParams"
|
||||
routerLinkActive="active"
|
||||
>
|
||||
<span class="side-menu-group-item-icon">
|
||||
<shared-icon icon="clipboard-check-outline"></shared-icon>
|
||||
</span>
|
||||
<span class="side-menu-group-item-label">Wareneingang</span>
|
||||
</a>
|
||||
}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
@@ -221,14 +221,6 @@ export class ShellSideMenuComponent {
|
||||
// this._pickUpShelfInNavigation.listRoute()
|
||||
// );
|
||||
|
||||
remissionNavigation$ = this.getLastNavigationByProcessId(
|
||||
this.#config.get('process.ids.remission'),
|
||||
{
|
||||
path: ['/filiale', 'remission'],
|
||||
queryParams: {},
|
||||
},
|
||||
);
|
||||
|
||||
packageInspectionNavigation$ = this.getLastNavigationByProcessId(
|
||||
this.#config.get('process.ids.packageInspection'),
|
||||
{
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import {
|
||||
type Meta,
|
||||
type StoryObj,
|
||||
applicationConfig,
|
||||
argsToTemplate,
|
||||
moduleMetadata,
|
||||
} from '@storybook/angular';
|
||||
|
||||
import { DestinationInfoComponent } from '@isa/checkout/shared/product-info';
|
||||
import { ShippingTarget } from '@isa/checkout/data-access';
|
||||
|
||||
const meta: Meta<DestinationInfoComponent> = {
|
||||
title: 'checkout/shared/product-info/DestinationInfoComponent',
|
||||
component: DestinationInfoComponent,
|
||||
decorators: [
|
||||
applicationConfig({
|
||||
providers: [],
|
||||
}),
|
||||
moduleMetadata({
|
||||
imports: [],
|
||||
providers: [],
|
||||
}),
|
||||
],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<DestinationInfoComponent>;
|
||||
|
||||
export const Delivery: Story = {
|
||||
args: {
|
||||
shoppingCartItem: {
|
||||
availability: {
|
||||
estimatedDelivery: {
|
||||
start: '2024-06-10T00:00:00+02:00',
|
||||
stop: '2024-06-12T00:00:00+02:00',
|
||||
},
|
||||
},
|
||||
destination: {
|
||||
data: {
|
||||
target: ShippingTarget.Delivery,
|
||||
},
|
||||
},
|
||||
features: {
|
||||
orderType: 'Versand',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Pickup: Story = {
|
||||
args: {
|
||||
shoppingCartItem: {
|
||||
availability: {
|
||||
estimatedDelivery: {
|
||||
start: '2024-06-10T00:00:00+02:00',
|
||||
stop: '2024-06-12T00:00:00+02:00',
|
||||
},
|
||||
},
|
||||
destination: {
|
||||
data: {
|
||||
target: ShippingTarget.Branch,
|
||||
targetBranch: {
|
||||
data: {
|
||||
name: 'Musterfiliale',
|
||||
address: {
|
||||
street: 'Musterstraße',
|
||||
streetNumber: '1',
|
||||
zipCode: '12345',
|
||||
city: 'Musterstadt',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
features: {
|
||||
orderType: 'Abholung',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const InStore: Story = {
|
||||
args: {
|
||||
shoppingCartItem: {
|
||||
destination: {
|
||||
data: {
|
||||
target: ShippingTarget.Branch,
|
||||
targetBranch: {
|
||||
data: {
|
||||
name: 'Musterfiliale',
|
||||
address: {
|
||||
street: 'Musterstraße',
|
||||
streetNumber: '1',
|
||||
zipCode: '12345',
|
||||
city: 'Musterstadt',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
features: {
|
||||
orderType: 'Rücklage',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,60 @@
|
||||
import {
|
||||
type Meta,
|
||||
type StoryObj,
|
||||
applicationConfig,
|
||||
argsToTemplate,
|
||||
moduleMetadata,
|
||||
} from '@storybook/angular';
|
||||
import { ProductInfoRedemptionComponent } from '@isa/checkout/shared/product-info';
|
||||
import { provideProductImageUrl } from '@isa/shared/product-image';
|
||||
import { provideProductRouterLinkBuilder } from '@isa/shared/product-router-link';
|
||||
import { provideRouter } from '@angular/router';
|
||||
|
||||
const meta: Meta<ProductInfoRedemptionComponent> = {
|
||||
title: 'checkout/shared/product-info/ProductInfoRedemption',
|
||||
component: ProductInfoRedemptionComponent,
|
||||
decorators: [
|
||||
applicationConfig({
|
||||
providers: [
|
||||
provideRouter([
|
||||
{ path: ':ean', component: ProductInfoRedemptionComponent },
|
||||
]),
|
||||
],
|
||||
}),
|
||||
moduleMetadata({
|
||||
providers: [
|
||||
provideProductImageUrl('https://produktbilder-test.paragon-data.net'),
|
||||
provideProductRouterLinkBuilder((ean: string) => ean),
|
||||
],
|
||||
}),
|
||||
],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<ProductInfoRedemptionComponent>;
|
||||
|
||||
export const Primary: Story = {
|
||||
args: {
|
||||
item: {
|
||||
product: {
|
||||
ean: '9783498007706',
|
||||
name: 'Die Assistentin',
|
||||
contributors: 'Wahl, Caroline',
|
||||
format: 'TB',
|
||||
formatDetail: 'Taschenbuch (Kartoniert)',
|
||||
manufacturer: 'Test Manufacturer',
|
||||
publicationDate: '2023-01-01',
|
||||
},
|
||||
redemptionPoints: 100,
|
||||
},
|
||||
orientation: 'vertical',
|
||||
},
|
||||
argTypes: {
|
||||
item: { control: 'object' },
|
||||
orientation: {
|
||||
control: { type: 'radio' },
|
||||
options: ['horizontal', 'vertical'],
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,130 @@
|
||||
import {
|
||||
type Meta,
|
||||
type StoryObj,
|
||||
applicationConfig,
|
||||
moduleMetadata,
|
||||
} from '@storybook/angular';
|
||||
import { ProductInfoComponent } from '@isa/checkout/shared/product-info';
|
||||
import { provideProductImageUrl } from '@isa/shared/product-image';
|
||||
import { provideProductRouterLinkBuilder } from '@isa/shared/product-router-link';
|
||||
import { provideRouter } from '@angular/router';
|
||||
|
||||
const mockProduct = {
|
||||
ean: '9783498007706',
|
||||
name: 'Die Assistentin',
|
||||
contributors: 'Wahl, Caroline',
|
||||
};
|
||||
|
||||
const meta: Meta<ProductInfoComponent> = {
|
||||
title: 'checkout/shared/product-info/ProductInfo',
|
||||
component: ProductInfoComponent,
|
||||
decorators: [
|
||||
applicationConfig({
|
||||
providers: [
|
||||
provideRouter([{ path: ':ean', component: ProductInfoComponent }]),
|
||||
],
|
||||
}),
|
||||
moduleMetadata({
|
||||
providers: [
|
||||
provideProductImageUrl('https://produktbilder-test.paragon-data.net'),
|
||||
provideProductRouterLinkBuilder((ean: string) => ean),
|
||||
],
|
||||
}),
|
||||
],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<ProductInfoComponent>;
|
||||
|
||||
export const BasicWithoutContent: Story = {
|
||||
args: {
|
||||
item: mockProduct,
|
||||
nameSize: 'medium',
|
||||
},
|
||||
argTypes: {
|
||||
item: { control: 'object' },
|
||||
nameSize: {
|
||||
control: { type: 'radio' },
|
||||
options: ['small', 'medium', 'large'],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const SmallNameSize: Story = {
|
||||
args: {
|
||||
item: mockProduct,
|
||||
nameSize: 'small',
|
||||
},
|
||||
};
|
||||
|
||||
export const MediumNameSize: Story = {
|
||||
args: {
|
||||
item: mockProduct,
|
||||
nameSize: 'medium',
|
||||
},
|
||||
};
|
||||
|
||||
export const LargeNameSize: Story = {
|
||||
args: {
|
||||
item: mockProduct,
|
||||
nameSize: 'large',
|
||||
},
|
||||
};
|
||||
|
||||
export const WithLesepunkte: Story = {
|
||||
args: {
|
||||
item: mockProduct,
|
||||
nameSize: 'medium',
|
||||
},
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: `
|
||||
<checkout-product-info [item]="item" [nameSize]="nameSize">
|
||||
<div class="isa-text-body-2-regular">
|
||||
<span class="isa-text-body-2-bold">150</span> Lesepunkte
|
||||
</div>
|
||||
</checkout-product-info>
|
||||
`,
|
||||
}),
|
||||
};
|
||||
|
||||
export const WithManufacturer: Story = {
|
||||
args: {
|
||||
item: mockProduct,
|
||||
nameSize: 'medium',
|
||||
},
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: `
|
||||
<checkout-product-info [item]="item" [nameSize]="nameSize">
|
||||
<div class="isa-text-body-2-regular text-neutral-600">
|
||||
Rowohlt Taschenbuch
|
||||
</div>
|
||||
</checkout-product-info>
|
||||
`,
|
||||
}),
|
||||
};
|
||||
|
||||
export const WithMultipleRows: Story = {
|
||||
args: {
|
||||
item: mockProduct,
|
||||
nameSize: 'medium',
|
||||
},
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: `
|
||||
<checkout-product-info [item]="item" [nameSize]="nameSize">
|
||||
<div class="isa-text-body-2-regular">
|
||||
<span class="isa-text-body-2-bold">150</span> Lesepunkte
|
||||
</div>
|
||||
<div class="isa-text-body-2-regular text-neutral-600">
|
||||
Rowohlt Taschenbuch
|
||||
</div>
|
||||
<div class="isa-text-body-2-regular text-neutral-600">
|
||||
Erschienen: 01. Januar 2023
|
||||
</div>
|
||||
</checkout-product-info>
|
||||
`,
|
||||
}),
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
import {
|
||||
type Meta,
|
||||
type StoryObj,
|
||||
applicationConfig,
|
||||
argsToTemplate,
|
||||
moduleMetadata,
|
||||
} from '@storybook/angular';
|
||||
|
||||
import { StockInfoComponent } from '@isa/checkout/shared/product-info';
|
||||
import { RemissionStockService } from '@isa/remission/data-access';
|
||||
import { StockInfoDTO } from '@generated/swagger/inventory-api';
|
||||
|
||||
const meta: Meta<StockInfoComponent> = {
|
||||
title: 'checkout/shared/product-info/StockInfoComponent',
|
||||
component: StockInfoComponent,
|
||||
decorators: [
|
||||
applicationConfig({
|
||||
providers: [],
|
||||
}),
|
||||
moduleMetadata({
|
||||
imports: [],
|
||||
providers: [
|
||||
{
|
||||
provide: RemissionStockService,
|
||||
useValue: {
|
||||
fetchStock: async (
|
||||
params: { itemIds: number[]; assignedStockId?: number },
|
||||
abortSignal?: AbortSignal,
|
||||
) => {
|
||||
const result: StockInfoDTO = {
|
||||
itemId: params.itemIds[0],
|
||||
stockId: params.assignedStockId,
|
||||
inStock: 14,
|
||||
};
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
return [result];
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<StockInfoComponent>;
|
||||
|
||||
export const Primary: Story = {
|
||||
args: {
|
||||
item: {
|
||||
id: 123456,
|
||||
catalogAvailability: {
|
||||
ssc: '999',
|
||||
sscText: 'Lieferbar in 1-3 Werktagen',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
183
apps/isa-app/stories/shared/address/address.component.stories.ts
Normal file
183
apps/isa-app/stories/shared/address/address.component.stories.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
import {
|
||||
type Meta,
|
||||
type StoryObj,
|
||||
applicationConfig,
|
||||
argsToTemplate,
|
||||
} from '@storybook/angular';
|
||||
|
||||
import { AddressComponent, Address } from '@isa/shared/address';
|
||||
import { CountryResource } from '@isa/crm/data-access';
|
||||
|
||||
const meta: Meta<AddressComponent> = {
|
||||
title: 'shared/address/AddressComponent',
|
||||
component: AddressComponent,
|
||||
decorators: [
|
||||
applicationConfig({
|
||||
providers: [
|
||||
{
|
||||
provide: CountryResource,
|
||||
useValue: {
|
||||
resource: {
|
||||
value: () => [
|
||||
{ isO3166_A_3: 'DEU', name: 'Germany' },
|
||||
{ isO3166_A_3: 'FRA', name: 'France' },
|
||||
{ isO3166_A_3: 'AUT', name: 'Austria' },
|
||||
{ isO3166_A_3: 'USA', name: 'United States' },
|
||||
{ isO3166_A_3: 'CHE', name: 'Switzerland' },
|
||||
{ isO3166_A_3: 'ITA', name: 'Italy' },
|
||||
{ isO3166_A_3: 'ESP', name: 'Spain' },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
argTypes: {
|
||||
address: {
|
||||
control: 'object',
|
||||
description: 'The address object to display',
|
||||
},
|
||||
},
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: `<shared-address ${argsToTemplate(args)}></shared-address>`,
|
||||
}),
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<AddressComponent>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
address: {
|
||||
careOf: 'John Doe',
|
||||
street: 'Hauptstraße',
|
||||
streetNumber: '42',
|
||||
apartment: 'Apt 3B',
|
||||
info: 'Building A, 3rd Floor',
|
||||
zipCode: '10115',
|
||||
city: 'Berlin',
|
||||
country: 'DEU',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const GermanAddress: Story = {
|
||||
args: {
|
||||
address: {
|
||||
street: 'Maximilianstraße',
|
||||
streetNumber: '15',
|
||||
zipCode: '80539',
|
||||
city: 'München',
|
||||
country: 'DEU',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const FrenchAddress: Story = {
|
||||
args: {
|
||||
address: {
|
||||
street: 'Rue de la Paix',
|
||||
streetNumber: '25',
|
||||
zipCode: '75002',
|
||||
city: 'Paris',
|
||||
country: 'FRA',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const AustrianAddress: Story = {
|
||||
args: {
|
||||
address: {
|
||||
street: 'Stephansplatz',
|
||||
streetNumber: '1',
|
||||
zipCode: '1010',
|
||||
city: 'Wien',
|
||||
country: 'AUT',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const SwissAddress: Story = {
|
||||
args: {
|
||||
address: {
|
||||
street: 'Bahnhofstrasse',
|
||||
streetNumber: '50',
|
||||
zipCode: '8001',
|
||||
city: 'Zürich',
|
||||
country: 'CHE',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const WithCareOf: Story = {
|
||||
args: {
|
||||
address: {
|
||||
careOf: 'Maria Schmidt',
|
||||
street: 'Berliner Straße',
|
||||
streetNumber: '100',
|
||||
zipCode: '60311',
|
||||
city: 'Frankfurt am Main',
|
||||
country: 'DEU',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const WithApartment: Story = {
|
||||
args: {
|
||||
address: {
|
||||
street: 'Lindenallee',
|
||||
streetNumber: '23',
|
||||
apartment: 'Wohnung 5A',
|
||||
zipCode: '50668',
|
||||
city: 'Köln',
|
||||
country: 'DEU',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const WithAdditionalInfo: Story = {
|
||||
args: {
|
||||
address: {
|
||||
street: 'Industriestraße',
|
||||
streetNumber: '7',
|
||||
info: 'Hintereingang, 2. Stock rechts',
|
||||
zipCode: '70565',
|
||||
city: 'Stuttgart',
|
||||
country: 'DEU',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const MinimalAddress: Story = {
|
||||
args: {
|
||||
address: {
|
||||
street: 'Dorfstraße',
|
||||
city: 'Neustadt',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const CompleteInternational: Story = {
|
||||
args: {
|
||||
address: {
|
||||
careOf: 'Jane Smith',
|
||||
street: 'Fifth Avenue',
|
||||
streetNumber: '350',
|
||||
apartment: 'Suite 2000',
|
||||
info: 'Empire State Building',
|
||||
zipCode: '10118',
|
||||
city: 'New York',
|
||||
state: 'NY',
|
||||
country: 'USA',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const EmptyAddress: Story = {
|
||||
args: {
|
||||
address: {},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,191 @@
|
||||
import {
|
||||
type Meta,
|
||||
type StoryObj,
|
||||
applicationConfig,
|
||||
argsToTemplate,
|
||||
} from '@storybook/angular';
|
||||
|
||||
import { InlineAddressComponent, Address } from '@isa/shared/address';
|
||||
import { CountryResource } from '@isa/crm/data-access';
|
||||
|
||||
const meta: Meta<InlineAddressComponent> = {
|
||||
title: 'shared/address/InlineAddressComponent',
|
||||
component: InlineAddressComponent,
|
||||
decorators: [
|
||||
applicationConfig({
|
||||
providers: [
|
||||
{
|
||||
provide: CountryResource,
|
||||
useValue: {
|
||||
resource: {
|
||||
value: () => [
|
||||
{ isO3166_A_3: 'DEU', name: 'Germany' },
|
||||
{ isO3166_A_3: 'FRA', name: 'France' },
|
||||
{ isO3166_A_3: 'AUT', name: 'Austria' },
|
||||
{ isO3166_A_3: 'USA', name: 'United States' },
|
||||
{ isO3166_A_3: 'CHE', name: 'Switzerland' },
|
||||
{ isO3166_A_3: 'ITA', name: 'Italy' },
|
||||
{ isO3166_A_3: 'ESP', name: 'Spain' },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
argTypes: {
|
||||
address: {
|
||||
control: 'object',
|
||||
description: 'The address object to display in inline format',
|
||||
},
|
||||
},
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: `<shared-inline-address ${argsToTemplate(args)}></shared-inline-address>`,
|
||||
}),
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<InlineAddressComponent>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
address: {
|
||||
street: 'Hauptstraße',
|
||||
streetNumber: '42',
|
||||
zipCode: '10115',
|
||||
city: 'Berlin',
|
||||
country: 'DEU',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const GermanAddress: Story = {
|
||||
args: {
|
||||
address: {
|
||||
street: 'Maximilianstraße',
|
||||
streetNumber: '15',
|
||||
zipCode: '80539',
|
||||
city: 'München',
|
||||
country: 'DEU',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const FrenchAddress: Story = {
|
||||
args: {
|
||||
address: {
|
||||
street: 'Rue de la Paix',
|
||||
streetNumber: '25',
|
||||
zipCode: '75002',
|
||||
city: 'Paris',
|
||||
country: 'FRA',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const AustrianAddress: Story = {
|
||||
args: {
|
||||
address: {
|
||||
street: 'Stephansplatz',
|
||||
streetNumber: '1',
|
||||
zipCode: '1010',
|
||||
city: 'Wien',
|
||||
country: 'AUT',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const SwissAddress: Story = {
|
||||
args: {
|
||||
address: {
|
||||
street: 'Bahnhofstrasse',
|
||||
streetNumber: '50',
|
||||
zipCode: '8001',
|
||||
city: 'Zürich',
|
||||
country: 'CHE',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const USAddress: Story = {
|
||||
args: {
|
||||
address: {
|
||||
street: 'Fifth Avenue',
|
||||
streetNumber: '350',
|
||||
zipCode: '10118',
|
||||
city: 'New York',
|
||||
country: 'USA',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const ShortAddress: Story = {
|
||||
args: {
|
||||
address: {
|
||||
street: 'Dorfstraße',
|
||||
streetNumber: '5',
|
||||
city: 'Neustadt',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const StreetOnly: Story = {
|
||||
args: {
|
||||
address: {
|
||||
street: 'Hauptstraße',
|
||||
streetNumber: '10',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const CityOnly: Story = {
|
||||
args: {
|
||||
address: {
|
||||
zipCode: '12345',
|
||||
city: 'Beispielstadt',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const NoCountry: Story = {
|
||||
args: {
|
||||
address: {
|
||||
street: 'Teststraße',
|
||||
streetNumber: '99',
|
||||
zipCode: '54321',
|
||||
city: 'Musterstadt',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const WithCountryLookup: Story = {
|
||||
args: {
|
||||
address: {
|
||||
street: 'Via Roma',
|
||||
streetNumber: '10',
|
||||
zipCode: '00100',
|
||||
city: 'Roma',
|
||||
country: 'ITA',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const SpanishAddress: Story = {
|
||||
args: {
|
||||
address: {
|
||||
street: 'Calle Mayor',
|
||||
streetNumber: '1',
|
||||
zipCode: '28013',
|
||||
city: 'Madrid',
|
||||
country: 'ESP',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const EmptyAddress: Story = {
|
||||
args: {
|
||||
address: {},
|
||||
},
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Meta, argsToTemplate } from '@storybook/angular';
|
||||
import { ProductFormatIconGroup } from '@isa/icons';
|
||||
import { ProductFormatIconComponent } from '@isa/shared/product-foramt';
|
||||
import { ProductFormatIconComponent } from '@isa/shared/product-format';
|
||||
|
||||
type ProductFormatInputs = {
|
||||
format: string;
|
||||
|
||||
@@ -1,48 +1,57 @@
|
||||
import { argsToTemplate, Meta } from '@storybook/angular';
|
||||
import { ProductFormatIconGroup } from '@isa/icons';
|
||||
import { ProductFormatComponent } from '@isa/shared/product-foramt';
|
||||
|
||||
type ProductFormatInputs = {
|
||||
format: string;
|
||||
formatDetail: string;
|
||||
};
|
||||
|
||||
const options = Object.keys(ProductFormatIconGroup).map((key) =>
|
||||
key.toUpperCase(),
|
||||
);
|
||||
|
||||
const meta: Meta<ProductFormatInputs> = {
|
||||
title: 'shared/product-format/ProductFormat',
|
||||
component: ProductFormatComponent,
|
||||
argTypes: {
|
||||
format: {
|
||||
control: {
|
||||
type: 'select',
|
||||
},
|
||||
options,
|
||||
description: 'The product format to display the icon for.',
|
||||
defaultValue: options[0],
|
||||
},
|
||||
formatDetail: {
|
||||
control: {
|
||||
type: 'text',
|
||||
},
|
||||
description: 'The detail text for the product format.',
|
||||
defaultValue: 'Default Format Detail',
|
||||
},
|
||||
},
|
||||
args: {
|
||||
format: options[0], // Default value for the product format
|
||||
formatDetail: 'Default Format Detail', // Default value for the format detail
|
||||
},
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: `<shared-product-format ${argsToTemplate(args)}></shared-product-format>`,
|
||||
}),
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = typeof meta;
|
||||
|
||||
export const Default: Story = {};
|
||||
import { argsToTemplate, Meta } from '@storybook/angular';
|
||||
import { ProductFormatIconGroup } from '@isa/icons';
|
||||
import { ProductFormatComponent } from '@isa/shared/product-format';
|
||||
|
||||
type ProductFormatInputs = {
|
||||
format: string;
|
||||
formatDetail: string;
|
||||
formatDetailsBold: boolean;
|
||||
};
|
||||
|
||||
const options = Object.keys(ProductFormatIconGroup).map((key) =>
|
||||
key.toUpperCase(),
|
||||
);
|
||||
|
||||
const meta: Meta<ProductFormatInputs> = {
|
||||
title: 'shared/product-format/ProductFormat',
|
||||
component: ProductFormatComponent,
|
||||
argTypes: {
|
||||
format: {
|
||||
control: {
|
||||
type: 'select',
|
||||
},
|
||||
options,
|
||||
description: 'The product format to display the icon for.',
|
||||
defaultValue: options[0],
|
||||
},
|
||||
formatDetail: {
|
||||
control: {
|
||||
type: 'text',
|
||||
},
|
||||
description: 'The detail text for the product format.',
|
||||
defaultValue: 'Default Format Detail',
|
||||
},
|
||||
formatDetailsBold: {
|
||||
control: {
|
||||
type: 'boolean',
|
||||
},
|
||||
description: 'Whether the format detail text should be bold.',
|
||||
defaultValue: false,
|
||||
},
|
||||
},
|
||||
args: {
|
||||
format: options[0], // Default value for the product format
|
||||
formatDetail: 'Default Format Detail', // Default value for the format detail
|
||||
formatDetailsBold: false, // Default value for the format details bold
|
||||
},
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: `<shared-product-format ${argsToTemplate(args)}></shared-product-format>`,
|
||||
}),
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = typeof meta;
|
||||
|
||||
export const Default: Story = {};
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import {
|
||||
argsToTemplate,
|
||||
moduleMetadata,
|
||||
type Meta,
|
||||
type StoryObj,
|
||||
} from '@storybook/angular';
|
||||
import { QuantityControlComponent } from '@isa/shared/quantity-control';
|
||||
import { FormControl, ReactiveFormsModule } from '@angular/forms';
|
||||
|
||||
interface QuantityControlStoryProps {
|
||||
value: number;
|
||||
disabled: boolean;
|
||||
min?: number;
|
||||
max?: number;
|
||||
presetLimit?: number;
|
||||
}
|
||||
|
||||
const meta: Meta<QuantityControlStoryProps> = {
|
||||
component: QuantityControlComponent,
|
||||
title: 'shared/quantity-control/QuantityControl',
|
||||
argTypes: {
|
||||
value: {
|
||||
control: { type: 'number', min: 0, max: 99 },
|
||||
description: 'The quantity value',
|
||||
},
|
||||
disabled: {
|
||||
control: 'boolean',
|
||||
description: 'Disables the control when true',
|
||||
},
|
||||
min: {
|
||||
control: { type: 'number', min: 0, max: 10 },
|
||||
description: 'Minimum selectable value',
|
||||
},
|
||||
max: {
|
||||
control: { type: 'number', min: 1, max: 99 },
|
||||
description: 'Maximum selectable value (e.g., stock available)',
|
||||
},
|
||||
presetLimit: {
|
||||
control: { type: 'number', min: 1, max: 99 },
|
||||
description: 'Number of preset options before requiring Edit',
|
||||
},
|
||||
},
|
||||
args: {
|
||||
value: 1,
|
||||
disabled: false,
|
||||
min: 1,
|
||||
max: undefined,
|
||||
presetLimit: 10,
|
||||
},
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: `<shared-quantity-control ${argsToTemplate(args)} />`,
|
||||
}),
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<QuantityControlStoryProps>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
value: 1,
|
||||
disabled: false,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithCustomValue: Story = {
|
||||
args: {
|
||||
value: 5,
|
||||
disabled: false,
|
||||
},
|
||||
};
|
||||
|
||||
export const HighStock: Story = {
|
||||
args: {
|
||||
value: 15,
|
||||
disabled: false,
|
||||
min: 1,
|
||||
max: 50,
|
||||
presetLimit: 20, // Shows 1-20, Edit for 21-50
|
||||
},
|
||||
};
|
||||
|
||||
export const LimitedStock: Story = {
|
||||
args: {
|
||||
value: 3,
|
||||
disabled: false,
|
||||
min: 1,
|
||||
max: 5,
|
||||
presetLimit: 10, // Shows 1-5 (capped at max), no Edit
|
||||
},
|
||||
};
|
||||
|
||||
export const ExactStock: Story = {
|
||||
args: {
|
||||
value: 1,
|
||||
disabled: false,
|
||||
min: 1,
|
||||
max: 10,
|
||||
presetLimit: 10, // Shows 1-10, no Edit (max=10 == presetLimit)
|
||||
},
|
||||
};
|
||||
|
||||
export const StartFromZero: Story = {
|
||||
args: {
|
||||
value: 0,
|
||||
disabled: false,
|
||||
min: 0,
|
||||
max: undefined,
|
||||
presetLimit: 10, // Shows 0-9, Edit for unlimited
|
||||
},
|
||||
};
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
value: 3,
|
||||
disabled: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const InFormContext: Story = {
|
||||
args: {
|
||||
value: 2,
|
||||
disabled: false,
|
||||
},
|
||||
decorators: [
|
||||
moduleMetadata({
|
||||
imports: [ReactiveFormsModule],
|
||||
}),
|
||||
],
|
||||
render: (args) => ({
|
||||
props: {
|
||||
...args,
|
||||
quantityControl: new FormControl(args.value),
|
||||
},
|
||||
template: `
|
||||
<div style="display: flex; flex-direction: column; gap: 1rem;">
|
||||
<shared-quantity-control [formControl]="quantityControl" />
|
||||
<div style="margin-top: 1rem; padding: 1rem; background: #f5f5f5; border-radius: 4px;">
|
||||
<strong>Form Value:</strong> {{ quantityControl.value }}
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
};
|
||||
@@ -11,11 +11,17 @@ const meta: Meta<TooltipDirective> = {
|
||||
control: 'multi-select',
|
||||
options: ['click', 'hover', 'focus'],
|
||||
},
|
||||
variant: {
|
||||
control: { type: 'select' },
|
||||
options: ['default', 'warning'],
|
||||
description: 'Determines the visual variant of the tooltip',
|
||||
},
|
||||
},
|
||||
args: {
|
||||
title: 'Tooltip Title',
|
||||
content: 'This is the tooltip content.',
|
||||
triggerOn: ['click', 'hover', 'focus'],
|
||||
variant: 'default',
|
||||
},
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
@@ -37,3 +43,12 @@ export const Default: Story = {
|
||||
triggerOn: ['hover', 'click'],
|
||||
},
|
||||
};
|
||||
|
||||
export const Warning: Story = {
|
||||
args: {
|
||||
title: 'Warning Tooltip',
|
||||
content: 'This is a warning message.',
|
||||
triggerOn: ['hover', 'click'],
|
||||
variant: 'warning',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -12,7 +12,7 @@ variables:
|
||||
value: '4'
|
||||
# Minor Version einstellen
|
||||
- name: 'Minor'
|
||||
value: '0'
|
||||
value: '1'
|
||||
- name: 'Patch'
|
||||
value: "$[counter(format('{0}.{1}', variables['Major'], variables['Minor']),0)]"
|
||||
- name: 'BuildUniqueID'
|
||||
|
||||
200
docs/architecture/README.md
Normal file
200
docs/architecture/README.md
Normal file
@@ -0,0 +1,200 @@
|
||||
# Architecture Decision Records (ADRs)
|
||||
|
||||
## Overview
|
||||
|
||||
Architecture Decision Records (ADRs) are lightweight documents that capture important architectural decisions made during the development of the ISA-Frontend project. They provide context for why certain decisions were made, helping current and future team members understand the reasoning behind architectural choices.
|
||||
|
||||
## What are ADRs?
|
||||
|
||||
An Architecture Decision Record is a document that captures a single architectural decision and its rationale. The goal of an ADR is to document the architectural decisions that are being made so that:
|
||||
|
||||
- **Future team members** can understand why certain decisions were made
|
||||
- **Current team members** can refer back to the reasoning behind decisions
|
||||
- **Architectural evolution** can be tracked over time
|
||||
- **Knowledge transfer** is facilitated during team changes
|
||||
|
||||
## ADR Structure
|
||||
|
||||
Each ADR follows a consistent structure based on our [TEMPLATE.md](./TEMPLATE.md) and includes:
|
||||
|
||||
- **Problem Statement**: What architectural challenge needs to be addressed
|
||||
- **Decision**: The architectural decision made
|
||||
- **Rationale**: Why this decision was chosen
|
||||
- **Consequences**: Both positive and negative outcomes of the decision
|
||||
- **Alternatives**: Other options that were considered
|
||||
- **Implementation**: Technical details and examples
|
||||
- **Status**: Current state of the decision (Draft, Approved, Superseded, etc.)
|
||||
|
||||
## Naming Convention
|
||||
|
||||
ADRs should follow this naming pattern:
|
||||
|
||||
```
|
||||
NNNN-short-descriptive-title.md
|
||||
```
|
||||
|
||||
Where:
|
||||
- `NNNN` is a 4-digit sequential number (e.g., 0001, 0002, 0003...)
|
||||
- `short-descriptive-title` uses kebab-case and briefly describes the decision
|
||||
- `.md` indicates it's a Markdown file
|
||||
|
||||
### Examples:
|
||||
- `0001-use-standalone-components.md`
|
||||
- `0002-adopt-ngrx-signals.md`
|
||||
- `0003-implement-micro-frontend-architecture.md`
|
||||
- `0004-choose-vitest-over-jest.md`
|
||||
|
||||
## Process Guidelines
|
||||
|
||||
### 1. When to Create an ADR
|
||||
|
||||
Create an ADR when making decisions about:
|
||||
|
||||
- **Architecture patterns** (e.g., micro-frontends, monorepo structure)
|
||||
- **Technology choices** (e.g., testing frameworks, state management)
|
||||
- **Development practices** (e.g., code organization, build processes)
|
||||
- **Technical standards** (e.g., coding conventions, performance requirements)
|
||||
- **Infrastructure decisions** (e.g., deployment strategies, CI/CD processes)
|
||||
|
||||
### 2. ADR Lifecycle
|
||||
|
||||
```
|
||||
Draft → Under Review → Approved → [Superseded/Deprecated]
|
||||
```
|
||||
|
||||
- **Draft**: Initial version, being written
|
||||
- **Under Review**: Shared with team for feedback and discussion
|
||||
- **Approved**: Team has agreed and decision is implemented
|
||||
- **Superseded**: Replaced by a newer ADR
|
||||
- **Deprecated**: No longer applicable but kept for historical reference
|
||||
|
||||
### 3. Creation Process
|
||||
|
||||
1. **Identify the Need**: Recognize an architectural decision needs documentation
|
||||
2. **Create from Template**: Copy [TEMPLATE.md](./TEMPLATE.md) to create new ADR
|
||||
3. **Fill in Content**: Complete all sections with relevant information
|
||||
4. **Set Status to Draft**: Mark the document as "Draft" initially
|
||||
5. **Share for Review**: Present to team for discussion and feedback
|
||||
6. **Iterate**: Update based on team input
|
||||
7. **Approve**: Once consensus is reached, mark as "Approved"
|
||||
8. **Implement**: Begin implementation of the architectural decision
|
||||
|
||||
### 4. Review Process
|
||||
|
||||
- **Author Review**: Self-review for completeness and clarity
|
||||
- **Peer Review**: Share with relevant team members for technical review
|
||||
- **Architecture Review**: Present in architecture meetings if significant
|
||||
- **Final Approval**: Get sign-off from technical leads/architects
|
||||
|
||||
## Angular/Nx Specific Considerations
|
||||
|
||||
When writing ADRs for this project, consider these Angular/Nx specific aspects:
|
||||
|
||||
### Architecture Decisions
|
||||
- **Library organization** in the monorepo structure
|
||||
- **Dependency management** between applications and libraries
|
||||
- **Feature module vs. standalone component** approaches
|
||||
- **State management patterns** (NgRx, Signals, Services)
|
||||
- **Routing strategies** for large applications
|
||||
|
||||
### Technical Decisions
|
||||
- **Build optimization** strategies using Nx
|
||||
- **Testing approaches** for different types of libraries
|
||||
- **Code sharing patterns** across applications
|
||||
- **Performance optimization** techniques
|
||||
- **Bundle splitting** and lazy loading strategies
|
||||
|
||||
### Development Workflow
|
||||
- **Nx executor usage** for custom tasks
|
||||
- **Generator patterns** for code scaffolding
|
||||
- **Linting and formatting** configurations
|
||||
- **CI/CD pipeline** optimizations using Nx affected commands
|
||||
|
||||
## Template Usage
|
||||
|
||||
### Getting Started
|
||||
|
||||
1. Copy the [TEMPLATE.md](./TEMPLATE.md) file
|
||||
2. Rename it following the naming convention
|
||||
3. Replace placeholder text with actual content
|
||||
4. Focus on the "why" not just the "what"
|
||||
5. Include concrete examples and code snippets
|
||||
6. Consider both immediate and long-term consequences
|
||||
|
||||
### Key Template Sections
|
||||
|
||||
- **Decision**: State the architectural decision clearly and concisely
|
||||
- **Context**: Provide background information and constraints
|
||||
- **Consequences**: Be honest about both benefits and drawbacks
|
||||
- **Implementation**: Include practical examples relevant to Angular/Nx
|
||||
- **Alternatives**: Show you considered other options
|
||||
|
||||
## Examples of Good ADRs
|
||||
|
||||
Here are some example titles that would make good ADRs for this project:
|
||||
|
||||
- **State Management**: "0001-adopt-ngrx-signals-for-component-state.md"
|
||||
- **Testing Strategy**: "0002-use-angular-testing-utilities-over-spectator.md"
|
||||
- **Code Organization**: "0003-implement-domain-driven-library-structure.md"
|
||||
- **Performance**: "0004-implement-lazy-loading-for-feature-modules.md"
|
||||
- **Build Process**: "0005-use-nx-cloud-for-distributed-task-execution.md"
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Writing Effective ADRs
|
||||
|
||||
1. **Be Concise**: Keep it focused and to the point
|
||||
2. **Be Specific**: Include concrete examples and implementation details
|
||||
3. **Be Honest**: Document both pros and cons honestly
|
||||
4. **Be Timely**: Write ADRs close to when decisions are made
|
||||
5. **Be Collaborative**: Involve relevant team members in the process
|
||||
|
||||
### Maintenance
|
||||
|
||||
- **Review Regularly**: Check ADRs during architecture reviews
|
||||
- **Update Status**: Keep status current as decisions evolve
|
||||
- **Link Related ADRs**: Reference connected decisions
|
||||
- **Archive Outdated**: Mark superseded ADRs appropriately
|
||||
|
||||
### Code Examples
|
||||
|
||||
When including code examples:
|
||||
- Use actual project syntax and patterns
|
||||
- Include both TypeScript and template examples where relevant
|
||||
- Show before/after scenarios for changes
|
||||
- Reference specific files in the codebase when possible
|
||||
|
||||
## Tools and Integration
|
||||
|
||||
### Recommended Tools
|
||||
|
||||
- **Markdown Editor**: Use any markdown-capable editor
|
||||
- **Version Control**: All ADRs are tracked in Git
|
||||
- **Review Process**: Use PR reviews for ADR approval
|
||||
- **Documentation**: Link ADRs from relevant code comments
|
||||
|
||||
### Integration with Development
|
||||
|
||||
- Reference ADR numbers in commit messages when implementing decisions
|
||||
- Include ADR links in PR descriptions for architectural changes
|
||||
- Update ADRs when decisions need modification
|
||||
- Use ADRs as reference during code reviews
|
||||
|
||||
## Getting Help
|
||||
|
||||
### Questions or Issues?
|
||||
|
||||
- **Team Discussions**: Bring up in team meetings or Slack
|
||||
- **Architecture Review**: Present in architecture meetings
|
||||
- **Documentation**: Update this README if process improvements are needed
|
||||
|
||||
### Resources
|
||||
|
||||
- [Architecture Decision Records (ADRs) - Michael Nygard](https://cognitect.com/blog/2011/11/15/documenting-architecture-decisions)
|
||||
- [ADR GitHub Organization](https://adr.github.io/)
|
||||
- [Nx Documentation](https://nx.dev/getting-started/intro)
|
||||
- [Angular Architecture Guide](https://angular.dev/guide/architecture)
|
||||
|
||||
---
|
||||
|
||||
*This ADR system helps maintain architectural consistency and knowledge sharing across the ISA-Frontend project. Keep it updated and use it regularly for the best results.*
|
||||
138
docs/architecture/TEMPLATE.md
Normal file
138
docs/architecture/TEMPLATE.md
Normal file
@@ -0,0 +1,138 @@
|
||||
# ADR NNNN: <short-descriptive-title>
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| Status | Draft / Under Review / Approved / Superseded by ADR NNNN / Deprecated |
|
||||
| Date | YYYY-MM-DD |
|
||||
| Owners | <author(s)> |
|
||||
| Participants | <key reviewers / stakeholders> |
|
||||
| Related ADRs | NNNN (title), NNNN (title) |
|
||||
| Tags | architecture, <domain>, <category> |
|
||||
|
||||
---
|
||||
## Summary (Decision in One Sentence)
|
||||
Concise statement of the architectural decision. Avoid rationale here—just the what.
|
||||
|
||||
## Context & Problem Statement
|
||||
Describe the background and the problem this decision addresses.
|
||||
- Business drivers / user needs
|
||||
- Technical constraints (performance, security, scalability, compliance, legacy, regulatory)
|
||||
- Current pain points / gaps
|
||||
- Measurable goals / success criteria (e.g. reduce build time by 30%)
|
||||
|
||||
### Scope
|
||||
What is in scope and explicitly out of scope for this decision.
|
||||
|
||||
## Decision
|
||||
State the decision clearly (active voice). Include high-level approach or pattern selection, not implementation detail.
|
||||
|
||||
## Rationale
|
||||
Why this option was selected:
|
||||
- Alignment with strategic/technical direction
|
||||
- Trade-offs considered
|
||||
- Data, benchmarks, experiments, spikes
|
||||
- Impact on developer experience / velocity
|
||||
- Long-term maintainability & extensibility
|
||||
|
||||
## Alternatives Considered
|
||||
| Alternative | Summary | Pros | Cons | Reason Not Chosen |
|
||||
|-------------|---------|------|------|-------------------|
|
||||
| Option A | | | | |
|
||||
| Option B | | | | |
|
||||
| Option C | | | | |
|
||||
|
||||
Add deeper detail below if needed:
|
||||
### Option A – <name>
|
||||
### Option B – <name>
|
||||
### Option C – <name>
|
||||
|
||||
## Consequences
|
||||
### Positive
|
||||
- …
|
||||
### Negative / Risks / Debt Introduced
|
||||
- …
|
||||
### Neutral / Open Questions
|
||||
- …
|
||||
|
||||
## Implementation Plan
|
||||
High-level rollout strategy. Break into phases if applicable.
|
||||
1. Phase 0 – Spike / Validation
|
||||
2. Phase 1 – Foundation / Infrastructure
|
||||
3. Phase 2 – Incremental Adoption / Migration
|
||||
4. Phase 3 – Hardening / Optimization
|
||||
5. Phase 4 – Decommission Legacy
|
||||
|
||||
### Tasks / Workstreams
|
||||
- Infra / tooling changes
|
||||
- Library additions (Nx generators, new libs under `libs/<domain>`)
|
||||
- Refactors / migrations
|
||||
- Testing strategy updates (Jest → Vitest, Signals adoption, etc.)
|
||||
- Documentation & onboarding materials
|
||||
|
||||
### Acceptance Criteria
|
||||
List objective criteria to mark implementation complete.
|
||||
|
||||
### Rollback Plan
|
||||
How to revert safely if outcomes are negative.
|
||||
|
||||
## Architectural Impact
|
||||
### Nx / Monorepo Layout
|
||||
Describe changes to library boundaries, tags, dependency graph, affected projects.
|
||||
### Module / Library Design
|
||||
New or modified public APIs (`src/index.ts` changes, path aliases additions to `tsconfig.base.json`).
|
||||
### State Management
|
||||
Implications for Signals, NgRx, resource factories, persistence patterns (`withStorage`).
|
||||
### Runtime & Performance
|
||||
Bundle size, lazy loading, code splitting, caching, SSR/hydration considerations.
|
||||
### Security & Compliance
|
||||
AuthZ/AuthN, token handling, data residency, PII, secure storage.
|
||||
### Observability & Logging
|
||||
Logging contexts (`@isa/core/logging`), metrics, tracing hooks.
|
||||
### DX / Tooling
|
||||
Generators, lint rules, schematic updates, local dev flow.
|
||||
|
||||
## Detailed Design Elements
|
||||
(Optional deeper technical articulation.)
|
||||
- Sequence diagrams / component diagrams
|
||||
- Data flow / async flow
|
||||
- Error handling strategy
|
||||
- Concurrency / cancellation (e.g. `rxMethod` + `switchMap` usage)
|
||||
- Abstractions & extension points
|
||||
|
||||
## Code Examples
|
||||
### Before
|
||||
```ts
|
||||
// Previous approach (simplified)
|
||||
```
|
||||
### After
|
||||
```ts
|
||||
// New approach (simplified)
|
||||
```
|
||||
### Migration Snippet
|
||||
```ts
|
||||
// Example incremental migration pattern
|
||||
```
|
||||
|
||||
## Open Questions / Follow-Ups
|
||||
- Unresolved design clarifications
|
||||
- Dependent ADRs required
|
||||
- External approvals needed
|
||||
|
||||
## Decision Review & Revalidation
|
||||
When and how this ADR will be re-evaluated (date, trigger conditions, metrics thresholds).
|
||||
|
||||
## Status Log
|
||||
| Date | Change | Author |
|
||||
|------|--------|--------|
|
||||
| YYYY-MM-DD | Created (Draft) | |
|
||||
| YYYY-MM-DD | Approved | |
|
||||
| YYYY-MM-DD | Superseded by ADR NNNN | |
|
||||
|
||||
## References
|
||||
- Links to spike notes, benchmark results
|
||||
- External articles, standards, RFCs
|
||||
- Related code PRs / commits
|
||||
|
||||
---
|
||||
> Document updates MUST reference this ADR number in commit messages: `ADR-NNNN:` prefix.
|
||||
> Keep this document updated through all lifecycle stages.
|
||||
506
docs/architecture/adr/0001-implement-data-access-api-requests.md
Normal file
506
docs/architecture/adr/0001-implement-data-access-api-requests.md
Normal file
@@ -0,0 +1,506 @@
|
||||
# ADR 0001: Implement `data-access` API Requests
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| Status | Draft |
|
||||
| Date | 29.09.2025 |
|
||||
| Owners | Lorenz, Nino |
|
||||
| Participants | N/A |
|
||||
| Related ADRs | N/A |
|
||||
| Tags | architecture, data-access, library, swagger |
|
||||
|
||||
---
|
||||
## Summary (Decision in One Sentence)
|
||||
Standardize data-access library implementation patterns for API requests using Zod schemas for validation, domain-specific models extending generated DTOs, and service layers that integrate with generated Swagger clients.
|
||||
|
||||
## Context & Problem Statement
|
||||
The ISA Frontend application requires consistent and maintainable patterns for implementing API requests across multiple domain libraries. Current data-access libraries show varying implementation approaches that need standardization.
|
||||
|
||||
**Business drivers / user needs:**
|
||||
- Consistent error handling across all API interactions
|
||||
- Type-safe request/response handling to prevent runtime errors
|
||||
- Maintainable code structure for easy onboarding and development
|
||||
- Reliable validation of API inputs and outputs
|
||||
|
||||
**Technical constraints:**
|
||||
- Must integrate with generated Swagger clients (`@generated/swagger/*`)
|
||||
- Need to support abort signals for request cancellation
|
||||
- Require caching and performance optimization capabilities
|
||||
- Must align with existing logging infrastructure (`@isa/core/logging`)
|
||||
- Support for domain-specific model extensions beyond generated DTOs
|
||||
|
||||
**Current pain points:**
|
||||
- Inconsistent validation patterns across different data-access libraries
|
||||
- Mixed approaches to error handling and response processing
|
||||
- Duplication of common patterns (abort signal handling, response parsing)
|
||||
- Lack of standardized model extension patterns
|
||||
|
||||
**Measurable goals:**
|
||||
- Standardize API request patterns across all 4+ data-access libraries
|
||||
- Reduce boilerplate code by 40% through shared utilities
|
||||
- Improve type safety with comprehensive Zod schema coverage
|
||||
|
||||
### Scope
|
||||
**In scope:**
|
||||
- Schema validation patterns using Zod
|
||||
- Model definition standards extending generated DTOs
|
||||
- Service implementation patterns with generated Swagger clients
|
||||
- Error handling and response processing standardization
|
||||
- Integration with common utilities and logging
|
||||
|
||||
**Out of scope:**
|
||||
- Modification of generated Swagger client code
|
||||
- Changes to backend API contracts
|
||||
- Authentication/authorization mechanisms
|
||||
- Caching implementation details (handled by decorators)
|
||||
|
||||
## Decision
|
||||
Implement a three-layer architecture pattern for data-access libraries:
|
||||
|
||||
1. **Schema Layer**: Use Zod schemas for input validation and type inference, following the naming convention `<Operation>Schema` with corresponding `<Operation>` and `<Operation>Input` types
|
||||
2. **Model Layer**: Define domain-specific interfaces that extend generated DTOs, using `EntityContainer<T>` pattern for lazy-loaded relationships
|
||||
3. **Service Layer**: Create injectable services that integrate generated Swagger clients, implement standardized error handling, and support request cancellation via AbortSignal
|
||||
|
||||
All data-access libraries will follow the standard export structure: `models`, `schemas`, `services`, and optionally `stores` and `helpers`.
|
||||
|
||||
## Rationale
|
||||
**Alignment with strategic/technical direction:**
|
||||
- Leverages existing Zod integration for consistent validation across the application
|
||||
- Builds upon established generated Swagger client infrastructure
|
||||
- Aligns with Angular dependency injection patterns and service architecture
|
||||
- Supports the project's type-safety goals with TypeScript
|
||||
|
||||
**Trade-offs considered:**
|
||||
- **Schema validation overhead**: Zod validation adds minimal runtime cost but provides significant development-time safety
|
||||
- **Model extension complexity**: Interface extension pattern adds a layer but enables domain-specific enhancements
|
||||
- **Service layer abstraction**: Additional abstraction over generated clients but enables consistent error handling and logging
|
||||
|
||||
**Evidence from current implementation:**
|
||||
- Analysis of 4 data-access libraries shows successful patterns in `catalogue`, `remission`, `crm`, and `oms`
|
||||
- `RemissionReturnReceiptService` demonstrates effective integration with logging and error handling
|
||||
- `EntityContainer<T>` pattern proven effective for lazy-loaded relationships in remission domain
|
||||
|
||||
**Developer experience impact:**
|
||||
- Consistent patterns reduce cognitive load when switching between domains
|
||||
- Type inference from Zod schemas eliminates manual type definitions
|
||||
- Standardized error handling reduces debugging time
|
||||
- Auto-completion and type safety improve development velocity
|
||||
|
||||
**Long-term maintainability:**
|
||||
- Clear separation of concerns between validation, models, and API integration
|
||||
- Generated client changes don't break domain-specific model extensions
|
||||
- Consistent logging and error handling simplifies troubleshooting
|
||||
|
||||
## Alternatives Considered
|
||||
| Alternative | Summary | Pros | Cons | Reason Not Chosen |
|
||||
|-------------|---------|------|------|-------------------|
|
||||
| Option A | | | | |
|
||||
| Option B | | | | |
|
||||
| Option C | | | | |
|
||||
|
||||
Add deeper detail below if needed:
|
||||
### Option A – <name>
|
||||
### Option B – <name>
|
||||
### Option C – <name>
|
||||
|
||||
## Consequences
|
||||
### Positive
|
||||
- …
|
||||
### Negative / Risks / Debt Introduced
|
||||
- …
|
||||
### Neutral / Open Questions
|
||||
- …
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Phase 0 – Analysis & Standards (Completed)
|
||||
- ✅ Analyzed existing data-access libraries (`catalogue`, `remission`, `crm`, `oms`)
|
||||
- ✅ Identified common patterns and best practices
|
||||
- ✅ Documented standard library structure
|
||||
|
||||
### Phase 1 – Common Utilities Enhancement
|
||||
- Enhance `@isa/common/data-access` with additional utilities
|
||||
- Add standardized error types and response handling
|
||||
- Create reusable operators and decorators
|
||||
- Add helper functions for common API patterns
|
||||
|
||||
### Phase 2 – Template & Generator Creation
|
||||
- Create Nx generator for new data-access libraries
|
||||
- Develop template files for schemas, models, and services
|
||||
- Add code snippets and documentation templates
|
||||
- Create migration guide for existing libraries
|
||||
|
||||
### Phase 3 – Existing Library Standardization
|
||||
- Update `catalogue/data-access` to follow complete pattern
|
||||
- Migrate `crm/data-access` to standard structure
|
||||
- Ensure `remission/data-access` follows all conventions
|
||||
- Standardize `oms/data-access` implementation
|
||||
|
||||
### Phase 4 – New Library Implementation
|
||||
- Apply patterns to new domain libraries as they're created
|
||||
- Use Nx generator for consistent setup
|
||||
- Enforce patterns through code review and linting
|
||||
|
||||
### Tasks / Workstreams
|
||||
**Infrastructure:**
|
||||
- Update `@isa/common/data-access` with enhanced utilities
|
||||
- Add ESLint rules for data-access pattern enforcement
|
||||
- Update `tsconfig.base.json` path mappings as needed
|
||||
|
||||
**Library Enhancements:**
|
||||
- Create Nx generator: `nx g @isa/generators:data-access-lib <domain>`
|
||||
- Add utility functions to `@isa/common/data-access`
|
||||
- Enhanced error handling and logging patterns
|
||||
|
||||
**Migration Tasks:**
|
||||
- Standardize schema validation across all libraries
|
||||
- Ensure consistent model extension patterns
|
||||
- Align service implementations with logging standards
|
||||
- Update tests to match new patterns
|
||||
|
||||
**Documentation:**
|
||||
- Create data-access implementation guide
|
||||
- Update onboarding materials with patterns
|
||||
- Add code examples to development wiki
|
||||
- Document generator usage and options
|
||||
|
||||
### Acceptance Criteria
|
||||
- [ ] All data-access libraries follow standardized structure
|
||||
- [ ] All API requests use Zod schema validation
|
||||
- [ ] All services implement consistent error handling
|
||||
- [ ] All services support AbortSignal for cancellation
|
||||
- [ ] All models extend generated DTOs appropriately
|
||||
- [ ] Nx generator produces compliant library structure
|
||||
- [ ] Code review checklist includes data-access patterns
|
||||
- [ ] Performance benchmarks show no degradation
|
||||
|
||||
### Rollback Plan
|
||||
- Individual library changes can be reverted via Git
|
||||
- Generated libraries can be recreated with previous patterns
|
||||
- No breaking changes to existing public APIs
|
||||
- Gradual migration allows for partial rollback by domain
|
||||
|
||||
## Architectural Impact
|
||||
### Nx / Monorepo Layout
|
||||
- Data-access libraries follow domain-based organization: `libs/<domain>/data-access/`
|
||||
- Each library exports standard modules: `models`, `schemas`, `services`
|
||||
- Dependencies on `@isa/common/data-access` for shared utilities
|
||||
- Integration with generated Swagger clients via `@generated/swagger/<api-name>`
|
||||
|
||||
### Module / Library Design
|
||||
**Standard public API structure (`src/index.ts`):**
|
||||
```typescript
|
||||
export * from './lib/models';
|
||||
export * from './lib/schemas';
|
||||
export * from './lib/services';
|
||||
// Optional: stores, helpers
|
||||
```
|
||||
|
||||
**Path aliases in `tsconfig.base.json`:**
|
||||
- `@isa/<domain>/data-access` for each domain library
|
||||
- `@generated/swagger/<api-name>` for generated clients
|
||||
- `@isa/common/data-access` for shared utilities
|
||||
|
||||
### State Management
|
||||
- Services integrate with NgRx signal stores in feature libraries
|
||||
- `EntityContainer<T>` pattern supports lazy loading in state management
|
||||
- Resource factory pattern used for async state management (see remission examples)
|
||||
- Caching implemented via decorators (`@Cache`, `@InFlight`)
|
||||
|
||||
### Runtime & Performance
|
||||
- Zod schema validation adds minimal runtime overhead
|
||||
- Generated clients are tree-shakeable
|
||||
- AbortSignal support enables request cancellation
|
||||
- Caching decorators reduce redundant API calls
|
||||
- `firstValueFrom` pattern avoids memory leaks from subscriptions
|
||||
|
||||
### Security & Compliance
|
||||
- All API calls go through generated clients with consistent auth handling
|
||||
- Input validation via Zod schemas prevents injection attacks
|
||||
- AbortSignal support enables proper request cleanup
|
||||
- Logging excludes sensitive data through structured context
|
||||
|
||||
### Observability & Logging
|
||||
- Consistent logging via `@isa/core/logging` with service-level context
|
||||
- Structured logging with operation context and request metadata
|
||||
- Error logging includes request details without sensitive data
|
||||
- Debug logging for development troubleshooting
|
||||
|
||||
### DX / Tooling
|
||||
- Consistent patterns reduce learning curve across domains
|
||||
- Type inference from Zod schemas eliminates manual type definitions
|
||||
- Auto-completion from TypeScript interfaces
|
||||
- Standard error handling patterns
|
||||
|
||||
## Detailed Design Elements
|
||||
|
||||
### Schema Validation Pattern
|
||||
**Structure:**
|
||||
```typescript
|
||||
// Input validation schema
|
||||
export const SearchByTermSchema = z.object({
|
||||
searchTerm: z.string().min(1, 'Search term must not be empty'),
|
||||
skip: z.number().int().min(0).default(0),
|
||||
take: z.number().int().min(1).max(100).default(20),
|
||||
});
|
||||
|
||||
// Type inference
|
||||
export type SearchByTerm = z.infer<typeof SearchByTermSchema>;
|
||||
export type SearchByTermInput = z.input<typeof SearchByTermSchema>;
|
||||
```
|
||||
|
||||
### Model Extension Pattern
|
||||
**Generated DTO Extension:**
|
||||
```typescript
|
||||
import { ProductDTO } from '@generated/swagger/cat-search-api';
|
||||
|
||||
export interface Product extends ProductDTO {
|
||||
name: string;
|
||||
contributors: string;
|
||||
catalogProductNumber: string;
|
||||
// Domain-specific enhancements
|
||||
}
|
||||
```
|
||||
|
||||
**Entity Container Pattern:**
|
||||
```typescript
|
||||
export interface Return extends ReturnDTO {
|
||||
id: number;
|
||||
receipts: EntityContainer<Receipt>[]; // Lazy-loaded relationships
|
||||
}
|
||||
```
|
||||
|
||||
### Service Implementation Pattern
|
||||
**Standard service structure:**
|
||||
```typescript
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class DomainService {
|
||||
#apiService = inject(GeneratedApiService);
|
||||
#logger = logger(() => ({ service: 'DomainService' }));
|
||||
|
||||
async fetchData(params: InputType, abortSignal?: AbortSignal): Promise<ResultType> {
|
||||
const validated = ValidationSchema.parse(params);
|
||||
|
||||
let req$ = this.#apiService.operation(validated);
|
||||
if (abortSignal) {
|
||||
req$ = req$.pipe(takeUntilAborted(abortSignal));
|
||||
}
|
||||
|
||||
const res = await firstValueFrom(req$);
|
||||
|
||||
if (res.error) {
|
||||
this.#logger.error('Operation failed', new Error(res.message));
|
||||
throw new ResponseArgsError(res);
|
||||
}
|
||||
|
||||
return res.result as ResultType;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Error Handling Strategy
|
||||
1. **Input Validation**: Zod schemas validate and transform inputs
|
||||
2. **API Error Handling**: Check `res.error` from generated clients
|
||||
3. **Structured Logging**: Log errors with context via `@isa/core/logging`
|
||||
4. **Error Propagation**: Throw `ResponseArgsError` for consistent handling
|
||||
|
||||
### Concurrency & Cancellation
|
||||
- **AbortSignal Support**: All async operations accept optional AbortSignal
|
||||
- **RxJS Integration**: Use `takeUntilAborted` operator for cancellation
|
||||
- **Promise Pattern**: `firstValueFrom` prevents subscription memory leaks
|
||||
- **Caching**: `@InFlight` decorator prevents duplicate concurrent requests
|
||||
|
||||
### Extension Points
|
||||
- **Custom Decorators**: `@Cache`, `@InFlight`, `@CacheTimeToLive`
|
||||
- **Schema Transformations**: Zod `.transform()` for data normalization
|
||||
- **Model Inheritance**: Interface extension for domain-specific properties
|
||||
- **Service Composition**: Services can depend on other domain services
|
||||
|
||||
## Code Examples
|
||||
|
||||
### Complete Data-Access Library Structure
|
||||
```typescript
|
||||
// libs/domain/data-access/src/lib/schemas/fetch-items.schema.ts
|
||||
import { z } from 'zod';
|
||||
|
||||
export const FetchItemsSchema = z.object({
|
||||
categoryId: z.string().min(1),
|
||||
skip: z.number().int().min(0).default(0),
|
||||
take: z.number().int().min(1).max(100).default(20),
|
||||
filters: z.record(z.any()).default({}),
|
||||
});
|
||||
|
||||
export type FetchItems = z.infer<typeof FetchItemsSchema>;
|
||||
export type FetchItemsInput = z.input<typeof FetchItemsSchema>;
|
||||
|
||||
// libs/domain/data-access/src/lib/models/item.ts
|
||||
import { ItemDTO } from '@generated/swagger/domain-api';
|
||||
import { EntityContainer } from '@isa/common/data-access';
|
||||
import { Category } from './category';
|
||||
|
||||
export interface Item extends ItemDTO {
|
||||
id: number;
|
||||
displayName: string;
|
||||
category: EntityContainer<Category>;
|
||||
// Domain-specific enhancements
|
||||
isAvailable: boolean;
|
||||
formattedPrice: string;
|
||||
}
|
||||
|
||||
// libs/domain/data-access/src/lib/services/item.service.ts
|
||||
import { inject, Injectable } from '@angular/core';
|
||||
import { ItemService as GeneratedItemService } from '@generated/swagger/domain-api';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { takeUntilAborted, ResponseArgsError } from '@isa/common/data-access';
|
||||
import { logger } from '@isa/core/logging';
|
||||
import { FetchItemsInput, FetchItemsSchema } from '../schemas';
|
||||
import { Item } from '../models';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ItemService {
|
||||
#itemService = inject(GeneratedItemService);
|
||||
#logger = logger(() => ({ service: 'ItemService' }));
|
||||
|
||||
async fetchItems(
|
||||
params: FetchItemsInput,
|
||||
abortSignal?: AbortSignal
|
||||
): Promise<Item[]> {
|
||||
this.#logger.debug('Fetching items', () => ({ params }));
|
||||
|
||||
const { categoryId, skip, take, filters } = FetchItemsSchema.parse(params);
|
||||
|
||||
let req$ = this.#itemService.getItems({
|
||||
categoryId,
|
||||
queryToken: { skip, take, filter: filters }
|
||||
});
|
||||
|
||||
if (abortSignal) {
|
||||
req$ = req$.pipe(takeUntilAborted(abortSignal));
|
||||
}
|
||||
|
||||
const res = await firstValueFrom(req$);
|
||||
|
||||
if (res.error) {
|
||||
this.#logger.error('Failed to fetch items', new Error(res.message));
|
||||
throw new ResponseArgsError(res);
|
||||
}
|
||||
|
||||
this.#logger.info('Successfully fetched items', () => ({
|
||||
count: res.result?.length || 0
|
||||
}));
|
||||
|
||||
return res.result as Item[];
|
||||
}
|
||||
}
|
||||
|
||||
// libs/domain/data-access/src/index.ts
|
||||
export * from './lib/models';
|
||||
export * from './lib/schemas';
|
||||
export * from './lib/services';
|
||||
```
|
||||
|
||||
### Usage in Feature Components
|
||||
```typescript
|
||||
// feature component using the data-access library
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import { ItemService, Item, FetchItemsInput } from '@isa/domain/data-access';
|
||||
|
||||
@Component({
|
||||
selector: 'app-item-list',
|
||||
template: `
|
||||
@if (loading()) {
|
||||
<div>Loading...</div>
|
||||
} @else {
|
||||
@for (item of items(); track item.id) {
|
||||
<div>{{ item.displayName }}</div>
|
||||
}
|
||||
}
|
||||
`
|
||||
})
|
||||
export class ItemListComponent {
|
||||
#itemService = inject(ItemService);
|
||||
|
||||
items = signal<Item[]>([]);
|
||||
loading = signal(false);
|
||||
|
||||
async loadItems(categoryId: string) {
|
||||
this.loading.set(true);
|
||||
|
||||
try {
|
||||
const params: FetchItemsInput = {
|
||||
categoryId,
|
||||
take: 50,
|
||||
filters: { active: true }
|
||||
};
|
||||
|
||||
const items = await this.#itemService.fetchItems(params);
|
||||
this.items.set(items);
|
||||
} catch (error) {
|
||||
console.error('Failed to load items', error);
|
||||
} finally {
|
||||
this.loading.set(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Migration Pattern for Existing Services
|
||||
```typescript
|
||||
// Before: Direct HTTP client usage
|
||||
@Injectable()
|
||||
export class LegacyItemService {
|
||||
constructor(private http: HttpClient) {}
|
||||
|
||||
getItems(categoryId: string): Observable<any> {
|
||||
return this.http.get(`/api/items?category=${categoryId}`);
|
||||
}
|
||||
}
|
||||
|
||||
// After: Standardized data-access pattern
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ItemService {
|
||||
#itemService = inject(GeneratedItemService);
|
||||
#logger = logger(() => ({ service: 'ItemService' }));
|
||||
|
||||
async fetchItems(params: FetchItemsInput, abortSignal?: AbortSignal): Promise<Item[]> {
|
||||
const validated = FetchItemsSchema.parse(params);
|
||||
// ... standard implementation pattern
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Open Questions / Follow-Ups
|
||||
- Unresolved design clarifications
|
||||
- Dependent ADRs required
|
||||
- External approvals needed
|
||||
|
||||
## Decision Review & Revalidation
|
||||
When and how this ADR will be re-evaluated (date, trigger conditions, metrics thresholds).
|
||||
|
||||
## Status Log
|
||||
| Date | Change | Author |
|
||||
|------|--------|--------|
|
||||
| 2025-09-29 | Created (Draft) | Lorenz, Nino |
|
||||
| 2025-09-25 | Analysis completed, comprehensive patterns documented | AI Assistant |
|
||||
|
||||
## References
|
||||
**Existing Implementation Examples:**
|
||||
- `libs/catalogue/data-access` - Basic schema and service patterns
|
||||
- `libs/remission/data-access` - Advanced patterns with EntityContainer and stores
|
||||
- `libs/common/data-access` - Shared utilities and response types
|
||||
- `generated/swagger/` - Generated API clients integration
|
||||
|
||||
**Key Dependencies:**
|
||||
- [Zod](https://github.com/colinhacks/zod) - Schema validation library
|
||||
- [ng-swagger-gen](https://github.com/cyclosproject/ng-swagger-gen) - OpenAPI client generation
|
||||
- `@isa/core/logging` - Structured logging infrastructure
|
||||
- `@isa/common/data-access` - Shared utilities and types
|
||||
|
||||
**Related Documentation:**
|
||||
- ISA Frontend Copilot Instructions - Data-access patterns
|
||||
- Tech Stack Documentation - Architecture overview
|
||||
- Code Style Guidelines - TypeScript and Angular patterns
|
||||
|
||||
---
|
||||
> Document updates MUST reference this ADR number in commit messages: `ADR-NNNN:` prefix.
|
||||
> Keep this document updated through all lifecycle stages.
|
||||
@@ -11,3 +11,346 @@
|
||||
- Use for complex application state
|
||||
- Follow feature-based store organization
|
||||
- Implement proper error handling
|
||||
|
||||
## Navigation State
|
||||
|
||||
Navigation state refers to temporary data preserved between routes during navigation flows. Unlike global state or local component state, navigation state is transient and tied to a specific navigation flow within a tab.
|
||||
|
||||
### Storage Architecture
|
||||
|
||||
Navigation contexts are stored in **tab metadata** using `@isa/core/navigation`:
|
||||
|
||||
```typescript
|
||||
// Context stored in tab metadata:
|
||||
tab.metadata['navigation-contexts'] = {
|
||||
'default': {
|
||||
data: { returnUrl: '/cart', customerId: 123 },
|
||||
createdAt: 1234567890
|
||||
},
|
||||
'customer-flow': {
|
||||
data: { step: 2, selectedOptions: ['A', 'B'] },
|
||||
createdAt: 1234567895
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- ✅ **Automatic cleanup** when tabs close (no manual cleanup needed)
|
||||
- ✅ **Tab isolation** (contexts don't leak between tabs)
|
||||
- ✅ **Persistence** across page refresh (via TabService UserStorage)
|
||||
- ✅ **Integration** with tab lifecycle management
|
||||
|
||||
### When to Use Navigation State
|
||||
|
||||
Use `@isa/core/navigation` for:
|
||||
|
||||
- **Return URLs**: Storing the previous route to navigate back to
|
||||
- **Wizard/Multi-step Forms**: Passing context between wizard steps
|
||||
- **Context Preservation**: Maintaining search queries, filters, or selections when drilling into details
|
||||
- **Temporary Data**: Any data needed only for the current navigation flow within a tab
|
||||
|
||||
### When NOT to Use Navigation State
|
||||
|
||||
Avoid using navigation state for:
|
||||
|
||||
- **Persistent Data**: Use NgRx stores or services instead
|
||||
- **Shareable URLs**: Use route parameters or query parameters if the URL should be bookmarkable
|
||||
- **Long-lived State**: Use session storage or NgRx with persistence
|
||||
- **Cross-tab Communication**: Use services with proper state management
|
||||
|
||||
### Best Practices
|
||||
|
||||
#### ✅ Do Use Navigation Context (Tab Metadata)
|
||||
|
||||
```typescript
|
||||
// Good: Clean URLs, automatic cleanup, tab-scoped
|
||||
navState.preserveContext({
|
||||
returnUrl: '/customer-list',
|
||||
searchQuery: 'John Doe',
|
||||
context: 'reward-selection'
|
||||
});
|
||||
|
||||
await router.navigate(['/customer', customerId]);
|
||||
|
||||
// Later (after intermediate navigations):
|
||||
const context = navState.restoreAndClearContext<{ returnUrl: string }>();
|
||||
await router.navigateByUrl(context.returnUrl);
|
||||
```
|
||||
|
||||
#### ❌ Don't Use Query Parameters for Temporary State
|
||||
|
||||
```typescript
|
||||
// Bad: URL pollution, can be overwritten, visible in browser bar
|
||||
await router.navigate(['/customer', customerId], {
|
||||
queryParams: {
|
||||
returnUrl: '/customer-list',
|
||||
searchQuery: 'John Doe'
|
||||
}
|
||||
});
|
||||
// URL becomes: /customer/123?returnUrl=%2Fcustomer-list&searchQuery=John%20Doe
|
||||
```
|
||||
|
||||
#### ❌ Don't Use Router State for Multi-Step Flows
|
||||
|
||||
```typescript
|
||||
// Bad: Lost after intermediate navigations
|
||||
await router.navigate(['/customer/search'], {
|
||||
state: { returnUrl: '/reward/cart' } // Lost after next navigation!
|
||||
});
|
||||
```
|
||||
|
||||
### Integration with TabService
|
||||
|
||||
Navigation context relies on `TabService` for automatic tab scoping:
|
||||
|
||||
```typescript
|
||||
// Context automatically scoped to active tab
|
||||
const tabId = tabService.activatedTabId(); // e.g., 123
|
||||
|
||||
// When you preserve context:
|
||||
navState.preserveContext({ returnUrl: '/cart' });
|
||||
// Stored in: tab[123].metadata['navigation-contexts']['default']
|
||||
|
||||
// When you preserve with custom scope:
|
||||
navState.preserveContext({ step: 2 }, 'wizard-flow');
|
||||
// Stored in: tab[123].metadata['navigation-contexts']['wizard-flow']
|
||||
```
|
||||
|
||||
**Automatic Cleanup:**
|
||||
When the tab closes, all contexts stored in that tab's metadata are automatically removed. No manual cleanup required!
|
||||
|
||||
### Usage Example: Multi-Step Flow
|
||||
|
||||
**Start of Flow (preserving context):**
|
||||
```typescript
|
||||
import { inject } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { NavigationStateService } from '@isa/core/navigation';
|
||||
|
||||
export class CustomerListComponent {
|
||||
private router = inject(Router);
|
||||
private navState = inject(NavigationStateService);
|
||||
|
||||
async viewCustomerDetails(customerId: number) {
|
||||
// Preserve context before navigating
|
||||
this.navState.preserveContext({
|
||||
returnUrl: this.router.url,
|
||||
searchQuery: this.searchForm.value.query
|
||||
});
|
||||
|
||||
await this.router.navigate(['/customer', customerId]);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Intermediate Navigation (context persists):**
|
||||
```typescript
|
||||
export class CustomerDetailsComponent {
|
||||
private router = inject(Router);
|
||||
|
||||
async editAddress(addressId: number) {
|
||||
// Context still preserved through intermediate navigations
|
||||
await this.router.navigate(['/address/edit', addressId]);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**End of Flow (restoring context):**
|
||||
```typescript
|
||||
import { inject } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { NavigationStateService } from '@isa/core/navigation';
|
||||
|
||||
interface CustomerNavigationContext {
|
||||
returnUrl: string;
|
||||
searchQuery?: string;
|
||||
}
|
||||
|
||||
export class AddressEditComponent {
|
||||
private router = inject(Router);
|
||||
private navState = inject(NavigationStateService);
|
||||
|
||||
async complete() {
|
||||
// Restore and clear context
|
||||
const context = this.navState.restoreAndClearContext<CustomerNavigationContext>();
|
||||
|
||||
if (context?.returnUrl) {
|
||||
await this.router.navigateByUrl(context.returnUrl);
|
||||
} else {
|
||||
// Fallback navigation
|
||||
await this.router.navigate(['/customers']);
|
||||
}
|
||||
}
|
||||
|
||||
// For template usage
|
||||
hasReturnUrl(): boolean {
|
||||
return this.navState.hasPreservedContext();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Template:**
|
||||
```html
|
||||
@if (hasReturnUrl()) {
|
||||
<button (click)="complete()">Zurück</button>
|
||||
}
|
||||
```
|
||||
|
||||
### Advanced: Multiple Concurrent Flows
|
||||
|
||||
Use custom scopes to manage multiple flows in the same tab:
|
||||
|
||||
```typescript
|
||||
export class DashboardComponent {
|
||||
private navState = inject(NavigationStateService);
|
||||
private router = inject(Router);
|
||||
|
||||
async startCustomerFlow() {
|
||||
// Save context for customer flow
|
||||
this.navState.preserveContext(
|
||||
{ returnUrl: '/dashboard', flowType: 'customer' },
|
||||
'customer-flow'
|
||||
);
|
||||
|
||||
await this.router.navigate(['/customer/search']);
|
||||
}
|
||||
|
||||
async startProductFlow() {
|
||||
// Save context for product flow (different scope)
|
||||
this.navState.preserveContext(
|
||||
{ returnUrl: '/dashboard', flowType: 'product' },
|
||||
'product-flow'
|
||||
);
|
||||
|
||||
await this.router.navigate(['/product/search']);
|
||||
}
|
||||
|
||||
async completeCustomerFlow() {
|
||||
// Restore customer flow context
|
||||
const context = this.navState.restoreAndClearContext('customer-flow');
|
||||
if (context?.returnUrl) {
|
||||
await this.router.navigateByUrl(context.returnUrl);
|
||||
}
|
||||
}
|
||||
|
||||
async completeProductFlow() {
|
||||
// Restore product flow context
|
||||
const context = this.navState.restoreAndClearContext('product-flow');
|
||||
if (context?.returnUrl) {
|
||||
await this.router.navigateByUrl(context.returnUrl);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Architecture Decision
|
||||
|
||||
**Why Tab Metadata instead of SessionStorage?**
|
||||
|
||||
Tab metadata provides several advantages over SessionStorage:
|
||||
|
||||
- **Automatic Cleanup**: Contexts are automatically removed when tabs close (no manual cleanup or TTL management needed)
|
||||
- **Better Integration**: Seamlessly integrated with tab lifecycle and TabService
|
||||
- **Tab Isolation**: Impossible to leak contexts between tabs (scoped by tab ID)
|
||||
- **Simpler Mental Model**: Contexts are "owned" by tabs, not stored globally
|
||||
- **Persistence**: Tab metadata persists across page refresh via UserStorage
|
||||
|
||||
**Why not Query Parameters?**
|
||||
|
||||
Query parameters were traditionally used for passing state, but they have significant drawbacks:
|
||||
|
||||
- **URL Pollution**: Makes URLs long, ugly, and non-bookmarkable
|
||||
- **Overwritable**: Intermediate navigations can overwrite query parameters
|
||||
- **Security**: Sensitive data visible in browser URL bar
|
||||
- **User Experience**: Affects URL sharing and bookmarking
|
||||
|
||||
**Why not Router State?**
|
||||
|
||||
Angular's Router state mechanism has limitations:
|
||||
|
||||
- **Lost After Navigation**: State is lost after the immediate navigation (doesn't survive intermediate navigations)
|
||||
- **Not Persistent**: Doesn't survive page refresh
|
||||
- **No Tab Scoping**: Can't isolate state by tab
|
||||
|
||||
**Why Tab Metadata is Better:**
|
||||
|
||||
Navigation context using tab metadata provides:
|
||||
|
||||
- **Survives Intermediate Navigations**: State persists across multiple navigation steps
|
||||
- **Clean URLs**: No visible state in the URL bar
|
||||
- **Reliable**: State survives page refresh (via TabService UserStorage)
|
||||
- **Type-safe**: Full TypeScript support with generics
|
||||
- **Platform-agnostic**: Works with SSR/Angular Universal
|
||||
- **Automatic Cleanup**: No manual cleanup needed when tabs close
|
||||
- **Tab Isolation**: Contexts automatically scoped to tabs
|
||||
|
||||
### Comparison with Other State Solutions
|
||||
|
||||
| Feature | Navigation Context | NgRx Store | Service State | Query Params | Router State |
|
||||
|---------|-------------------|------------|---------------|--------------|--------------|
|
||||
| **Scope** | Tab-scoped flow | Application-wide | Feature-specific | URL-based | Single navigation |
|
||||
| **Persistence** | Until tab closes | Configurable | Component lifetime | URL lifetime | Lost after nav |
|
||||
| **Survives Refresh** | ✅ Yes | ⚠️ Optional | ❌ No | ✅ Yes | ❌ No |
|
||||
| **Survives Intermediate Navs** | ✅ Yes | ✅ Yes | ✅ Yes | ⚠️ Sometimes | ❌ No |
|
||||
| **Automatic Cleanup** | ✅ Yes (tab close) | ❌ Manual | ❌ Manual | N/A | ✅ Yes |
|
||||
| **Tab Isolation** | ✅ Yes | ❌ No | ❌ No | ❌ No | ❌ No |
|
||||
| **Clean URLs** | ✅ Yes | N/A | N/A | ❌ No | ✅ Yes |
|
||||
| **Shareability** | ❌ No | ❌ No | ❌ No | ✅ Yes | ❌ No |
|
||||
| **Type Safety** | ✅ Yes | ✅ Yes | ✅ Yes | ⚠️ Limited | ✅ Yes |
|
||||
| **Use Case** | Multi-step flow | Global state | Feature state | Bookmarkable state | Simple navigation |
|
||||
|
||||
### Best Practices for Navigation Context
|
||||
|
||||
#### ✅ Do
|
||||
|
||||
- **Use for temporary flow context** (return URLs, wizard state, search filters)
|
||||
- **Use custom scopes** for multiple concurrent flows in the same tab
|
||||
- **Always use type safety** with TypeScript generics
|
||||
- **Trust automatic cleanup** - no need to manually clear contexts in `ngOnDestroy`
|
||||
- **Check for null** when restoring contexts (they may not exist)
|
||||
|
||||
#### ❌ Don't
|
||||
|
||||
- **Don't store large objects** - keep contexts lean (URLs, IDs, simple flags)
|
||||
- **Don't use for persistent data** - use NgRx or services for long-lived state
|
||||
- **Don't store sensitive data** - contexts may be visible in browser dev tools
|
||||
- **Don't manually clear in ngOnDestroy** - tab lifecycle handles cleanup automatically
|
||||
- **Don't use for cross-tab communication** - use services or BroadcastChannel
|
||||
|
||||
### Cleanup Behavior
|
||||
|
||||
**Automatic Cleanup (Recommended):**
|
||||
```typescript
|
||||
// ✅ No manual cleanup needed - tab lifecycle handles it!
|
||||
export class CustomerFlowComponent {
|
||||
navState = inject(NavigationStateService);
|
||||
|
||||
async startFlow() {
|
||||
this.navState.preserveContext({ returnUrl: '/home' });
|
||||
// Context automatically cleaned up when tab closes
|
||||
}
|
||||
|
||||
// No ngOnDestroy needed!
|
||||
}
|
||||
```
|
||||
|
||||
**Manual Cleanup (Rarely Needed):**
|
||||
```typescript
|
||||
// Use only if you need to explicitly clear contexts during tab lifecycle
|
||||
export class ComplexFlowComponent {
|
||||
navState = inject(NavigationStateService);
|
||||
|
||||
async cancelFlow() {
|
||||
// Explicitly clear all contexts for this tab
|
||||
const cleared = this.navState.clearScopeContexts();
|
||||
console.log(`Cleared ${cleared} contexts`);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Related Documentation
|
||||
|
||||
- **Full API Reference**: See [libs/core/navigation/README.md](../../libs/core/navigation/README.md) for complete documentation
|
||||
- **Usage Patterns**: Detailed examples and patterns in the library README
|
||||
- **Testing Guide**: Full testing guide included in the library documentation
|
||||
- **Migration Guide**: Instructions for migrating from SessionStorage approach in the library README
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
* [Vitest: Modern Testing Framework](#vitest-modern-testing-framework)
|
||||
* [Overview](#vitest-overview)
|
||||
* [Configuration](#vitest-configuration)
|
||||
* [CI/CD Integration: JUnit and Cobertura Reporting](#cicd-integration-junit-and-cobertura-reporting)
|
||||
* [Core Testing Features](#core-testing-features)
|
||||
* [Mocking in Vitest](#mocking-in-vitest)
|
||||
* [Example Test Structures with Vitest](#example-test-structures-with-vitest)
|
||||
@@ -58,6 +59,8 @@ This document outlines the guidelines and best practices for writing unit tests
|
||||
|
||||
- Test files must end with `.spec.ts`.
|
||||
- **Migration to Vitest**: New libraries should use **Vitest** as the primary test runner. Existing libraries continue to use **Jest** until migrated.
|
||||
- **Current Status (as of 2025-10-22):** 40 libraries use Jest (65.6%), 21 libraries use Vitest (34.4%)
|
||||
- All formal libraries now have test executors configured (migration progressing well)
|
||||
- **Testing Framework Migration**: New tests should use **Angular Testing Utilities** (TestBed, ComponentFixture, etc.). Existing tests using **Spectator** remain until migrated.
|
||||
- Employ **ng-mocks** for mocking complex dependencies like child components.
|
||||
|
||||
@@ -144,6 +147,128 @@ export default defineConfig({
|
||||
});
|
||||
```
|
||||
|
||||
#### CI/CD Integration: JUnit and Cobertura Reporting
|
||||
|
||||
Both Jest and Vitest are configured to generate JUnit XML reports and Cobertura coverage reports for Azure Pipelines integration.
|
||||
|
||||
##### Jest Configuration (Existing Libraries)
|
||||
|
||||
Jest projects inherit JUnit and Cobertura configuration from `jest.preset.js`:
|
||||
|
||||
```javascript
|
||||
// jest.preset.js (workspace root)
|
||||
module.exports = {
|
||||
...nxPreset,
|
||||
coverageReporters: ['text', 'cobertura'],
|
||||
reporters: [
|
||||
'default',
|
||||
[
|
||||
'jest-junit',
|
||||
{
|
||||
outputDirectory: 'testresults',
|
||||
outputName: 'TESTS',
|
||||
uniqueOutputName: 'true',
|
||||
classNameTemplate: '{classname}',
|
||||
titleTemplate: '{title}',
|
||||
ancestorSeparator: ' › ',
|
||||
usePathForSuiteName: true,
|
||||
},
|
||||
],
|
||||
],
|
||||
};
|
||||
```
|
||||
|
||||
**Key Points:**
|
||||
- JUnit XML files are written to `testresults/TESTS-{uuid}.xml`
|
||||
- Cobertura coverage reports are written to `coverage/{projectPath}/cobertura-coverage.xml`
|
||||
- No additional configuration needed in individual Jest projects
|
||||
- Run with coverage: `npx nx test <project> --code-coverage`
|
||||
|
||||
##### Vitest Configuration (New Libraries)
|
||||
|
||||
Vitest projects require explicit JUnit and Cobertura configuration in their `vite.config.mts` files:
|
||||
|
||||
```typescript
|
||||
// libs/{domain}/{library}/vite.config.mts
|
||||
/// <reference types='vitest' />
|
||||
import { defineConfig } from 'vite';
|
||||
import angular from '@analogjs/vite-plugin-angular';
|
||||
import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin';
|
||||
import { nxCopyAssetsPlugin } from '@nx/vite/plugins/nx-copy-assets.plugin';
|
||||
|
||||
export default
|
||||
// @ts-expect-error - Vitest reporter tuple types have complex inference issues, but config works correctly at runtime
|
||||
defineConfig(() => ({
|
||||
root: __dirname,
|
||||
cacheDir: '../../../node_modules/.vite/libs/{domain}/{library}',
|
||||
plugins: [angular(), nxViteTsPaths(), nxCopyAssetsPlugin(['*.md'])],
|
||||
test: {
|
||||
watch: false,
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
include: ['{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
|
||||
setupFiles: ['src/test-setup.ts'],
|
||||
reporters: [
|
||||
'default',
|
||||
['junit', { outputFile: '../../../testresults/junit-{project-name}.xml' }],
|
||||
],
|
||||
coverage: {
|
||||
reportsDirectory: '../../../coverage/libs/{domain}/{library}',
|
||||
provider: 'v8' as const,
|
||||
reporter: ['text', 'cobertura'],
|
||||
},
|
||||
},
|
||||
}));
|
||||
```
|
||||
|
||||
**Key Points:**
|
||||
- **JUnit Reporter**: Built into Vitest, no additional package needed
|
||||
- **Output Path**: Adjust relative path based on library depth:
|
||||
- 3 levels (`libs/domain/library`): Use `../../../testresults/`
|
||||
- 4 levels (`libs/domain/type/library`): Use `../../../../testresults/`
|
||||
- **Coverage Reporter**: Add `'cobertura'` to the reporter array
|
||||
- **TypeScript Suppression**: Add `// @ts-expect-error` comment before `defineConfig` to suppress type inference warnings
|
||||
- **Run with Coverage**: `npx nx test <project> --coverage.enabled=true`
|
||||
|
||||
##### Azure Pipelines Integration
|
||||
|
||||
Both Jest and Vitest reports are consumed by Azure Pipelines:
|
||||
|
||||
```yaml
|
||||
# azure-pipelines.yml
|
||||
- task: PublishTestResults@2
|
||||
displayName: Publish Test results
|
||||
inputs:
|
||||
testResultsFiles: '**/TESTS-*.xml'
|
||||
searchFolder: $(Build.StagingDirectory)/testresults
|
||||
testResultsFormat: JUnit
|
||||
mergeTestResults: false
|
||||
failTaskOnFailedTests: true
|
||||
|
||||
- task: PublishCodeCoverageResults@2
|
||||
displayName: Publish code Coverage
|
||||
inputs:
|
||||
codeCoverageTool: Cobertura
|
||||
summaryFileLocation: $(Build.StagingDirectory)/coverage/**/cobertura-coverage.xml
|
||||
```
|
||||
|
||||
**Verification:**
|
||||
- JUnit XML files: `testresults/junit-*.xml` or `testresults/TESTS-*.xml`
|
||||
- Cobertura XML files: `coverage/libs/{path}/cobertura-coverage.xml`
|
||||
|
||||
##### New Library Checklist
|
||||
|
||||
When creating a new Vitest-based library, ensure:
|
||||
|
||||
1. ✅ `reporters` array includes both `'default'` and JUnit configuration
|
||||
2. ✅ JUnit `outputFile` uses correct relative path depth
|
||||
3. ✅ Coverage `reporter` array includes `'cobertura'`
|
||||
4. ✅ Add `// @ts-expect-error` comment before `defineConfig()` if TypeScript errors appear
|
||||
5. ✅ Verify report generation: Run `npx nx test <project> --coverage.enabled=true --skip-cache`
|
||||
6. ✅ Check files exist:
|
||||
- `testresults/junit-{project-name}.xml`
|
||||
- `coverage/libs/{path}/cobertura-coverage.xml`
|
||||
|
||||
#### Core Testing Features
|
||||
|
||||
Vitest provides similar APIs to Jest with enhanced performance:
|
||||
|
||||
384
docs/library-reference.md
Normal file
384
docs/library-reference.md
Normal file
@@ -0,0 +1,384 @@
|
||||
# Library Reference Guide
|
||||
|
||||
> **Last Updated:** 2025-10-22
|
||||
> **Angular Version:** 20.1.2
|
||||
> **Nx Version:** 21.3.2
|
||||
> **Total Libraries:** 61
|
||||
|
||||
All 61 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.
|
||||
|
||||
---
|
||||
|
||||
## Availability Domain (1 library)
|
||||
|
||||
### `@isa/availability/data-access`
|
||||
A comprehensive product availability service for Angular applications supporting multiple order types and delivery methods across retail operations.
|
||||
|
||||
**Location:** `libs/availability/data-access/`
|
||||
|
||||
---
|
||||
|
||||
## Catalogue Domain (1 library)
|
||||
|
||||
### `@isa/catalogue/data-access`
|
||||
A comprehensive product catalogue search and availability service for Angular applications, providing catalog item search, loyalty program integration, and specialized availability validation for download and delivery order types.
|
||||
|
||||
**Location:** `libs/catalogue/data-access/`
|
||||
|
||||
---
|
||||
|
||||
## Checkout Domain (6 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.
|
||||
|
||||
**Location:** `libs/checkout/data-access/`
|
||||
|
||||
### `@isa/checkout/feature/reward-order-confirmation`
|
||||
This library was generated with [Nx](https://nx.dev).
|
||||
|
||||
**Location:** `libs/checkout/feature/reward-order-confirmation/`
|
||||
|
||||
### `@isa/checkout/feature/reward-shopping-cart`
|
||||
A comprehensive reward shopping cart feature for Angular applications supporting loyalty points redemption workflow across retail operations.
|
||||
|
||||
**Location:** `libs/checkout/feature/reward-shopping-cart/`
|
||||
|
||||
### `@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.
|
||||
|
||||
**Location:** `libs/checkout/shared/product-info/`
|
||||
|
||||
### `@isa/checkout/feature/reward-catalog`
|
||||
A comprehensive loyalty rewards catalog feature for Angular applications supporting reward item browsing, selection, and checkout for customers with bonus cards.
|
||||
|
||||
**Location:** `libs/checkout/feature/reward-catalog/`
|
||||
|
||||
### `@isa/checkout/shared/reward-selection-dialog`
|
||||
Angular library for managing reward selection in shopping cart context. Allows users to toggle between regular purchase and reward redemption using bonus points.
|
||||
|
||||
**Location:** `libs/checkout/shared/reward-selection-dialog/`
|
||||
|
||||
---
|
||||
|
||||
## Common Libraries (3 libraries)
|
||||
|
||||
### `@isa/common/data-access`
|
||||
A foundational data access library providing core utilities, error handling, RxJS operators, response models, and advanced batching infrastructure for Angular applications.
|
||||
|
||||
**Location:** `libs/common/data-access/`
|
||||
|
||||
### `@isa/common/decorators`
|
||||
A comprehensive collection of TypeScript decorators for enhancing method behavior in Angular applications. This library provides decorators for validation, caching, debouncing, rate limiting, and more.
|
||||
|
||||
**Location:** `libs/common/decorators/`
|
||||
|
||||
### `@isa/common/print`
|
||||
A comprehensive print management library for Angular applications providing printer discovery, selection, and unified print operations across label and office printers.
|
||||
|
||||
**Location:** `libs/common/print/`
|
||||
|
||||
---
|
||||
|
||||
## Core Libraries (5 libraries)
|
||||
|
||||
### `@isa/core/config`
|
||||
A lightweight, type-safe configuration management system for Angular applications with runtime validation and nested object access.
|
||||
|
||||
**Location:** `libs/core/config/`
|
||||
|
||||
### `@isa/core/logging`
|
||||
A structured, high-performance logging library for Angular applications with hierarchical context support and flexible sink architecture.
|
||||
|
||||
**Location:** `libs/core/logging/`
|
||||
|
||||
### `@isa/core/navigation`
|
||||
A reusable Angular library providing **context preservation** for multi-step navigation flows with automatic tab-scoped storage.
|
||||
|
||||
**Location:** `libs/core/navigation/`
|
||||
|
||||
### `@isa/core/storage`
|
||||
A powerful, type-safe storage library for Angular applications built on top of NgRx Signals. This library provides seamless integration between NgRx Signal Stores and various storage backends including localStorage, sessionStorage, IndexedDB, and server-side user state.
|
||||
|
||||
**Location:** `libs/core/storage/`
|
||||
|
||||
### `@isa/core/tabs`
|
||||
A sophisticated tab management system for Angular applications providing browser-like navigation with intelligent history management, persistence, and configurable pruning strategies.
|
||||
|
||||
**Location:** `libs/core/tabs/`
|
||||
|
||||
---
|
||||
|
||||
## CRM Domain (1 library)
|
||||
|
||||
### `@isa/crm/data-access`
|
||||
A comprehensive Customer Relationship Management (CRM) data access library for Angular applications providing customer, shipping address, payer, and bonus card management with reactive data loading using Angular resources.
|
||||
|
||||
**Location:** `libs/crm/data-access/`
|
||||
|
||||
---
|
||||
|
||||
## Icons (1 library)
|
||||
|
||||
### `@isa/icons`
|
||||
This library was generated with [Nx](https://nx.dev).
|
||||
|
||||
**Location:** `libs/icons/`
|
||||
|
||||
---
|
||||
|
||||
## OMS Domain (9 libraries)
|
||||
|
||||
### `@isa/oms/data-access`
|
||||
A comprehensive Order Management System (OMS) data access library for Angular applications providing return processing, receipt management, order creation, and print capabilities.
|
||||
|
||||
**Location:** `libs/oms/data-access/`
|
||||
|
||||
### `@isa/oms/feature/return-details`
|
||||
A comprehensive Angular feature library for displaying receipt details and managing product returns in the Order Management System (OMS). Provides an interactive interface for viewing receipt information, selecting items for return, configuring return quantities and product categories, and initiating return processes.
|
||||
|
||||
**Location:** `libs/oms/feature/return-details/`
|
||||
|
||||
### `@isa/oms/feature/return-process`
|
||||
A comprehensive Angular feature library for managing product returns with dynamic question flows, validation, and backend integration. Part of the Order Management System (OMS) domain.
|
||||
|
||||
**Location:** `libs/oms/feature/return-process/`
|
||||
|
||||
### `@isa/oms/feature/return-search`
|
||||
A comprehensive return search feature library for Angular applications, providing intelligent receipt search, filtering, and navigation capabilities for the Order Management System (OMS).
|
||||
|
||||
**Location:** `libs/oms/feature/return-search/`
|
||||
|
||||
### `@isa/oms/feature/return-summary`
|
||||
A comprehensive Angular feature library for displaying and confirming return process summaries in the Order Management System (OMS). This library provides a review interface where users can inspect all items being returned, verify return details, and complete the return process with receipt printing.
|
||||
|
||||
**Location:** `libs/oms/feature/return-summary/`
|
||||
|
||||
### `@isa/oms/shared/product-info`
|
||||
A reusable Angular component library for displaying product information in a standardized, visually consistent format across Order Management System (OMS) workflows.
|
||||
|
||||
**Location:** `libs/oms/shared/product-info/`
|
||||
|
||||
### `@isa/oms/utils/translation`
|
||||
A lightweight translation utility library for OMS receipt types providing human-readable German translations through both service-based and pipe-based interfaces.
|
||||
|
||||
**Location:** `libs/oms/utils/translation/`
|
||||
|
||||
### `@isa/oms/feature/return-review`
|
||||
A comprehensive Angular feature library for reviewing completed return processes in the Order Management System (OMS). Provides a confirmation interface for successful returns with task review capabilities and receipt printing functionality.
|
||||
|
||||
**Location:** `libs/oms/feature/return-review/`
|
||||
|
||||
### `@isa/oms/shared/task-list`
|
||||
A specialized Angular component library for displaying and managing return receipt item tasks in the OMS (Order Management System) domain.
|
||||
|
||||
**Location:** `libs/oms/shared/task-list/`
|
||||
|
||||
---
|
||||
|
||||
## Remission Domain (8 libraries)
|
||||
|
||||
### `@isa/remission/feature/remission-list`
|
||||
Feature module providing the main remission list view with filtering, searching, item selection, and remitting capabilities for department ("Abteilung") and mandatory ("Pflicht") return workflows.
|
||||
|
||||
**Location:** `libs/remission/feature/remission-list/`
|
||||
|
||||
### `@isa/remission/data-access`
|
||||
A comprehensive remission (returns) management system for Angular applications supporting mandatory returns (Pflichtremission) and department overflow returns (Abteilungsremission) in retail inventory operations.
|
||||
|
||||
**Location:** `libs/remission/data-access/`
|
||||
|
||||
### `@isa/remission/feature/remission-return-receipt-details`
|
||||
Feature component for displaying detailed view of a return receipt ("Warenbegleitschein") with items, actions, and completion workflows.
|
||||
|
||||
**Location:** `libs/remission/feature/remission-return-receipt-details/`
|
||||
|
||||
### `@isa/remission/feature/remission-return-receipt-list`
|
||||
Feature component providing a comprehensive list view of all return receipts with filtering, sorting, and action capabilities.
|
||||
|
||||
**Location:** `libs/remission/feature/remission-return-receipt-list/`
|
||||
|
||||
### `@isa/remission/shared/return-receipt-actions`
|
||||
Angular standalone components for managing return receipt actions including deletion, continuation, and completion workflows in the remission process.
|
||||
|
||||
**Location:** `libs/remission/shared/return-receipt-actions/`
|
||||
|
||||
### `@isa/remission/shared/product`
|
||||
A collection of Angular standalone components for displaying product information in remission workflows, including product details, stock information, and shelf metadata.
|
||||
|
||||
**Location:** `libs/remission/shared/product/`
|
||||
|
||||
### `@isa/remission/shared/search-item-to-remit-dialog`
|
||||
Angular dialog component for searching and adding items to remission lists that are not on the mandatory return list (Pflichtremission).
|
||||
|
||||
**Location:** `libs/remission/shared/search-item-to-remit-dialog/`
|
||||
|
||||
### `@isa/remission/shared/remission-start-dialog`
|
||||
Angular dialog component for initiating remission processes with two-step workflow: creating return receipts and assigning package numbers.
|
||||
|
||||
**Location:** `libs/remission/shared/remission-start-dialog/`
|
||||
|
||||
---
|
||||
|
||||
## Shared Component Libraries (7 libraries)
|
||||
|
||||
### `@isa/shared/address`
|
||||
Comprehensive Angular components for displaying addresses in both multi-line and inline formats with automatic country name resolution and intelligent formatting.
|
||||
|
||||
**Location:** `libs/shared/address/`
|
||||
|
||||
### `@isa/shared/filter`
|
||||
A powerful and flexible filtering library for Angular applications that provides a complete solution for implementing filters, search functionality, and sorting capabilities.
|
||||
|
||||
**Location:** `libs/shared/filter/`
|
||||
|
||||
### `@isa/shared/product-format`
|
||||
Angular components for displaying product format information with icons and formatted text, supporting various media types like hardcover, paperback, audio, and digital formats.
|
||||
|
||||
**Location:** `libs/shared/product-format/`
|
||||
|
||||
### `@isa/shared/product-image`
|
||||
A lightweight Angular library providing a directive and service for displaying product images from a CDN with dynamic sizing and fallback support.
|
||||
|
||||
**Location:** `libs/shared/product-image/`
|
||||
|
||||
### `@isa/shared/product-router-link`
|
||||
An Angular library providing a customizable directive for creating product navigation links based on EAN codes with flexible URL generation strategies.
|
||||
|
||||
**Location:** `libs/shared/product-router-link/`
|
||||
|
||||
### `@isa/shared/quantity-control`
|
||||
An accessible, feature-rich Angular quantity selector component with dropdown presets and manual input mode.
|
||||
|
||||
**Location:** `libs/shared/quantity-control/`
|
||||
|
||||
### `@isa/shared/scanner`
|
||||
## Overview
|
||||
|
||||
**Location:** `libs/shared/scanner/`
|
||||
|
||||
---
|
||||
|
||||
## UI Component Libraries (16 libraries)
|
||||
|
||||
### `@isa/ui/label`
|
||||
A flexible label component for displaying tags and notices with configurable priority levels across Angular applications.
|
||||
|
||||
**Location:** `libs/ui/label/`
|
||||
|
||||
### `@isa/ui/bullet-list`
|
||||
A lightweight bullet list component system for Angular applications supporting customizable icons and hierarchical content presentation.
|
||||
|
||||
**Location:** `libs/ui/bullet-list/`
|
||||
|
||||
### `@isa/ui/buttons`
|
||||
A comprehensive button component library for Angular applications providing five specialized button components with consistent styling, loading states, and accessibility features.
|
||||
|
||||
**Location:** `libs/ui/buttons/`
|
||||
|
||||
### `@isa/ui/datepicker`
|
||||
A comprehensive date range picker component library for Angular applications with calendar and month/year selection views, form integration, and robust validation.
|
||||
|
||||
**Location:** `libs/ui/datepicker/`
|
||||
|
||||
### `@isa/ui/dialog`
|
||||
A comprehensive dialog system for Angular applications built on Angular CDK Dialog with preset components for common use cases.
|
||||
|
||||
**Location:** `libs/ui/dialog/`
|
||||
|
||||
### `@isa/ui/empty-state`
|
||||
A standalone Angular component library providing consistent empty state displays for various scenarios (no results, no articles, all done, select action). Part of the ISA Design System.
|
||||
|
||||
**Location:** `libs/ui/empty-state/`
|
||||
|
||||
### `@isa/ui/expandable`
|
||||
A set of Angular directives for creating expandable/collapsible content sections with proper accessibility support.
|
||||
|
||||
**Location:** `libs/ui/expandable/`
|
||||
|
||||
### `@isa/ui/input-controls`
|
||||
A comprehensive collection of form input components and directives for Angular applications supporting reactive forms, template-driven forms, and accessibility features.
|
||||
|
||||
**Location:** `libs/ui/input-controls/`
|
||||
|
||||
### `@isa/ui/item-rows`
|
||||
A collection of reusable row components for displaying structured data with consistent layouts across Angular applications.
|
||||
|
||||
**Location:** `libs/ui/item-rows/`
|
||||
|
||||
### `@isa/ui/layout`
|
||||
This library provides utilities and directives for responsive design in Angular applications.
|
||||
|
||||
**Location:** `libs/ui/layout/`
|
||||
|
||||
### `@isa/ui/menu`
|
||||
A lightweight Angular component library providing accessible menu components built on Angular CDK Menu. Part of the ISA Design System.
|
||||
|
||||
**Location:** `libs/ui/menu/`
|
||||
|
||||
### `@isa/ui/progress-bar`
|
||||
A lightweight Angular progress bar component supporting both determinate and indeterminate modes.
|
||||
|
||||
**Location:** `libs/ui/progress-bar/`
|
||||
|
||||
### `@isa/ui/search-bar`
|
||||
A feature-rich Angular search bar component with integrated clear functionality and customizable appearance modes.
|
||||
|
||||
**Location:** `libs/ui/search-bar/`
|
||||
|
||||
### `@isa/ui/skeleton-loader`
|
||||
A lightweight Angular structural directive and component for displaying skeleton loading states during asynchronous operations.
|
||||
|
||||
**Location:** `libs/ui/skeleton-loader/`
|
||||
|
||||
### `@isa/ui/toolbar`
|
||||
A flexible toolbar container component for Angular applications with configurable sizing and content projection.
|
||||
|
||||
**Location:** `libs/ui/toolbar/`
|
||||
|
||||
### `@isa/ui/tooltip`
|
||||
A flexible tooltip library for Angular applications, built with Angular CDK overlays.
|
||||
|
||||
**Location:** `libs/ui/tooltip/`
|
||||
|
||||
---
|
||||
|
||||
## Utility Libraries (3 libraries)
|
||||
|
||||
### `@isa/utils/ean-validation`
|
||||
Lightweight Angular utility library for validating EAN (European Article Number) barcodes with reactive forms integration and standalone validation functions.
|
||||
|
||||
**Location:** `libs/utils/ean-validation/`
|
||||
|
||||
### `@isa/utils/scroll-position`
|
||||
## Overview
|
||||
|
||||
**Location:** `libs/utils/scroll-position/`
|
||||
|
||||
### `@isa/utils/z-safe-parse`
|
||||
A lightweight Zod utility library for safe parsing with automatic fallback to original values on validation failures.
|
||||
|
||||
**Location:** `libs/utils/z-safe-parse/`
|
||||
|
||||
---
|
||||
|
||||
## How to Use This Guide
|
||||
|
||||
1. **Quick Lookup**: Use this guide to find the purpose of any library in the monorepo
|
||||
2. **Detailed Documentation**: Always use the `docs-researcher` subagent to read the full README.md for implementation details
|
||||
3. **Path Resolution**: Use the location information to navigate to the library source code
|
||||
4. **Architecture Understanding**: Use `npx nx graph --filter=[library-name]` to visualize dependencies
|
||||
|
||||
---
|
||||
|
||||
## Maintenance Notes
|
||||
|
||||
This file should be updated when:
|
||||
- New libraries are added to the monorepo
|
||||
- Libraries are renamed or moved
|
||||
- Library purposes significantly change
|
||||
- Angular or Nx versions are upgraded
|
||||
|
||||
**Automation:** This file is auto-generated using `npm run docs:generate`. Run this command after adding or modifying libraries to keep the documentation up-to-date.
|
||||
@@ -103,6 +103,41 @@
|
||||
- **[Storybook](https://storybook.js.org/)**
|
||||
- Isolated component development and living documentation environment.
|
||||
|
||||
## Core Libraries
|
||||
|
||||
### Navigation State Management
|
||||
|
||||
- **`@isa/core/navigation`**
|
||||
- Type-safe navigation state management through Angular Router state
|
||||
- Provides clean abstraction for passing temporary state between routes
|
||||
- Eliminates URL pollution from query parameters
|
||||
- Platform-agnostic using Angular's Location service
|
||||
- Full documentation: [libs/core/navigation/README.md](../libs/core/navigation/README.md)
|
||||
|
||||
### Logging
|
||||
|
||||
- **`@isa/core/logging`**
|
||||
- Centralized logging service for application-wide logging
|
||||
- Provides contextual information for debugging
|
||||
|
||||
### Storage
|
||||
|
||||
- **`@isa/core/storage`**
|
||||
- Storage providers for state persistence
|
||||
- Session and local storage abstractions
|
||||
|
||||
### Tabs
|
||||
|
||||
- **`@isa/core/tabs`**
|
||||
- Tab management and navigation history tracking
|
||||
- Persistent tab state across sessions
|
||||
|
||||
### Configuration
|
||||
|
||||
- **`@isa/core/config`**
|
||||
- Application configuration management
|
||||
- Environment-specific settings
|
||||
|
||||
## Domain Libraries
|
||||
|
||||
### Customer Relationship Management (CRM)
|
||||
|
||||
@@ -37,6 +37,7 @@ export { Gender } from './models/gender';
|
||||
export { DateRangeDTO } from './models/date-range-dto';
|
||||
export { PaymentType } from './models/payment-type';
|
||||
export { PaymentStatus } from './models/payment-status';
|
||||
export { LoyaltyDTO } from './models/loyalty-dto';
|
||||
export { QueryTokenDTO } from './models/query-token-dto';
|
||||
export { ListResponseArgsOfOrderItemListItemDTO } from './models/list-response-args-of-order-item-list-item-dto';
|
||||
export { ResponseArgsOfIEnumerableOfOrderItemListItemDTO } from './models/response-args-of-ienumerable-of-order-item-list-item-dto';
|
||||
@@ -130,7 +131,6 @@ export { Price } from './models/price';
|
||||
export { ShippingTarget } from './models/shipping-target';
|
||||
export { EntityDTOBaseOfShopItemDTOAndIShopItem } from './models/entity-dtobase-of-shop-item-dtoand-ishop-item';
|
||||
export { CampaignDTO } from './models/campaign-dto';
|
||||
export { LoyaltyDTO } from './models/loyalty-dto';
|
||||
export { EntityDTOBaseOfOrderItemDTOAndIOrderItem } from './models/entity-dtobase-of-order-item-dtoand-iorder-item';
|
||||
export { EntityDTOContainerOfSupplierDTO } from './models/entity-dtocontainer-of-supplier-dto';
|
||||
export { SupplierDTO } from './models/supplier-dto';
|
||||
@@ -207,6 +207,8 @@ export { EntityDTOContainerOfOrderItemSubsetTransitionDTO } from './models/entit
|
||||
export { OrderItemSubsetTransitionDTO } from './models/order-item-subset-transition-dto';
|
||||
export { EntityDTOBaseOfOrderItemSubsetTransitionDTOAndIOrderItemStatusTransition } from './models/entity-dtobase-of-order-item-subset-transition-dtoand-iorder-item-status-transition';
|
||||
export { EntityDTOBaseOfOrderItemSubsetTaskDTOAndIOrderItemStatusTask } from './models/entity-dtobase-of-order-item-subset-task-dtoand-iorder-item-status-task';
|
||||
export { LoyaltyCollectValues } from './models/loyalty-collect-values';
|
||||
export { LoyaltyCollectType } from './models/loyalty-collect-type';
|
||||
export { ResponseArgsOfBoolean } from './models/response-args-of-boolean';
|
||||
export { ResponseArgsOfOrderItemDTO } from './models/response-args-of-order-item-dto';
|
||||
export { ResponseArgsOfIEnumerableOfOrderItemDTO } from './models/response-args-of-ienumerable-of-order-item-dto';
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/* tslint:disable */
|
||||
import { EntityDTOBaseOfDisplayOrderItemDTOAndIOrderItem } from './entity-dtobase-of-display-order-item-dtoand-iorder-item';
|
||||
import { LoyaltyDTO } from './loyalty-dto';
|
||||
import { DisplayOrderDTO } from './display-order-dto';
|
||||
import { PriceDTO } from './price-dto';
|
||||
import { ProductDTO } from './product-dto';
|
||||
@@ -23,6 +24,11 @@ export interface DisplayOrderItemDTO extends EntityDTOBaseOfDisplayOrderItemDTOA
|
||||
*/
|
||||
features?: {[key: string]: string};
|
||||
|
||||
/**
|
||||
* Loylty
|
||||
*/
|
||||
loyalty?: LoyaltyDTO;
|
||||
|
||||
/**
|
||||
* Bestellung
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
/* tslint:disable */
|
||||
export type LoyaltyCollectType = 0 | 1 | 2;
|
||||
@@ -0,0 +1,18 @@
|
||||
/* tslint:disable */
|
||||
import { LoyaltyCollectType } from './loyalty-collect-type';
|
||||
|
||||
/**
|
||||
* Loyalty collect values
|
||||
*/
|
||||
export interface LoyaltyCollectValues {
|
||||
|
||||
/**
|
||||
* Collect Type
|
||||
*/
|
||||
collectType: LoyaltyCollectType;
|
||||
|
||||
/**
|
||||
* Quantity (optional, default null)
|
||||
*/
|
||||
quantity?: number;
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { EnvironmentChannel } from './environment-channel';
|
||||
import { CRUDA } from './cruda';
|
||||
import { DateRangeDTO } from './date-range-dto';
|
||||
import { Gender } from './gender';
|
||||
import { LoyaltyDTO } from './loyalty-dto';
|
||||
import { OrderType } from './order-type';
|
||||
import { PaymentStatus } from './payment-status';
|
||||
import { PaymentType } from './payment-type';
|
||||
@@ -102,6 +103,11 @@ export interface OrderItemListItemDTO {
|
||||
*/
|
||||
lastName?: string;
|
||||
|
||||
/**
|
||||
* Loylty
|
||||
*/
|
||||
loyalty?: LoyaltyDTO;
|
||||
|
||||
/**
|
||||
* Bestellfiliale
|
||||
*/
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
/* tslint:disable */
|
||||
import { EntityReferenceDTO } from './entity-reference-dto';
|
||||
import { KeyValueDTOOfStringAndString } from './key-value-dtoof-string-and-string';
|
||||
import { CRUDA } from './cruda';
|
||||
import { PriceDTO } from './price-dto';
|
||||
import { LoyaltyDTO } from './loyalty-dto';
|
||||
import { ProductDTO } from './product-dto';
|
||||
import { PromotionDTO } from './promotion-dto';
|
||||
import { QuantityDTO } from './quantity-dto';
|
||||
import { ReceiptListItemDTO } from './receipt-list-item-dto';
|
||||
export interface ReceiptItemListItemDTO extends EntityReferenceDTO{
|
||||
|
||||
/**
|
||||
* Mögliche Aktionen
|
||||
*/
|
||||
actions?: Array<KeyValueDTOOfStringAndString>;
|
||||
buyerComment?: string;
|
||||
|
||||
/**
|
||||
@@ -19,6 +26,11 @@ export interface ReceiptItemListItemDTO extends EntityReferenceDTO{
|
||||
*/
|
||||
discountedPrice?: PriceDTO;
|
||||
|
||||
/**
|
||||
* Zusätzliche Markierungen
|
||||
*/
|
||||
features?: {[key: string]: string};
|
||||
|
||||
/**
|
||||
* PK
|
||||
*/
|
||||
@@ -35,6 +47,11 @@ export interface ReceiptItemListItemDTO extends EntityReferenceDTO{
|
||||
*/
|
||||
lineNumber?: number;
|
||||
|
||||
/**
|
||||
* Loyalty
|
||||
*/
|
||||
loyalty?: LoyaltyDTO;
|
||||
|
||||
/**
|
||||
* Bestellnummer
|
||||
*/
|
||||
|
||||
@@ -21,6 +21,8 @@ import { ResponseArgsOfQuerySettingsDTO } from '../models/response-args-of-query
|
||||
import { ResponseArgsOfIEnumerableOfAutocompleteDTO } from '../models/response-args-of-ienumerable-of-autocomplete-dto';
|
||||
import { AutocompleteTokenDTO } from '../models/autocomplete-token-dto';
|
||||
import { ListResponseArgsOfDBHOrderItemListItemDTO } from '../models/list-response-args-of-dbhorder-item-list-item-dto';
|
||||
import { ResponseArgsOfIEnumerableOfDBHOrderItemListItemDTO } from '../models/response-args-of-ienumerable-of-dbhorder-item-list-item-dto';
|
||||
import { LoyaltyCollectValues } from '../models/loyalty-collect-values';
|
||||
import { ListResponseArgsOfOrderItemListItemDTO } from '../models/list-response-args-of-order-item-list-item-dto';
|
||||
import { ResponseArgsOfIEnumerableOfOrderItemDTO } from '../models/response-args-of-ienumerable-of-order-item-dto';
|
||||
import { OrderItemDTO } from '../models/order-item-dto';
|
||||
@@ -54,6 +56,7 @@ class OrderService extends __BaseService {
|
||||
static readonly OrderKundenbestellungenSettingsPath = '/kundenbestellungen/s/settings';
|
||||
static readonly OrderKundenbestellungenAutocompletePath = '/kundenbestellungen/s/complete';
|
||||
static readonly OrderKundenbestellungenPath = '/kundenbestellungen/s';
|
||||
static readonly OrderLoyaltyCollectPath = '/order/{orderId}/orderitem/{orderItemId}/orderitemsubset/{orderItemSubsetId}/loyaltycollect';
|
||||
static readonly OrderQueryOrderItemPath = '/order/item/s';
|
||||
static readonly OrderQueryOrderItemAutocompletePath = '/order/item/s/complete';
|
||||
static readonly OrderGetOrderItemPath = '/order/orderitem/{orderItemId}';
|
||||
@@ -636,6 +639,63 @@ class OrderService extends __BaseService {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ausgabe order Storno von Prämienbestellposten
|
||||
* Falls die Menge/Stückzahl kleiner der ursprünglichen Menge/Stückzahl ist, wird eine neue Bestellpostenteilmenge erzeugt.
|
||||
* @param params The `OrderService.OrderLoyaltyCollectParams` containing the following parameters:
|
||||
*
|
||||
* - `orderItemSubsetId`: PK Bestellpostenteilmenge
|
||||
*
|
||||
* - `orderItemId`: PK Bestellposten
|
||||
*
|
||||
* - `orderId`: PK Bestellung
|
||||
*
|
||||
* - `data`: Daten zur Änderung des Bearbeitungsstatus
|
||||
*/
|
||||
OrderLoyaltyCollectResponse(params: OrderService.OrderLoyaltyCollectParams): __Observable<__StrictHttpResponse<ResponseArgsOfIEnumerableOfDBHOrderItemListItemDTO>> {
|
||||
let __params = this.newParams();
|
||||
let __headers = new HttpHeaders();
|
||||
let __body: any = null;
|
||||
|
||||
|
||||
|
||||
__body = params.data;
|
||||
let req = new HttpRequest<any>(
|
||||
'POST',
|
||||
this.rootUrl + `/order/${encodeURIComponent(String(params.orderId))}/orderitem/${encodeURIComponent(String(params.orderItemId))}/orderitemsubset/${encodeURIComponent(String(params.orderItemSubsetId))}/loyaltycollect`,
|
||||
__body,
|
||||
{
|
||||
headers: __headers,
|
||||
params: __params,
|
||||
responseType: 'json'
|
||||
});
|
||||
|
||||
return this.http.request<any>(req).pipe(
|
||||
__filter(_r => _r instanceof HttpResponse),
|
||||
__map((_r) => {
|
||||
return _r as __StrictHttpResponse<ResponseArgsOfIEnumerableOfDBHOrderItemListItemDTO>;
|
||||
})
|
||||
);
|
||||
}
|
||||
/**
|
||||
* Ausgabe order Storno von Prämienbestellposten
|
||||
* Falls die Menge/Stückzahl kleiner der ursprünglichen Menge/Stückzahl ist, wird eine neue Bestellpostenteilmenge erzeugt.
|
||||
* @param params The `OrderService.OrderLoyaltyCollectParams` containing the following parameters:
|
||||
*
|
||||
* - `orderItemSubsetId`: PK Bestellpostenteilmenge
|
||||
*
|
||||
* - `orderItemId`: PK Bestellposten
|
||||
*
|
||||
* - `orderId`: PK Bestellung
|
||||
*
|
||||
* - `data`: Daten zur Änderung des Bearbeitungsstatus
|
||||
*/
|
||||
OrderLoyaltyCollect(params: OrderService.OrderLoyaltyCollectParams): __Observable<ResponseArgsOfIEnumerableOfDBHOrderItemListItemDTO> {
|
||||
return this.OrderLoyaltyCollectResponse(params).pipe(
|
||||
__map(_r => _r.body as ResponseArgsOfIEnumerableOfDBHOrderItemListItemDTO)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Suche nach Bestellposten
|
||||
* @param queryToken Suchkriterien
|
||||
@@ -1671,6 +1731,32 @@ module OrderService {
|
||||
buyerNumber?: null | string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parameters for OrderLoyaltyCollect
|
||||
*/
|
||||
export interface OrderLoyaltyCollectParams {
|
||||
|
||||
/**
|
||||
* PK Bestellpostenteilmenge
|
||||
*/
|
||||
orderItemSubsetId: number;
|
||||
|
||||
/**
|
||||
* PK Bestellposten
|
||||
*/
|
||||
orderItemId: number;
|
||||
|
||||
/**
|
||||
* PK Bestellung
|
||||
*/
|
||||
orderId: number;
|
||||
|
||||
/**
|
||||
* Daten zur Änderung des Bearbeitungsstatus
|
||||
*/
|
||||
data: LoyaltyCollectValues;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parameters for OrderUpdateOrderItem
|
||||
*/
|
||||
|
||||
@@ -236,6 +236,7 @@ class ReceiptService extends __BaseService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Aufgabe auf erledigt setzen
|
||||
* @param taskId undefined
|
||||
*/
|
||||
ReceiptReceiptItemTaskCompletedResponse(taskId: number): __Observable<__StrictHttpResponse<ResponseArgsOfReceiptItemTaskListItemDTO>> {
|
||||
@@ -261,6 +262,7 @@ class ReceiptService extends __BaseService {
|
||||
);
|
||||
}
|
||||
/**
|
||||
* Aufgabe auf erledigt setzen
|
||||
* @param taskId undefined
|
||||
*/
|
||||
ReceiptReceiptItemTaskCompleted(taskId: number): __Observable<ResponseArgsOfReceiptItemTaskListItemDTO> {
|
||||
|
||||
8399
graph.json
Normal file
8399
graph.json
Normal file
File diff suppressed because it is too large
Load Diff
980
libs/availability/data-access/README.md
Normal file
980
libs/availability/data-access/README.md
Normal file
@@ -0,0 +1,980 @@
|
||||
# @isa/availability/data-access
|
||||
|
||||
A comprehensive product availability service for Angular applications supporting multiple order types and delivery methods across retail operations.
|
||||
|
||||
## Overview
|
||||
|
||||
The Availability Data Access library provides a unified interface for checking product availability across six different order types: in-store pickup (Rücklage), customer pickup (Abholung), standard shipping (Versand), digital shipping (DIG-Versand), B2B shipping (B2B-Versand), and digital downloads (Download). It integrates with the generated availability API client and provides intelligent routing, validation, and transformation of availability data.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Features](#features)
|
||||
- [Quick Start](#quick-start)
|
||||
- [Core Concepts](#core-concepts)
|
||||
- [API Reference](#api-reference)
|
||||
- [Usage Examples](#usage-examples)
|
||||
- [Order Types](#order-types)
|
||||
- [Validation and Business Rules](#validation-and-business-rules)
|
||||
- [Error Handling](#error-handling)
|
||||
- [Testing](#testing)
|
||||
- [Architecture Notes](#architecture-notes)
|
||||
|
||||
## Features
|
||||
|
||||
- **Six order type support** - InStore, Pickup, Delivery, DIG-Versand, B2B-Versand, Download
|
||||
- **Intelligent routing** - Automatic endpoint selection based on order type
|
||||
- **Zod validation** - Runtime schema validation for all parameters
|
||||
- **Request cancellation** - AbortSignal support for all operations
|
||||
- **Batch and single-item APIs** - Flexible interfaces for different use cases
|
||||
- **Preferred availability selection** - Automatic selection of preferred suppliers
|
||||
- **Business rule enforcement** - Download validation, B2B logistician override
|
||||
- **Type-safe transformations** - Adapter pattern for API request/response mapping
|
||||
- **Comprehensive logging** - Integration with @isa/core/logging for debugging
|
||||
- **Stock integration** - Direct stock service integration for in-store availability
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Import and Inject
|
||||
|
||||
```typescript
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { AvailabilityService } from '@isa/availability/data-access';
|
||||
|
||||
@Component({
|
||||
selector: 'app-product-detail',
|
||||
template: '...'
|
||||
})
|
||||
export class ProductDetailComponent {
|
||||
#availabilityService = inject(AvailabilityService);
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Check Availability for Multiple Items
|
||||
|
||||
```typescript
|
||||
async checkAvailability(): Promise<void> {
|
||||
const availabilities = await this.#availabilityService.getAvailabilities({
|
||||
orderType: 'Versand',
|
||||
items: [
|
||||
{ itemId: 123, ean: '1234567890123', quantity: 2 },
|
||||
{ itemId: 456, ean: '9876543210987', quantity: 1 }
|
||||
]
|
||||
});
|
||||
|
||||
// Result: { '123': Availability, '456': Availability }
|
||||
const item123Availability = availabilities['123'];
|
||||
console.log(`Item 123 status: ${item123Availability.status}`);
|
||||
console.log(`Item 123 quantity: ${item123Availability.qty}`);
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Check Availability for Single Item
|
||||
|
||||
```typescript
|
||||
async checkSingleItem(): Promise<void> {
|
||||
const availability = await this.#availabilityService.getAvailability({
|
||||
orderType: 'Versand',
|
||||
item: { itemId: 123, ean: '1234567890123', quantity: 1 }
|
||||
});
|
||||
|
||||
if (availability) {
|
||||
console.log(`Available: ${availability.qty} units`);
|
||||
console.log(`Price: ${availability.price?.value?.value}`);
|
||||
} else {
|
||||
console.log('Item not available');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Request Cancellation
|
||||
|
||||
```typescript
|
||||
async checkWithCancellation(): Promise<void> {
|
||||
const abortController = new AbortController();
|
||||
|
||||
// Cancel after 5 seconds
|
||||
setTimeout(() => abortController.abort(), 5000);
|
||||
|
||||
try {
|
||||
const availabilities = await this.#availabilityService.getAvailabilities(
|
||||
{
|
||||
orderType: 'Versand',
|
||||
items: [{ itemId: 123, ean: '1234567890123', quantity: 1 }]
|
||||
},
|
||||
abortController.signal
|
||||
);
|
||||
} catch (error) {
|
||||
console.log('Request cancelled or failed');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Core Concepts
|
||||
|
||||
### Order Types
|
||||
|
||||
The library supports six distinct order types, each with specific requirements and behavior:
|
||||
|
||||
#### 1. InStore (Rücklage)
|
||||
- **Purpose**: Branch-based in-store availability for customer reservation
|
||||
- **Endpoint**: Stock service (not availability API)
|
||||
- **Required**: branchId, itemsIds array
|
||||
- **Special handling**: Uses RemissionStockService to fetch real-time stock quantities
|
||||
|
||||
#### 2. Pickup (Abholung)
|
||||
- **Purpose**: Customer pickup at branch location
|
||||
- **Endpoint**: Store availability API
|
||||
- **Required**: branchId, items array with itemId, ean, quantity
|
||||
- **Special handling**: Uses store endpoint with branch context
|
||||
|
||||
#### 3. Delivery (Versand)
|
||||
- **Purpose**: Standard shipping to customer address
|
||||
- **Endpoint**: Shipping availability API
|
||||
- **Required**: items array with itemId, ean, quantity
|
||||
- **Special handling**: Excludes supplier/logistician fields to prevent automatic orderType change
|
||||
|
||||
#### 4. DIG-Versand
|
||||
- **Purpose**: Digital shipping for webshop customers
|
||||
- **Endpoint**: Shipping availability API
|
||||
- **Required**: items array with itemId, ean, quantity
|
||||
- **Special handling**: Standard transformation, includes supplier/logistician
|
||||
|
||||
#### 5. B2B-Versand
|
||||
- **Purpose**: Business-to-business shipping with specific logistician
|
||||
- **Endpoint**: Store availability API
|
||||
- **Required**: items array with itemId, ean, quantity
|
||||
- **Special handling**:
|
||||
- Automatically fetches default branch (no branchId parameter needed)
|
||||
- Fetches logistician '2470' and overrides response logisticianId
|
||||
- Uses store endpoint (not shipping)
|
||||
|
||||
#### 6. Download
|
||||
- **Purpose**: Digital product downloads
|
||||
- **Endpoint**: Shipping availability API
|
||||
- **Required**: items array with itemId, ean (no quantity)
|
||||
- **Special handling**:
|
||||
- Quantity forced to 1
|
||||
- Validates download availability (supplier 16 with 0 stock = unavailable)
|
||||
- Validates status codes against whitelist
|
||||
|
||||
### Availability Response Structure
|
||||
|
||||
```typescript
|
||||
interface Availability {
|
||||
itemId: number; // Product item ID
|
||||
status: AvailabilityType; // Availability status code (see below)
|
||||
qty: number; // Available quantity
|
||||
ssc?: string; // Shipping service code
|
||||
sscText?: string; // Shipping service description
|
||||
supplierId?: number; // Supplier ID
|
||||
supplier?: string; // Supplier name
|
||||
logisticianId?: number; // Logistician ID
|
||||
logistician?: string; // Logistician name
|
||||
price?: Price; // Current price with VAT
|
||||
priceMaintained?: boolean; // Price maintenance flag
|
||||
at?: string; // Estimated delivery date (ISO format)
|
||||
altAt?: string; // Alternative delivery date
|
||||
requestStatusCode?: string; // Request status from API
|
||||
preferred?: number; // Preferred availability flag (1 = preferred)
|
||||
}
|
||||
```
|
||||
|
||||
### Availability Type Codes
|
||||
|
||||
```typescript
|
||||
const AvailabilityType = {
|
||||
NotSet: 0, // Not determined
|
||||
NotAvailable: 1, // Not available
|
||||
PrebookAtBuyer: 2, // Pre-order at buyer
|
||||
PrebookAtRetailer: 32, // Pre-order at retailer
|
||||
PrebookAtSupplier: 256, // Pre-order at supplier
|
||||
TemporaryNotAvailable: 512, // Temporarily unavailable
|
||||
Available: 1024, // Available for immediate delivery
|
||||
OnDemand: 2048, // Available on demand
|
||||
AtProductionDate: 4096, // Available at production date
|
||||
Discontinued: 8192, // Discontinued product
|
||||
EndOfLife: 16384, // End of life product
|
||||
};
|
||||
```
|
||||
|
||||
### Validation with Zod
|
||||
|
||||
All input parameters are validated using Zod schemas before processing:
|
||||
|
||||
```typescript
|
||||
// Example: Delivery availability params
|
||||
const params = {
|
||||
orderType: 'Versand',
|
||||
items: [
|
||||
{
|
||||
itemId: '123', // Coerced to number
|
||||
ean: '1234567890123',
|
||||
quantity: '2', // Coerced to number
|
||||
price: { ... }
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
// Validation happens automatically
|
||||
const result = await service.getAvailabilities(params);
|
||||
// Throws ZodError if validation fails
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### AvailabilityService
|
||||
|
||||
Main service for checking product availability across order types.
|
||||
|
||||
#### `getAvailabilities(params, abortSignal?): Promise<{ [itemId: string]: Availability }>`
|
||||
|
||||
Checks availability for multiple items based on order type.
|
||||
|
||||
**Parameters:**
|
||||
- `params: GetAvailabilityInputParams` - Availability parameters (automatically validated)
|
||||
- `abortSignal?: AbortSignal` - Optional abort signal for request cancellation
|
||||
|
||||
**Returns:** Promise resolving to dictionary mapping itemId to Availability
|
||||
|
||||
**Throws:**
|
||||
- `ZodError` - If params validation fails
|
||||
- `ResponseArgsError` - If API returns an error
|
||||
- `Error` - If default branch/logistician not found (B2B only)
|
||||
|
||||
**Example:**
|
||||
```typescript
|
||||
const availabilities = await service.getAvailabilities({
|
||||
orderType: 'Versand',
|
||||
items: [
|
||||
{ itemId: 123, ean: '1234567890', quantity: 2 },
|
||||
{ itemId: 456, ean: '0987654321', quantity: 1 }
|
||||
]
|
||||
});
|
||||
|
||||
// Result: { '123': Availability, '456': Availability }
|
||||
```
|
||||
|
||||
#### `getAvailability(params, abortSignal?): Promise<Availability | undefined>`
|
||||
|
||||
Checks availability for a single item.
|
||||
|
||||
**Parameters:**
|
||||
- `params: GetSingleItemAvailabilityInputParams` - Single item parameters (automatically validated)
|
||||
- `abortSignal?: AbortSignal` - Optional abort signal for request cancellation
|
||||
|
||||
**Returns:** Promise resolving to Availability, or undefined if not available
|
||||
|
||||
**Throws:**
|
||||
- `ZodError` - If params validation fails
|
||||
- `ResponseArgsError` - If API returns an error
|
||||
|
||||
**Example:**
|
||||
```typescript
|
||||
const availability = await service.getAvailability({
|
||||
orderType: 'Versand',
|
||||
item: { itemId: 123, ean: '1234567890', quantity: 1 }
|
||||
});
|
||||
|
||||
if (availability) {
|
||||
console.log(`Available: ${availability.qty} units`);
|
||||
}
|
||||
```
|
||||
|
||||
### AvailabilityFacade
|
||||
|
||||
Pass-through facade for AvailabilityService.
|
||||
|
||||
**Note**: This facade is currently under architectural review. It provides no additional value over direct service injection and may be removed in a future refactoring. Consider injecting `AvailabilityService` directly.
|
||||
|
||||
```typescript
|
||||
// Current pattern (via facade)
|
||||
#availabilityFacade = inject(AvailabilityFacade);
|
||||
|
||||
// Recommended pattern (direct service)
|
||||
#availabilityService = inject(AvailabilityService);
|
||||
```
|
||||
|
||||
### Helper Functions
|
||||
|
||||
#### `isDownloadAvailable(availability): boolean`
|
||||
|
||||
Validates if a download item is available based on business rules.
|
||||
|
||||
**Business Rules:**
|
||||
- Supplier ID 16 with 0 stock = unavailable
|
||||
- Must have valid availability type code (see VALID_DOWNLOAD_STATUS_CODES)
|
||||
|
||||
**Parameters:**
|
||||
- `availability: Availability | null | undefined` - Availability to validate
|
||||
|
||||
**Returns:** true if download is available, false otherwise
|
||||
|
||||
**Example:**
|
||||
```typescript
|
||||
import { isDownloadAvailable } from '@isa/availability/data-access';
|
||||
|
||||
if (isDownloadAvailable(availability)) {
|
||||
console.log('Download ready');
|
||||
}
|
||||
```
|
||||
|
||||
#### `selectPreferredAvailability(availabilities): Availability | undefined`
|
||||
|
||||
Selects the preferred availability from a list (marked with `preferred === 1`).
|
||||
|
||||
**Parameters:**
|
||||
- `availabilities: Availability[]` - List of availability options
|
||||
|
||||
**Returns:** The preferred availability, or undefined if none found
|
||||
|
||||
**Example:**
|
||||
```typescript
|
||||
import { selectPreferredAvailability } from '@isa/availability/data-access';
|
||||
|
||||
const preferred = selectPreferredAvailability(apiResponse);
|
||||
```
|
||||
|
||||
#### `calculateEstimatedDate(availability): string | undefined`
|
||||
|
||||
Calculates the estimated shipping/delivery date based on API response.
|
||||
|
||||
**Business Rule:**
|
||||
- If requestStatusCode === '32', use altAt (alternative date)
|
||||
- Otherwise, use at (standard date)
|
||||
|
||||
**Parameters:**
|
||||
- `availability: Availability | null | undefined` - Availability data
|
||||
|
||||
**Returns:** The estimated date string (ISO format), or undefined
|
||||
|
||||
**Example:**
|
||||
```typescript
|
||||
import { calculateEstimatedDate } from '@isa/availability/data-access';
|
||||
|
||||
const estimatedDate = calculateEstimatedDate(availability);
|
||||
console.log(`Delivery expected: ${estimatedDate}`);
|
||||
```
|
||||
|
||||
#### `hasValidPrice(availability): boolean`
|
||||
|
||||
Type guard to check if an availability has a valid price.
|
||||
|
||||
**Parameters:**
|
||||
- `availability: Availability | null | undefined` - Availability to check
|
||||
|
||||
**Returns:** true if availability has a price with a value > 0
|
||||
|
||||
**Example:**
|
||||
```typescript
|
||||
import { hasValidPrice } from '@isa/availability/data-access';
|
||||
|
||||
if (hasValidPrice(availability)) {
|
||||
// TypeScript narrows type - price is guaranteed to exist
|
||||
console.log(`Price: ${availability.price.value.value}`);
|
||||
}
|
||||
```
|
||||
|
||||
#### `isPriceMaintained(availability): boolean`
|
||||
|
||||
Checks if an availability is price-maintained.
|
||||
|
||||
**Parameters:**
|
||||
- `availability: Availability | null | undefined` - Availability to check
|
||||
|
||||
**Returns:** true if price-maintained flag is set
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Checking In-Store Availability (Rücklage)
|
||||
|
||||
```typescript
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { AvailabilityService } from '@isa/availability/data-access';
|
||||
|
||||
@Component({
|
||||
selector: 'app-in-store-check',
|
||||
template: '...'
|
||||
})
|
||||
export class InStoreCheckComponent {
|
||||
#availabilityService = inject(AvailabilityService);
|
||||
|
||||
async checkInStoreAvailability(branchId: number, itemIds: number[]): Promise<void> {
|
||||
const availabilities = await this.#availabilityService.getAvailabilities({
|
||||
orderType: 'Rücklage',
|
||||
branchId: branchId,
|
||||
itemsIds: itemIds
|
||||
});
|
||||
|
||||
for (const [itemId, availability] of Object.entries(availabilities)) {
|
||||
console.log(`Item ${itemId}: ${availability.qty} in stock`);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Checking Pickup Availability (Abholung)
|
||||
|
||||
```typescript
|
||||
async checkPickupAvailability(branchId: number): Promise<void> {
|
||||
const availabilities = await this.#availabilityService.getAvailabilities({
|
||||
orderType: 'Abholung',
|
||||
branchId: branchId,
|
||||
items: [
|
||||
{ itemId: 123, ean: '1234567890', quantity: 2 },
|
||||
{ itemId: 456, ean: '0987654321', quantity: 1 }
|
||||
]
|
||||
});
|
||||
|
||||
// Check if items are available for pickup
|
||||
for (const [itemId, availability] of Object.entries(availabilities)) {
|
||||
if (availability.status === AvailabilityType.Available) {
|
||||
console.log(`Item ${itemId} ready for pickup at branch ${branchId}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Checking Standard Delivery (Versand)
|
||||
|
||||
```typescript
|
||||
import { AvailabilityType, calculateEstimatedDate } from '@isa/availability/data-access';
|
||||
|
||||
async checkDeliveryAvailability(): Promise<void> {
|
||||
const availabilities = await this.#availabilityService.getAvailabilities({
|
||||
orderType: 'Versand',
|
||||
items: [
|
||||
{
|
||||
itemId: 123,
|
||||
ean: '1234567890',
|
||||
quantity: 1,
|
||||
price: {
|
||||
value: { value: 19.99, currency: 'EUR', currencySymbol: '€' },
|
||||
vat: { value: 3.18, inPercent: 19, label: '19%', vatType: 1 }
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
const item123 = availabilities['123'];
|
||||
if (item123) {
|
||||
const estimatedDate = calculateEstimatedDate(item123);
|
||||
console.log(`Available for delivery: ${item123.qty} units`);
|
||||
console.log(`Estimated delivery: ${estimatedDate}`);
|
||||
console.log(`Supplier: ${item123.supplier} (ID: ${item123.supplierId})`);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Checking B2B Delivery (B2B-Versand)
|
||||
|
||||
```typescript
|
||||
async checkB2BDelivery(): Promise<void> {
|
||||
// No branchId required - automatically uses default branch
|
||||
// Logistician '2470' is automatically fetched and applied
|
||||
const availabilities = await this.#availabilityService.getAvailabilities({
|
||||
orderType: 'B2B-Versand',
|
||||
items: [
|
||||
{ itemId: 123, ean: '1234567890', quantity: 10 }
|
||||
]
|
||||
});
|
||||
|
||||
const item123 = availabilities['123'];
|
||||
if (item123) {
|
||||
console.log(`B2B availability: ${item123.qty} units`);
|
||||
console.log(`Logistician: ${item123.logisticianId} (overridden to 2470)`);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Checking Download Availability
|
||||
|
||||
```typescript
|
||||
import { isDownloadAvailable } from '@isa/availability/data-access';
|
||||
|
||||
async checkDownloadAvailability(): Promise<void> {
|
||||
const availabilities = await this.#availabilityService.getAvailabilities({
|
||||
orderType: 'Download',
|
||||
items: [
|
||||
{ itemId: 123, ean: '1234567890' } // No quantity needed
|
||||
]
|
||||
});
|
||||
|
||||
const item123 = availabilities['123'];
|
||||
if (item123 && isDownloadAvailable(item123)) {
|
||||
console.log('Download ready for immediate delivery');
|
||||
} else {
|
||||
console.log('Download not available');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Single Item with AbortSignal
|
||||
|
||||
```typescript
|
||||
async checkSingleItemWithTimeout(): Promise<void> {
|
||||
const abortController = new AbortController();
|
||||
|
||||
// Set 10 second timeout
|
||||
const timeoutId = setTimeout(() => {
|
||||
abortController.abort();
|
||||
}, 10000);
|
||||
|
||||
try {
|
||||
const availability = await this.#availabilityService.getAvailability(
|
||||
{
|
||||
orderType: 'Versand',
|
||||
item: { itemId: 123, ean: '1234567890', quantity: 1 }
|
||||
},
|
||||
abortController.signal
|
||||
);
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (availability) {
|
||||
console.log(`Item available: ${availability.qty} units`);
|
||||
}
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId);
|
||||
console.error('Request failed or timed out', error);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Handling Multiple Order Types
|
||||
|
||||
```typescript
|
||||
import { OrderType } from '@isa/checkout/data-access';
|
||||
|
||||
async checkMultipleOrderTypes(
|
||||
orderType: OrderType,
|
||||
items: Array<{ itemId: number; ean: string; quantity: number }>
|
||||
): Promise<void> {
|
||||
let params: GetAvailabilityInputParams;
|
||||
|
||||
switch (orderType) {
|
||||
case 'Rücklage':
|
||||
params = {
|
||||
orderType: 'Rücklage',
|
||||
branchId: this.selectedBranchId,
|
||||
itemsIds: items.map(i => i.itemId)
|
||||
};
|
||||
break;
|
||||
case 'Abholung':
|
||||
params = {
|
||||
orderType: 'Abholung',
|
||||
branchId: this.selectedBranchId,
|
||||
items: items
|
||||
};
|
||||
break;
|
||||
case 'Versand':
|
||||
case 'DIG-Versand':
|
||||
params = {
|
||||
orderType: orderType,
|
||||
items: items
|
||||
};
|
||||
break;
|
||||
case 'B2B-Versand':
|
||||
params = {
|
||||
orderType: 'B2B-Versand',
|
||||
items: items
|
||||
};
|
||||
break;
|
||||
case 'Download':
|
||||
params = {
|
||||
orderType: 'Download',
|
||||
items: items.map(i => ({ itemId: i.itemId, ean: i.ean }))
|
||||
};
|
||||
break;
|
||||
}
|
||||
|
||||
const availabilities = await this.#availabilityService.getAvailabilities(params);
|
||||
|
||||
console.log(`${orderType} availability:`, availabilities);
|
||||
}
|
||||
```
|
||||
|
||||
## Order Types
|
||||
|
||||
### Parameter Requirements by Order Type
|
||||
|
||||
| Order Type | Required Parameters | Optional | Notes |
|
||||
|------------|-------------------|----------|-------|
|
||||
| **Rücklage** (InStore) | `orderType`, `itemsIds` | `branchId` | Uses stock service |
|
||||
| **Abholung** (Pickup) | `orderType`, `branchId`, `items` | - | Store endpoint |
|
||||
| **Versand** (Delivery) | `orderType`, `items` | - | Shipping endpoint, excludes supplier/logistician |
|
||||
| **DIG-Versand** | `orderType`, `items` | - | Shipping endpoint |
|
||||
| **B2B-Versand** | `orderType`, `items` | - | Fetches default branch + logistician 2470 |
|
||||
| **Download** | `orderType`, `items` (no quantity) | - | Quantity forced to 1, validation applied |
|
||||
|
||||
### Item Structure by Order Type
|
||||
|
||||
#### InStore (Rücklage)
|
||||
```typescript
|
||||
{
|
||||
orderType: 'Rücklage',
|
||||
branchId?: number, // Optional branch ID
|
||||
itemsIds: number[] // Array of item IDs only
|
||||
}
|
||||
```
|
||||
|
||||
#### Pickup, Delivery, DIG-Versand, B2B-Versand
|
||||
```typescript
|
||||
{
|
||||
orderType: 'Abholung' | 'Versand' | 'DIG-Versand' | 'B2B-Versand',
|
||||
branchId?: number, // Required only for Abholung
|
||||
items: Array<{
|
||||
itemId: number,
|
||||
ean: string,
|
||||
quantity: number,
|
||||
price?: Price // Optional price information
|
||||
}>
|
||||
}
|
||||
```
|
||||
|
||||
#### Download
|
||||
```typescript
|
||||
{
|
||||
orderType: 'Download',
|
||||
items: Array<{
|
||||
itemId: number,
|
||||
ean: string,
|
||||
price?: Price // Optional price information
|
||||
// No quantity field - always 1
|
||||
}>
|
||||
}
|
||||
```
|
||||
|
||||
## Validation and Business Rules
|
||||
|
||||
### Zod Schema Validation
|
||||
|
||||
All parameters are validated using Zod schemas before processing:
|
||||
|
||||
**Type Coercion:**
|
||||
```typescript
|
||||
// String to number coercion
|
||||
{ itemId: '123' } → { itemId: 123 }
|
||||
{ quantity: '2' } → { quantity: 2 }
|
||||
|
||||
// Validation requirements
|
||||
itemId: z.coerce.number().int().positive() // Must be positive integer
|
||||
quantity: z.coerce.number().int().positive().default(1) // Positive with default
|
||||
ean: z.string() // Required string
|
||||
```
|
||||
|
||||
**Minimum Array Lengths:**
|
||||
```typescript
|
||||
items: z.array(ItemSchema).min(1) // At least 1 item required
|
||||
itemsIds: z.array(z.coerce.number()).min(1) // At least 1 ID required
|
||||
```
|
||||
|
||||
### Download Validation Rules
|
||||
|
||||
Downloads have special validation requirements enforced by `isDownloadAvailable()`:
|
||||
|
||||
1. **Supplier 16 with 0 stock = unavailable**
|
||||
```typescript
|
||||
if (availability.supplierId === 16 && availability.qty === 0) {
|
||||
return false; // Not available
|
||||
}
|
||||
```
|
||||
|
||||
2. **Valid status codes for downloads**
|
||||
```typescript
|
||||
const VALID_CODES = [
|
||||
AvailabilityType.PrebookAtBuyer, // 2
|
||||
AvailabilityType.PrebookAtRetailer, // 32
|
||||
AvailabilityType.PrebookAtSupplier, // 256
|
||||
AvailabilityType.Available, // 1024
|
||||
AvailabilityType.OnDemand, // 2048
|
||||
AvailabilityType.AtProductionDate // 4096
|
||||
];
|
||||
```
|
||||
|
||||
### B2B Special Handling
|
||||
|
||||
B2B-Versand has unique requirements:
|
||||
|
||||
1. **Automatic default branch fetching**
|
||||
- No branchId parameter required
|
||||
- Service automatically fetches default branch via `BranchService`
|
||||
- Throws error if default branch has no ID
|
||||
|
||||
2. **Logistician 2470 override**
|
||||
- Automatically fetches logistician '2470'
|
||||
- Overrides all availability responses with this logisticianId
|
||||
- Throws error if logistician 2470 not found
|
||||
|
||||
3. **Store endpoint usage**
|
||||
- Uses store availability endpoint (not shipping)
|
||||
- Similar to Pickup but with automatic branch selection
|
||||
|
||||
### Preferred Availability Selection
|
||||
|
||||
When multiple availability options exist for an item:
|
||||
|
||||
```typescript
|
||||
// API might return multiple availabilities per item
|
||||
// The service automatically selects the preferred one
|
||||
const preferred = availabilities.find(av => av.preferred === 1);
|
||||
```
|
||||
|
||||
Only the preferred availability is included in the result dictionary.
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Error Types
|
||||
|
||||
#### ZodError
|
||||
Thrown when input parameters fail validation:
|
||||
|
||||
```typescript
|
||||
try {
|
||||
await service.getAvailabilities({
|
||||
orderType: 'Versand',
|
||||
items: [] // Empty array - fails min(1) validation
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof ZodError) {
|
||||
console.error('Validation error:', error.errors);
|
||||
// error.errors contains detailed validation failures
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### ResponseArgsError
|
||||
Thrown when the API returns an error:
|
||||
|
||||
```typescript
|
||||
import { ResponseArgsError } from '@isa/common/data-access';
|
||||
|
||||
try {
|
||||
await service.getAvailabilities(params);
|
||||
} catch (error) {
|
||||
if (error instanceof ResponseArgsError) {
|
||||
console.error('API error:', error.message);
|
||||
// Check error.message for details
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Error (Generic)
|
||||
Thrown for business logic failures:
|
||||
|
||||
```typescript
|
||||
try {
|
||||
// B2B-Versand without default branch
|
||||
await service.getAvailabilities({
|
||||
orderType: 'B2B-Versand',
|
||||
items: [{ itemId: 123, ean: '123', quantity: 1 }]
|
||||
});
|
||||
} catch (error) {
|
||||
if (error.message === 'Default branch has no ID') {
|
||||
console.error('Branch configuration error');
|
||||
}
|
||||
if (error.message === 'Logistician 2470 not found') {
|
||||
console.error('Logistician configuration error');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Error Context Logging
|
||||
|
||||
The service automatically logs errors with context:
|
||||
|
||||
```typescript
|
||||
// Logged automatically on error
|
||||
{
|
||||
orderType: 'Versand',
|
||||
itemIds: [123, 456],
|
||||
additional: { /* context-specific data */ }
|
||||
}
|
||||
```
|
||||
|
||||
### Request Cancellation
|
||||
|
||||
Use AbortSignal to cancel in-flight requests:
|
||||
|
||||
```typescript
|
||||
const controller = new AbortController();
|
||||
|
||||
// Start request
|
||||
const promise = service.getAvailabilities(params, controller.signal);
|
||||
|
||||
// Cancel if needed
|
||||
controller.abort();
|
||||
|
||||
try {
|
||||
await promise;
|
||||
} catch (error) {
|
||||
// Handle cancellation or other errors
|
||||
console.log('Request cancelled or failed');
|
||||
}
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
The library uses **Vitest** with **Angular Testing Utilities** for testing.
|
||||
|
||||
### Running Tests
|
||||
|
||||
```bash
|
||||
# Run tests for this library
|
||||
npx nx test availability-data-access --skip-nx-cache
|
||||
|
||||
# Run tests with coverage
|
||||
npx nx test availability-data-access --code-coverage --skip-nx-cache
|
||||
|
||||
# Run tests in watch mode
|
||||
npx nx test availability-data-access --watch
|
||||
```
|
||||
|
||||
### Test Structure
|
||||
|
||||
The library includes comprehensive unit tests covering:
|
||||
|
||||
- **Order type routing** - Validates correct endpoint selection for each order type
|
||||
- **Validation** - Tests Zod schema validation for all parameter types
|
||||
- **Business rules** - Tests download validation, B2B logistician override, etc.
|
||||
- **Error handling** - Tests API errors, validation failures, missing data
|
||||
- **Abort signal support** - Tests request cancellation
|
||||
- **Multiple items** - Tests batch processing
|
||||
- **Preferred selection** - Tests preferred availability selection logic
|
||||
|
||||
### Example Test
|
||||
|
||||
```typescript
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { AvailabilityService } from './availability.service';
|
||||
|
||||
describe('AvailabilityService', () => {
|
||||
let service: AvailabilityService;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
AvailabilityService,
|
||||
// Mock providers...
|
||||
]
|
||||
});
|
||||
service = TestBed.inject(AvailabilityService);
|
||||
});
|
||||
|
||||
it('should fetch standard delivery availability', async () => {
|
||||
const result = await service.getAvailabilities({
|
||||
orderType: 'Versand',
|
||||
items: [{ itemId: 123, ean: '1234567890', quantity: 3 }]
|
||||
});
|
||||
|
||||
expect(result).toHaveProperty('123');
|
||||
expect(result['123'].itemId).toBe(123);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Architecture Notes
|
||||
|
||||
### Current Architecture
|
||||
|
||||
The library follows a layered architecture:
|
||||
|
||||
```
|
||||
Components/Features
|
||||
↓
|
||||
AvailabilityFacade (optional, pass-through)
|
||||
↓
|
||||
AvailabilityService (main business logic)
|
||||
↓
|
||||
├─→ RemissionStockService (InStore)
|
||||
├─→ AvailabilityRequestAdapter (request mapping)
|
||||
├─→ Generated API Client (availability-api)
|
||||
└─→ Helper functions (transformers, validators)
|
||||
```
|
||||
|
||||
### Known Architectural Considerations
|
||||
|
||||
#### 1. Facade Evaluation (Medium Priority)
|
||||
|
||||
The `AvailabilityFacade` is currently under evaluation:
|
||||
|
||||
**Current State:**
|
||||
- Pass-through wrapper with no added value
|
||||
- Just delegates to AvailabilityService
|
||||
- No orchestration logic
|
||||
|
||||
**Recommendation:**
|
||||
- Consider removal if no orchestration is planned
|
||||
- Update components to inject AvailabilityService directly
|
||||
- Keep facade only if future orchestration is planned
|
||||
|
||||
**Impact:** Low risk, reduces one layer of indirection
|
||||
|
||||
#### 2. Order Type Handler Duplication (High Priority)
|
||||
|
||||
The service contains 6 similar handler methods with significant code duplication:
|
||||
|
||||
**Current State:**
|
||||
- ~180 lines of duplicated code
|
||||
- Bug fixes need to be applied to multiple methods
|
||||
|
||||
**Proposed Refactoring:**
|
||||
- Template Method + Strategy pattern
|
||||
- Handler registry with common workflow
|
||||
- Post-processing hooks for special cases
|
||||
|
||||
**Impact:** High value, reduces complexity significantly
|
||||
|
||||
#### 3. Cross-Domain Dependency
|
||||
|
||||
The library depends on `@isa/remission/data-access` for `BranchService`:
|
||||
|
||||
**Current State:**
|
||||
- Direct dependency on remission domain
|
||||
- Availability domain cannot be used without remission domain
|
||||
|
||||
**Proposed Solution:**
|
||||
- Create abstract `DefaultBranchProvider` interface
|
||||
- Inject provider instead of concrete BranchService
|
||||
- Implement at app level for domain independence
|
||||
|
||||
**Impact:** Improves domain boundaries and testability
|
||||
|
||||
### Performance Considerations
|
||||
|
||||
1. **Parallel Requests** - B2B-Versand fetches branch and logistician in parallel
|
||||
2. **Early Validation** - Zod validation fails fast before API calls
|
||||
3. **Preferred Selection** - Efficient filtering with Array.find()
|
||||
4. **Request Cancellation** - AbortSignal support prevents wasted bandwidth
|
||||
|
||||
### Future Enhancements
|
||||
|
||||
Potential improvements identified:
|
||||
|
||||
1. **Caching Layer** - Cache availability responses for short periods
|
||||
2. **Batch Optimization** - Optimize multiple availability checks
|
||||
3. **Retry Logic** - Automatic retry for transient failures
|
||||
4. **Analytics Integration** - Track availability check patterns
|
||||
5. **Schema Simplification** - Reduce single-item schema duplication
|
||||
|
||||
## Dependencies
|
||||
|
||||
### Required Libraries
|
||||
|
||||
- `@angular/core` - Angular framework
|
||||
- `@generated/swagger/availability-api` - Generated API client
|
||||
- `@isa/common/data-access` - Common data access utilities
|
||||
- `@isa/core/logging` - Logging service
|
||||
- `@isa/checkout/data-access` - Supplier and OrderType
|
||||
- `@isa/remission/data-access` - Stock and branch services
|
||||
- `@isa/oms/data-access` - Logistician service
|
||||
- `zod` - Schema validation
|
||||
- `rxjs` - Reactive programming
|
||||
|
||||
### Path Alias
|
||||
|
||||
Import from: `@isa/availability/data-access`
|
||||
|
||||
## License
|
||||
|
||||
Internal ISA Frontend library - not for external distribution.
|
||||
34
libs/availability/data-access/eslint.config.cjs
Normal file
34
libs/availability/data-access/eslint.config.cjs
Normal file
@@ -0,0 +1,34 @@
|
||||
const nx = require('@nx/eslint-plugin');
|
||||
const baseConfig = require('../../../eslint.config.js');
|
||||
|
||||
module.exports = [
|
||||
...baseConfig,
|
||||
...nx.configs['flat/angular'],
|
||||
...nx.configs['flat/angular-template'],
|
||||
{
|
||||
files: ['**/*.ts'],
|
||||
rules: {
|
||||
'@angular-eslint/directive-selector': [
|
||||
'error',
|
||||
{
|
||||
type: 'attribute',
|
||||
prefix: 'availability',
|
||||
style: 'camelCase',
|
||||
},
|
||||
],
|
||||
'@angular-eslint/component-selector': [
|
||||
'error',
|
||||
{
|
||||
type: 'element',
|
||||
prefix: 'availability',
|
||||
style: 'kebab-case',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['**/*.html'],
|
||||
// Override or add rules here
|
||||
rules: {},
|
||||
},
|
||||
];
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user