Compare commits

...

55 Commits

Author SHA1 Message Date
Lorenz Hilpert
0a1f25a1ee 📝 docs(shell): update READMEs for shell and connectivity libraries
- Expand core-connectivity README with usage examples and API docs
- Add TabsCollapsedService documentation to shell-common README
- Update shell-layout README with responsive behavior documentation
- Update library-reference.md with current library descriptions
2025-12-10 20:40:12 +01:00
Lorenz Hilpert
609a7ed6dd ️ feat(shell): add E2E testing and ARIA accessibility attributes
- Add data-what/data-which attributes for E2E test targeting
- Add ARIA roles (tablist, tab, group) for screen readers
- Add aria-label attributes for interactive elements
- Add aria-current for active tab indication
2025-12-10 20:37:23 +01:00
Lorenz Hilpert
5bebd3de4d feat(shell): add logging and JSDoc documentation across shell components
- Add @isa/core/logging to services and components with actions
- Add comprehensive JSDoc documentation with @example blocks
- Fix typo: TabsCollabsedService → TabsCollapsedService
- Add error handling with try/catch for async operations
- Add tap operator for logging in NetworkStatusService
2025-12-10 20:36:58 +01:00
Lorenz Hilpert
b7e69dacf7 Merge branch 'develop' into feature/5087-application-shell
# Conflicts:
#	libs/ui/layout/src/index.ts
2025-12-10 20:10:03 +01:00
Lorenz Hilpert
a8cca9143e Merge branch 'develop' into feature/5087-application-shell 2025-12-10 20:08:43 +01:00
Lorenz Hilpert
16b9761573 💡 docs(shell): add JSDoc documentation for shell components and services
Add comprehensive JSDoc comments to shell layout, navigation, and tabs
components to improve code documentation and IDE support.

- Document TabsCollabsedService with usage examples
- Document ShellLayoutComponent with feature descriptions
- Document ShellNavigationItemComponent with signal and method descriptions
- Document ShellNavigationSubItemComponent with route resolution details
- Document navigations configuration with route type explanations
2025-12-10 20:07:54 +01:00
Lorenz Hilpert
7a86fcf507 feat(shell): add tabs collapsed state service and navigation indicators
Add TabsCollabsedService to manage tabs bar collapsed/expanded state with
proximity-based switching. Implement activity indicators for navigation items
that bubble up from child routes when menu is collapsed.

- Add TabsCollabsedService for centralized tabs state management
- Add indicator support to navigation types and components
- Implement indicator bubbling from children to parent when collapsed
- Update shell-layout with click-outside-to-close for mobile navigation
- Add dynamic route resolution with factory functions and signals
- Update shell-tabs with compact mode and proximity detection
2025-12-10 20:07:35 +01:00
Nino Righi
de3edaa0f9 Merged PR 2077: fix(checkout-data-access, checkout-reward-shopping-cart, checkout-reward-sele...
fix(checkout-data-access, checkout-reward-shopping-cart, checkout-reward-selection-dialog): Show Low Stock message inside Dialog, Adjusted Item Identifyer so that mergedItems inside reward-selection-dialog service works properly, Adjusted Error Message Logic and Quantity Select Logic based on purchasing Options for Abholung

Ref: #5523
2025-12-10 17:12:47 +00:00
Nino Righi
964a6026a0 Merged PR 2076: fix(common-data-access, crm-data-access): Improved Error handling, handling i...
fix(common-data-access, crm-data-access): Improved Error handling, handling invalidProperties errors corretly inside crm customer card area

Refs: #5528, #5529
2025-12-10 17:11:22 +00:00
Nino Righi
83ad5f526e Merged PR 2075: fix(ui-layout, ui-input-controls, shared-filter): Set overlayPositions inside...
fix(ui-layout, ui-input-controls, shared-filter): Set overlayPositions inside filter-menu-button and outsourced the logic

Ref: #5526, #5477
2025-12-10 09:50:15 +00:00
Nino Righi
ccc5285602 Merged PR 2074: fix(remission): Implementation of Abort Remission Logic
fix(remission): Implementation of Abort Remission Logic

Ref: #5489
2025-12-10 09:48:49 +00:00
Nino Righi
7200eaefbf Merged PR 2073: fix(checkout-reward-shopping-cart): Preselect Purchasing Option Branch if Edi...
fix(checkout-reward-shopping-cart): Preselect Purchasing Option Branch if Edit item

Ref: #5516
2025-12-08 16:51:49 +00:00
Lorenz Hilpert
1cc13eebe1 feat(shell): enhance tab routing and responsive navigation
- Add tab route injection utilities (injectTabRoute, injectLabeledTabRoute,
  injectLegacyTabRoute, injectLabeledLegacyTabRoute) for dynamic tab-aware navigation
- Improve TabService with tabsByActivationOrder computed, removeTab returning
  previous tab, and removeAllTabs method
- Implement slide-in/slide-out animations for navigation on tablet viewports
- Update navigation config to use new tab route injection utilities
- Add dynamic date calculation for package inspection filter
- Enhance shell-tabs with close functionality, keyboard navigation, and
  proximity-based compact mode
- Update shell-layout to handle navigation visibility and margin responsively
- Add comprehensive JSDoc documentation to navigation types
- Update README documentation for navigation and tabs libraries
2025-12-08 17:51:10 +01:00
Nino Righi
39e56a275e Merged PR 2072: fix(reward-order-confirmation): Only display one orderDate if other ones are...
fix(reward-order-confirmation): Only display one orderDate if other ones are equal

Ref: #5517
2025-12-08 15:20:19 +00:00
Lorenz Hilpert
44426109bd feat(shell-layout): integrate tabs and responsive element positioning
- Add ShellTabsComponent to the header section
- Use UiElementSizeObserverDirective for dynamic margin calculations
- Apply fixed positioning for header and navigation with proper z-index
- Calculate main content margins based on header/navigation dimensions
2025-12-05 21:05:45 +01:00
Lorenz Hilpert
bb9e9ff90e feat(shell-tabs): scaffold shell-tabs library with tab components
Adds new shell-tabs library with ShellTabsComponent and ShellTabItemComponent
for managing application tabs in the shell header area.
2025-12-05 21:05:31 +01:00
Lorenz Hilpert
e5c7c18c40 feat(ui-layout): add UiElementSizeObserverDirective for reactive element sizing
Adds a directive that uses ResizeObserver to track element dimensions
and exposes them as signals for reactive positioning calculations.
2025-12-05 21:05:20 +01:00
Nino Righi
6c41214d69 Merged PR 2071: fix(crm-customer-bon-redemption): Added Date Pipe to Bon
fix(crm-customer-bon-redemption): Added Date Pipe to Bon

Ref: #5521
2025-12-05 20:04:28 +00:00
Nino Righi
6e55b7b0da Merged PR 2070: fix(shared-filter, reward-catalog): Added Branch Filter Toggle to Reward HSC...
fix(shared-filter, reward-catalog): Added Branch Filter Toggle to Reward HSC View, Adjusted Controls Panel Filter Styling and Layout to fix mobile issues and added spacing to order-by-toolbar

Refs: #5514, #5475
2025-12-05 20:04:07 +00:00
Nino Righi
5711a75188 Merged PR 2068: fix(shared-filter): Adjusted Styling parameters for Height and Scrolling
fix(shared-filter): Adjusted Styling parameters for Height and Scrolling

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

#5373
2025-12-05 10:10:18 +00:00
Lorenz Hilpert
e3c60f14f7 feat(shell-navigation): implement navigation components with collapsible menu
Add modular navigation system with three reusable components:
- NavigationGroupComponent for grouped navigation sections
- NavigationItemComponent for main navigation entries with expand/collapse
- NavigationSubItemComponent for nested child routes

Include navigation types, route configuration with dynamic tabId support,
and integrate navigation into shell-layout sidebar.
2025-12-04 22:15:35 +01:00
Lorenz Hilpert
5fe85282e7 📝 docs(shell): update READMEs and library reference
Update documentation for shell-header, shell-layout, shell-notifications,
and shell-common libraries with API references, E2E attributes, and
accessibility documentation.
2025-12-03 21:18:34 +01:00
Lorenz Hilpert
9a8eac3f9a 🎨 style(icons): reformat icons file and add new icons
Reformat SVG exports for consistent code style.
Add new icons: isaFiliale, isaFilialeLocation, isaArtikel variants.
2025-12-03 21:18:16 +01:00
Lorenz Hilpert
93752efb9d feat(shell-layout): integrate shell-header component
Add ShellHeaderComponent to layout and configure path aliases for new
shell-common and shell-notifications libraries.
2025-12-03 21:17:36 +01:00
Lorenz Hilpert
0c546802fa feat(core-auth): add AuthService for logout functionality
Add AuthService wrapping OAuthService logout with proper logging.
Refactor RoleService to use private class fields (#) convention.
2025-12-03 21:17:15 +01:00
Lorenz Hilpert
3ed3d0b466 ♻️ refactor(shell-header): restructure with modular sub-components
Reorganize header into focused sub-components:
- ShellNavigationToggleComponent: drawer toggle with dynamic icon
- ShellFontSizeSelectorComponent: accessibility font size control
- ShellLogoutButtonComponent: logout with AuthService integration
- ShellNotificationsToggleComponent: notification panel overlay

Add E2E testing attributes and ARIA accessibility support
2025-12-03 21:16:57 +01:00
Lorenz Hilpert
daf79d55a5 feat(shell-notifications): add notification display component
Add feature library for rendering grouped notifications with:
- Grouped notification display with collapsible sections
- Unread/read status indication
- Relative timestamps via date-fns
- Action buttons supporting navigation and callback types
2025-12-03 21:16:33 +01:00
Lorenz Hilpert
062a8044f2 feat(shell-common): add shared shell services library
Add new util library providing state management services for shell components:
- NavigationService: navigation drawer open/closed state
- FontSizeService: application-wide font size with document sync
- NotificationsService: notification state with read status tracking
2025-12-03 21:16:14 +01:00
Nino
7e7721b222 Merge branch 'release/4.5' into develop 2025-12-03 16:00:41 +01:00
Nino
14be1365bd fix(crm-loyalty-cards): Show always Points of first Card 2025-12-03 15:59:08 +01:00
Nino Righi
d5324675ef Merged PR 2067: fix(ui-layout): Ipad Dropdown Scrolling Fix
fix(ui-layout): Ipad Dropdown Scrolling Fix
2025-12-03 14:16:21 +00:00
Lorenz Hilpert
86b0493591 feat(shell-layout): enhance network status banner with animations and tests
- Add slide-in/slide-out CSS animations for banner enter/leave
- Add content fade animation for smooth offline→online transitions
- Refactor to declarative RxJS pattern (pairwise + switchMap + timer)
- Use computed() for derived showBanner state
- Add wifi/wifi-off SVG icons inline
- Add comprehensive unit tests (14 test cases)
- Integrate shell-layout into isa-app root component
- Add proper JSDoc documentation
- Add ARIA accessibility attributes (role, aria-live)
- Export ONLINE_BANNER_DISPLAY_DURATION_MS constant for tests
2025-12-03 15:13:45 +01:00
Lorenz Hilpert
85f1184648 📝 docs: add Claude Code guide documentation 2025-12-03 14:20:21 +01:00
Lorenz Hilpert
803a53253c feat(isa-app): migrate to standalone component architecture
- Replace NgModule bootstrap with bootstrapApplication and ApplicationConfig
- Convert app.module.ts to app.config.ts with provider functions
- Convert routing module to standalone routes array
- Remove domain NgModules (services now providedIn: 'root')
- Remove NgRx application store in favor of signals
- Update icon components and registries for modern patterns
- Update page components for standalone compatibility
2025-12-03 14:20:21 +01:00
Lorenz Hilpert
abcb8e2cb4 ♻️ refactor(shell-layout): restructure component and add network status banner
- Move shell-layout from nested folder to lib root
- Add network-status-banner component for connectivity display
- Remove old component structure and specs
2025-12-03 14:15:50 +01:00
Nino
f10338a48b Merge branch 'release/4.5' into develop 2025-12-02 17:33:50 +01:00
Lorenz Hilpert
598a77b288 feat(core-connectivity): add network status service library
- Scaffold new core-connectivity library with Nx generator
- Migrate NetworkStatusService from isa-app to shared library
- Refactor service to use fromEvent/merge with shareReplay
- Add NetworkStatus type and injectNetworkStatus signal helper
- Update all consumers to use @isa/core/connectivity imports
- Add comprehensive unit tests (9 tests)
- Configure Vitest with JUnit/Cobertura reporters
- Update library-reference.md (74 → 75 libraries)
2025-12-02 17:26:25 +01:00
Nino
aa57d27924 fix(oms-return-details): Label Unknown Fix
Ref: #5513
2025-12-02 17:20:56 +01:00
Nino
6cb9aea7d1 Merge branch 'release/4.5' into develop 2025-12-02 17:18:11 +01:00
Lorenz Hilpert
fdfb54a3a0 Merged PR 2065: ♻️ refactor(core-navigation): remove library and use TabService directly
♻️ refactor(core-navigation): remove library and use TabService directly

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

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

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

Refs #5502
2025-12-02 15:41:18 +00:00
Nino
5f94549539 fix(oms-return-details): Dropdown Label and Select Bullet Styling Adjustments
Ref: #5513
2025-12-02 16:32:57 +01:00
Lorenz Hilpert
e5dd1e312d feat(shell): scaffold shell-layout, shell-header, and shell-navigation libraries
Add new shell domain with three feature libraries:
- shell-layout: Main application layout container
- shell-header: Application header component
- shell-navigation: Navigation component

All libraries configured with:
- Standalone Angular components
- Vitest with JUnit and Cobertura reporters
- Architectural tags (scope:shell, type:feature)
2025-12-02 16:14:35 +01:00
Nino Righi
aee63711e4 Merged PR 2066: fix(ui-layout, input-controls-dropdown, oms-return-details): prevent stale sc...
fix(ui-layout, input-controls-dropdown, oms-return-details): prevent stale scroll events from closing dropdown on open

Delay scroll listener registration using requestAnimationFrame when
activating CloseOnScrollDirective. This prevents stale scroll events
still in the event queue from immediately triggering closeOnScroll
when opening the dropdown after scrolling.

Also adds conditional rendering for product format and publication date
in return-details-order-group-item component.

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

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

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

Key changes:

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

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

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

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

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

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

Ref: #5488
2025-12-02 11:59:35 +00:00
Lorenz Hilpert
db4f30af86 🔧 chore: improve skills cross-references and CLAUDE.md guidance
- Add --amend option with safety rules to commit command
- Add logging skill cross-references to angular-template and html-template
- Fix logging skill name from logging-helper to logging
- Add extended thinking triggers, context monitoring, and code investigation
  rules to CLAUDE.md
2025-12-02 12:57:27 +01:00
Lorenz Hilpert
39b945ae88 📝 docs: add reference documentation for specialist skills
Add migration-patterns.md for test-migration-specialist (Jest to Vitest)
and zod-patterns.md for type-safety-engineer (Zod validation patterns).
2025-12-02 12:57:11 +01:00
Lorenz Hilpert
a2b29c0525 ♻️ refactor: convert architecture-documentation from command to skill
Move architecture documentation generation from a slash command to a
skill with comprehensive reference materials (C4 model, Arc42, ADR templates).
2025-12-02 12:56:56 +01:00
Lorenz Hilpert
2c385210db 🔧 chore: add frontmatter metadata to docs commands
Add allowed-tools, argument-hint, and description frontmatter to
docs-library.md and docs-refresh-reference.md for better discoverability.
2025-12-02 12:56:40 +01:00
Lorenz Hilpert
46999cc04c 🔧 chore: consolidate quality commands into single quality.md
Merge quality-bundle-analyze.md and quality-coverage.md into a unified
quality.md command with subcommand support (bundle, coverage).
2025-12-02 12:56:20 +01:00
Nino Righi
ee9f030a99 Merged PR 2062: fix(isa-app-customer): Clear Navigation State Context if Customer Area gets d...
fix(isa-app-customer): Clear Navigation State Context if Customer Area gets destroyed

Ref: #5512
2025-12-01 11:25:11 +00:00
Lorenz Hilpert
5aded6ff8e 🩹 fix(ui-input-controls): remove top padding from dropdown options when filter present 2025-11-28 18:18:57 +01:00
Nino Righi
7884e1af32 Merged PR 2059: feature(ui-modal): add QR code display for URLs in dialog modals
feature(ui-modal): add QR code display for URLs in dialog modals

Add automatic URL detection and QR code rendering in dialog modals:
- Parse dialog content to extract URLs (http/https)
- Display extracted URLs as QR codes using angularx-qrcode library
- Split content around URL to show text before and after the QR code
- Auto-detect URLs by default, with optional showUrlAsQrCode override
- Add comprehensive unit tests for URL parsing helpers

Ref: #5511
2025-11-27 16:41:51 +00:00
344 changed files with 16668 additions and 9057 deletions

View File

@@ -174,6 +174,42 @@ Example of splitting commits:
## Command Options
- `--no-verify`: Skip running the pre-commit checks (lint, build, generate:docs)
- `--amend`: Amend the previous commit (RESTRICTED - see rules below)
## Amend Rules (CRITICAL)
**ONLY use `--amend` in these specific cases:**
1. **Adding pre-commit hook fixes**: If a pre-commit hook modified files (formatting, linting auto-fixes), you may amend to include those changes.
2. **Before amending, ALWAYS verify:**
```bash
# Check authorship - NEVER amend another developer's commit
git log -1 --format='%an %ae'
# Check not pushed - NEVER amend pushed commits
git status # Should show "Your branch is ahead of..."
```
3. **If either check fails:**
- Create a NEW commit instead
- Never amend commits authored by others
- Never amend commits already pushed to remote
**Example workflow for pre-commit hook changes:**
```bash
# 1. Initial commit triggers pre-commit hook
git commit -m "feat(scope): add feature"
# Hook modifies files...
# 2. Verify safe to amend
git log -1 --format='%an %ae' # Your name/email
git status # "Your branch is ahead..."
# 3. Stage hook changes and amend
git add .
git commit --amend --no-edit
```
## Important Notes

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

@@ -18,6 +18,7 @@ Guide for modern Angular 20+ template patterns: control flow, lazy loading, proj
**Related Skills:** These skills work together when writing Angular templates:
- **[html-template](../html-template/SKILL.md)** - E2E testing attributes (`data-what`, `data-which`) and ARIA accessibility
- **[tailwind](../tailwind/SKILL.md)** - ISA design system styling (colors, typography, spacing, layout)
- **[logging](../logging/SKILL.md)** - MANDATORY logging in all Angular files using `@isa/core/logging`
## Control Flow (Angular 17+)

View File

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

View File

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

View File

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

View File

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

View File

@@ -19,6 +19,7 @@ Use this skill when:
**Works seamlessly with:**
- **[angular-template](../angular-template/SKILL.md)** - Angular template syntax, control flow, and modern patterns
- **[tailwind](../tailwind/SKILL.md)** - ISA design system styling for visual design
- **[logging](../logging/SKILL.md)** - MANDATORY logging in all Angular components using `@isa/core/logging`
## Overview

View File

@@ -1,5 +1,5 @@
---
name: logging-helper
name: logging
description: This skill should be used when working with Angular components, directives, services, pipes, guards, or TypeScript classes. Logging is MANDATORY in all Angular files. Implements @isa/core/logging with logger() factory pattern, appropriate log levels, lazy evaluation for performance, error handling, and avoids console.log and common mistakes.
---

View File

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

View File

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

3
.gitignore vendored
View File

@@ -80,3 +80,6 @@ CLAUDE.md
*.pyc
.vite
reports/
# Local iPad dev setup (proxy)
/local-dev/

View File

@@ -112,6 +112,52 @@ angular-template → html-template → logging → tailwind
Use `/clear` between unrelated tasks to prevent context degradation.
### Long-Running Task Pattern
For complex tasks approaching context limits:
1. Dump progress to a `.md` file (e.g., `progress.md`)
2. Use `/clear` to reset context
3. Resume by reading the progress file
4. Continue from where you left off
### Context Monitoring
- Use `/context` to check current token usage
- Fresh session baseline: ~20k tokens
- Consider `/compact` when approaching 150k tokens
- Prefer `/clear` over `/compact` when starting new topics
## Extended Thinking
Use progressive thinking depth for complex analysis:
| Trigger | Thinking Budget | Use Case |
|---------|----------------|----------|
| `"think"` | ~4k tokens | Basic analysis, simple decisions |
| `"think hard"` | ~10k tokens | Moderate complexity, multi-factor decisions |
| `"think harder"` | ~16k tokens | Deep analysis, architectural decisions |
| `"ultrathink"` | ~32k tokens | Maximum depth, critical planning |
**Examples:**
- "Think about how to structure this component"
- "Think hard about the best approach for state management"
- "Ultrathink about the architecture for this feature"
## Code Investigation (MANDATORY)
**Never speculate about code you haven't read.**
If user references a specific file:
1. **READ the file first** using the Read tool
2. **THEN provide analysis** based on actual contents
3. If file doesn't exist, **say so explicitly**
**Anti-Hallucination Rules:**
- Never describe code you haven't opened
- Never assume file contents based on names
- Never guess API signatures without documentation
- Always verify imports and dependencies exist
## Implementation Decisions
| Task Type | Required Agent | Escalation Path |

View File

@@ -1,19 +1,22 @@
import { NgModule } from '@angular/core';
import { DevScanAdapter } from './dev.scan-adapter';
import { NativeScanAdapter } from './native.scan-adapter';
import { SCAN_ADAPTER } from './tokens';
@NgModule({})
export class ScanAdapterModule {
static forRoot() {
return {
ngModule: ScanAdapterModule,
providers: [
{ provide: SCAN_ADAPTER, useClass: NativeScanAdapter, multi: true },
{ provide: SCAN_ADAPTER, useClass: DevScanAdapter, multi: true },
],
// Use for testing:
// providers: [{ provide: SCAN_ADAPTER, useClass: dev ? DevScanAdapter : NativeScanAdapter, multi: true }],
};
}
}
import { NgModule } from '@angular/core';
import { DevScanAdapter } from './dev.scan-adapter';
import { NativeScanAdapter } from './native.scan-adapter';
import { SCAN_ADAPTER } from './tokens';
/**
* @deprecated Use '@isa/shared/scanner' instead.
*/
@NgModule({})
export class ScanAdapterModule {
static forRoot() {
return {
ngModule: ScanAdapterModule,
providers: [
{ provide: SCAN_ADAPTER, useClass: NativeScanAdapter, multi: true },
{ provide: SCAN_ADAPTER, useClass: DevScanAdapter, multi: true },
],
// Use for testing:
// providers: [{ provide: SCAN_ADAPTER, useClass: dev ? DevScanAdapter : NativeScanAdapter, multi: true }],
};
}
}

View File

@@ -1,20 +1,25 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ScanditOverlayComponent } from './scandit-overlay.component';
import { ScanditScanAdapter } from './scandit.scan-adapter';
import { SCAN_ADAPTER } from '../tokens';
@NgModule({
imports: [CommonModule],
exports: [ScanditOverlayComponent],
declarations: [ScanditOverlayComponent],
})
export class ScanditScanAdapterModule {
static forRoot() {
return {
ngModule: ScanditScanAdapterModule,
providers: [{ provide: SCAN_ADAPTER, useClass: ScanditScanAdapter, multi: true }],
};
}
}
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ScanditOverlayComponent } from './scandit-overlay.component';
import { ScanditScanAdapter } from './scandit.scan-adapter';
import { SCAN_ADAPTER } from '../tokens';
/**
* @deprecated Use @isa/shared/scanner instead.
*/
@NgModule({
imports: [CommonModule],
exports: [ScanditOverlayComponent],
declarations: [ScanditOverlayComponent],
})
export class ScanditScanAdapterModule {
static forRoot() {
return {
ngModule: ScanditScanAdapterModule,
providers: [
{ provide: SCAN_ADAPTER, useClass: ScanditScanAdapter, multi: true },
],
};
}
}

View File

@@ -11,7 +11,7 @@ import { Config } from '@core/config';
import { ComponentPortal } from '@angular/cdk/portal';
import { ScanditOverlayComponent } from './scandit-overlay.component';
import { EnvironmentService } from '@core/environment';
import { injectNetworkStatus$ } from 'apps/isa-app/src/app/services/network-status.service';
import { injectNetworkStatus$ } from '@isa/core/connectivity';
import { toSignal } from '@angular/core/rxjs-interop';
@Injectable()

View File

@@ -1,19 +0,0 @@
import { NgModule } from '@angular/core';
import { DomainAvailabilityModule } from '@domain/availability';
import { DomainCatalogModule } from '@domain/catalog';
import { DomainIsaModule } from '@domain/isa';
import { DomainCheckoutModule } from '@domain/checkout';
import { DomainOmsModule } from '@domain/oms';
import { DomainRemissionModule } from '@domain/remission';
@NgModule({
imports: [
DomainIsaModule.forRoot(),
DomainCatalogModule.forRoot(),
DomainAvailabilityModule.forRoot(),
DomainCheckoutModule.forRoot(),
DomainOmsModule.forRoot(),
DomainRemissionModule.forRoot(),
],
})
export class AppDomainModule {}

View File

@@ -1,34 +0,0 @@
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 {}

View File

@@ -1,40 +0,0 @@
import { HttpInterceptorFn, provideHttpClient, withInterceptors } from '@angular/common/http';
import { NgModule } from '@angular/core';
import { Config } from '@core/config';
import { AvConfiguration } from '@generated/swagger/availability-api';
import { CatConfiguration } from '@generated/swagger/cat-search-api';
import { CheckoutConfiguration } from '@generated/swagger/checkout-api';
import { CrmConfiguration } from '@generated/swagger/crm-api';
import { EisConfiguration } from '@generated/swagger/eis-api';
import { IsaConfiguration } from '@generated/swagger/isa-api';
import { OmsConfiguration } from '@generated/swagger/oms-api';
import { PrintConfiguration } from '@generated/swagger/print-api';
import { RemiConfiguration } from '@generated/swagger/inventory-api';
import { WwsConfiguration } from '@generated/swagger/wws-api';
export function createConfigurationFactory(name: string) {
return function (config: Config): { rootUrl: string } {
return config.get(`@swagger/${name}`);
};
}
const serviceWorkerInterceptor: HttpInterceptorFn = (req, next) => {
return next(req.clone({ setHeaders: { 'ngsw-bypass': 'true' } }));
};
@NgModule({
providers: [
provideHttpClient(withInterceptors([serviceWorkerInterceptor])),
{ provide: AvConfiguration, useFactory: createConfigurationFactory('av'), deps: [Config] },
{ provide: CatConfiguration, useFactory: createConfigurationFactory('cat'), deps: [Config] },
{ provide: CheckoutConfiguration, useFactory: createConfigurationFactory('checkout'), deps: [Config] },
{ provide: CrmConfiguration, useFactory: createConfigurationFactory('crm'), deps: [Config] },
{ provide: EisConfiguration, useFactory: createConfigurationFactory('eis'), deps: [Config] },
{ provide: IsaConfiguration, useFactory: createConfigurationFactory('isa'), deps: [Config] },
{ provide: OmsConfiguration, useFactory: createConfigurationFactory('oms'), deps: [Config] },
{ provide: PrintConfiguration, useFactory: createConfigurationFactory('print'), deps: [Config] },
{ provide: RemiConfiguration, useFactory: createConfigurationFactory('remi'), deps: [Config] },
{ provide: WwsConfiguration, useFactory: createConfigurationFactory('wws'), deps: [Config] },
],
})
export class AppSwaggerModule {}

View File

@@ -1,28 +1,28 @@
@if ($offlineBannerVisible()) {
<div [@fadeInOut] class="bg-brand text-white text-center fixed inset-x-0 top-0 z-tooltip p-4">
<h3 class="font-bold grid grid-flow-col items-center justify-center text-xl gap-4">
<div>
<ng-icon name="matWifiOff"></ng-icon>
</div>
<div>Sie sind offline, keine Verbindung zum Netzwerk.</div>
</h3>
<p>Bereits geladene Ihnalte werden angezeigt, Interaktionen sind aktuell nicht möglich.</p>
</div>
}
@if ($onlineBannerVisible()) {
<div [@fadeInOut] class="bg-green-500 text-white text-center fixed inset-x-0 top-0 z-tooltip p-4">
<h3 class="font-bold grid grid-flow-col items-center justify-center text-xl gap-4">
<div>
<ng-icon name="matWifi"></ng-icon>
</div>
<div>Sie sind wieder online.</div>
</h3>
<button class="fixed top-2 right-4 text-3xl w-12 h-12" type="button" (click)="$onlineBannerVisible.set(false)">
<ng-icon name="matClose"></ng-icon>
</button>
</div>
}
<router-outlet></router-outlet>
<!-- @if ($offlineBannerVisible()) {
<div [@fadeInOut] class="bg-brand text-white text-center fixed inset-x-0 top-0 z-tooltip p-4">
<h3 class="font-bold grid grid-flow-col items-center justify-center text-xl gap-4">
<div>
<ng-icon name="matWifiOff"></ng-icon>
</div>
<div>Sie sind offline, keine Verbindung zum Netzwerk.</div>
</h3>
<p>Bereits geladene Ihnalte werden angezeigt, Interaktionen sind aktuell nicht möglich.</p>
</div>
}
@if ($onlineBannerVisible()) {
<div [@fadeInOut] class="bg-green-500 text-white text-center fixed inset-x-0 top-0 z-tooltip p-4">
<h3 class="font-bold grid grid-flow-col items-center justify-center text-xl gap-4">
<div>
<ng-icon name="matWifi"></ng-icon>
</div>
<div>Sie sind wieder online.</div>
</h3>
<button class="fixed top-2 right-4 text-3xl w-12 h-12" type="button" (click)="$onlineBannerVisible.set(false)">
<ng-icon name="matClose"></ng-icon>
</button>
</div>
}
<router-outlet></router-outlet> -->

View File

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

View File

@@ -1,137 +0,0 @@
import { Spectator, createComponentFactory, SpyObject, createSpyObject } from '@ngneat/spectator';
import { RouterTestingModule } from '@angular/router/testing';
import { AppComponent } from './app.component';
import { Config } from '@core/config';
import { ApplicationService } from '@core/application';
import { of } from 'rxjs';
import { Renderer2 } from '@angular/core';
import { CommonModule } from '@angular/common';
import { SwUpdate } from '@angular/service-worker';
import { NotificationsHub } from '@hub/notifications';
import { UserStateService } from '@generated/swagger/isa-api';
import { UiModalService } from '@ui/modal';
import { AuthService } from '@core/auth';
describe('AppComponent', () => {
let spectator: Spectator<AppComponent>;
let config: SpyObject<Config>;
let renderer: SpyObject<Renderer2>;
let applicationServiceMock: SpyObject<ApplicationService>;
let notificationsHubMock: SpyObject<NotificationsHub>;
let swUpdateMock: SpyObject<SwUpdate>;
const createComponent = createComponentFactory({
component: AppComponent,
imports: [CommonModule, RouterTestingModule],
providers: [],
mocks: [Config, SwUpdate, UserStateService, UiModalService, AuthService],
});
beforeEach(() => {
applicationServiceMock = createSpyObject(ApplicationService);
applicationServiceMock.getSection$.and.returnValue(of('customer'));
applicationServiceMock.getActivatedProcessId$.and.returnValue(of(undefined));
renderer = jasmine.createSpyObj('Renderer2', ['addClass', 'removeClass']);
notificationsHubMock = createSpyObject(NotificationsHub);
notificationsHubMock.notifications$ = of({});
swUpdateMock = createSpyObject(SwUpdate);
spectator = createComponent({
providers: [
{ provide: ApplicationService, useValue: applicationServiceMock },
{
provide: Renderer2,
useValue: renderer,
},
{ provide: NotificationsHub, useValue: notificationsHubMock },
{ provide: SwUpdate, useValue: swUpdateMock },
],
});
config = spectator.inject(Config);
});
it('should create the app', () => {
expect(spectator.component).toBeTruthy();
});
it('should have a router outlet', () => {
expect(spectator.query('router-outlet')).toExist();
});
describe('ngOnInit', () => {
it('should call setTitle', () => {
const spy = spyOn(spectator.component, 'setTitle');
spectator.component.ngOnInit();
expect(spy).toHaveBeenCalled();
});
it('should call logVersion', () => {
const spy = spyOn(spectator.component, 'logVersion');
spectator.component.ngOnInit();
expect(spy).toHaveBeenCalled();
});
});
describe('setTitle', () => {
it('should call Title.setTitle()', () => {
const spyTitleSetTitle = spyOn(spectator.component['_title'], 'setTitle');
config.get.and.returnValue('test');
spectator.component.setTitle();
expect(spyTitleSetTitle).toHaveBeenCalledWith('test');
});
});
describe('logVersion', () => {
it('should call console.log()', () => {
const spyConsoleLog = spyOn(console, 'log');
config.get.and.returnValue('test');
spectator.component.logVersion();
expect(spyConsoleLog).toHaveBeenCalled();
});
});
// --------------------------------------------------------
// Unit Tests Implementation for Angular Version 13.x.x
// describe('updateClient()', () => {
// it('should call checkForUpdate() if SwUpdate.isEnabled is True', () => {
// spyOn(spectator.component, 'checkForUpdate');
// spyOn(spectator.component, 'initialCheckForUpdate');
// (swUpdateMock as any).isEnabled = true;
// spectator.component.updateClient();
// expect(spectator.component.initialCheckForUpdate).toHaveBeenCalled();
// expect(spectator.component.checkForUpdate).toHaveBeenCalled();
// });
// it('should not call checkForUpdate() if SwUpdate.isEnabled is False', () => {
// spyOn(spectator.component, 'checkForUpdate');
// spyOn(spectator.component, 'initialCheckForUpdate');
// (swUpdateMock as any).isEnabled = false;
// spectator.component.updateClient();
// expect(spectator.component.initialCheckForUpdate).not.toHaveBeenCalled();
// expect(spectator.component.checkForUpdate).not.toHaveBeenCalled();
// });
// });
// describe('checkForUpdate', () => {
// it('should call swUpdate.checkForUpdate() and notifications.updateNotification() every second', fakeAsync(() => {
// swUpdateMock.checkForUpdate.and.returnValue(Promise.resolve());
// spectator.component.checkForUpdates = 1000;
// spectator.component.checkForUpdate();
// spectator.detectChanges();
// tick(1100);
// expect(notificationsHubMock.updateNotification).toHaveBeenCalled();
// discardPeriodicTasks();
// }));
// });
// describe('initialCheckForUpdate', () => {
// it('should call swUpdate.checkForUpdate()', () => {
// swUpdateMock.checkForUpdate.and.returnValue(new Promise(undefined));
// spectator.component.initialCheckForUpdate();
// expect(swUpdateMock.checkForUpdate).toHaveBeenCalled();
// });
// });
});

View File

@@ -1,206 +1,205 @@
import {
Component,
effect,
HostListener,
inject,
Inject,
Injector,
OnInit,
Renderer2,
signal,
untracked,
DOCUMENT
} from '@angular/core';
import { Title } from '@angular/platform-browser';
import { SwUpdate } from '@angular/service-worker';
import { ApplicationService } from '@core/application';
import { Config } from '@core/config';
import { NotificationsHub } from '@hub/notifications';
import packageInfo from 'packageJson';
import { asapScheduler, interval, Subscription } from 'rxjs';
import { UserStateService } from '@generated/swagger/isa-api';
import { IsaLogProvider } from './providers';
import { EnvironmentService } from '@core/environment';
import { AuthService, LoginStrategy } from '@core/auth';
import { UiMessageModalComponent, UiModalService } from '@ui/modal';
import { injectOnline$ } from './services/network-status.service';
import { toSignal } from '@angular/core/rxjs-interop';
import { animate, style, transition, trigger } from '@angular/animations';
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.scss'],
animations: [
trigger('fadeInOut', [
transition(':enter', [
// :enter wird ausgelöst, wenn das Element zum DOM hinzugefügt wird
style({ opacity: 0, transform: 'translateY(-100%)' }),
animate('300ms', style({ opacity: 1, transform: 'translateY(0)' })),
]),
transition(':leave', [
// :leave wird ausgelöst, wenn das Element aus dem DOM entfernt wird
animate('300ms', style({ opacity: 0, transform: 'translateY(-100%)' })),
]),
]),
],
standalone: false,
})
export class AppComponent implements OnInit {
readonly injector = inject(Injector);
$online = toSignal(injectOnline$());
$offlineBannerVisible = signal(false);
$onlineBannerVisible = signal(false);
private onlineBannerDismissTimeout: any;
onlineEffects = effect(() => {
const online = this.$online();
const offlineBannerVisible = this.$offlineBannerVisible();
untracked(() => {
this.$offlineBannerVisible.set(!online);
if (!online) {
this.$onlineBannerVisible.set(false);
clearTimeout(this.onlineBannerDismissTimeout);
}
if (offlineBannerVisible && online) {
this.$onlineBannerVisible.set(true);
this.onlineBannerDismissTimeout = setTimeout(() => this.$onlineBannerVisible.set(false), 5000);
}
});
});
private _checkForUpdates: number = this._config.get('checkForUpdates');
get checkForUpdates(): number {
return this._checkForUpdates ?? 60 * 60 * 1000; // default 1 hour
}
// For Unit Testing
set checkForUpdates(time: number) {
this._checkForUpdates = time;
}
subscriptions = new Subscription();
constructor(
private readonly _config: Config,
private readonly _title: Title,
private readonly _appService: ApplicationService,
@Inject(DOCUMENT) private readonly _document: Document,
private readonly _renderer: Renderer2,
private readonly _swUpdate: SwUpdate,
private readonly _notifications: NotificationsHub,
private infoService: UserStateService,
private readonly _environment: EnvironmentService,
private readonly _authService: AuthService,
private readonly _modal: UiModalService,
) {
this.updateClient();
IsaLogProvider.InfoService = this.infoService;
}
ngOnInit() {
this.setTitle();
this.logVersion();
asapScheduler.schedule(() => this.determinePlatform(), 250);
this._appService.getSection$().subscribe(this.sectionChangeHandler.bind(this));
this.setupSilentRefresh();
}
// Setup interval for silent refresh
setupSilentRefresh() {
const silentRefreshInterval = this._config.get('silentRefresh.interval');
if (silentRefreshInterval > 0) {
interval(silentRefreshInterval).subscribe(() => {
if (this._authService.isAuthenticated()) {
this._authService.refresh();
}
});
}
}
setTitle() {
this._title.setTitle(this._config.get('title'));
}
logVersion() {
console.log(
`%c${this._config.get('title')}\r\nVersion: ${packageInfo.version}`,
'font-weight: bold; font-size: 20px;',
);
}
determinePlatform() {
if (this._environment.isNative()) {
this._renderer.addClass(this._document.body, 'tablet-native');
} else if (this._environment.isTablet()) {
this._renderer.addClass(this._document.body, 'tablet-browser');
}
if (this._environment.isTablet()) {
this._renderer.addClass(this._document.body, 'tablet');
}
if (this._environment.isDesktop()) {
this._renderer.addClass(this._document.body, 'desktop');
}
}
sectionChangeHandler(section: string) {
if (section === 'customer') {
this._renderer.removeClass(this._document.body, 'branch');
this._renderer.addClass(this._document.body, 'customer');
} else if (section === 'branch') {
this._renderer.removeClass(this._document.body, 'customer');
this._renderer.addClass(this._document.body, 'branch');
}
}
updateClient() {
if (!this._swUpdate.isEnabled) {
return;
}
this.initialCheckForUpdate();
this.checkForUpdate();
}
checkForUpdate() {
interval(this._checkForUpdates).subscribe(() => {
this._swUpdate.checkForUpdate().then((value) => {
console.log('check for update', value);
if (value) {
this._notifications.updateNotification();
}
});
});
}
initialCheckForUpdate() {
this._swUpdate.checkForUpdate().then((value) => {
console.log('initial check for update', value);
if (value) {
location.reload();
}
});
}
@HostListener('window:visibilitychange', ['$event'])
onVisibilityChange(event: Event) {
// refresh token when app is in background
if (this._document.hidden && this._authService.isAuthenticated()) {
this._authService.refresh();
} else if (!this._authService.isAuthenticated()) {
const strategy = this.injector.get(LoginStrategy);
return strategy.login('Sie sind nicht mehr angemeldet');
}
}
}
// import {
// Component,
// effect,
// HostListener,
// inject,
// Inject,
// Injector,
// OnInit,
// Renderer2,
// signal,
// untracked,
// DOCUMENT
// } from '@angular/core';
// import { Title } from '@angular/platform-browser';
// import { SwUpdate } from '@angular/service-worker';
// import { ApplicationService } from '@core/application';
// import { Config } from '@core/config';
// import { NotificationsHub } from '@hub/notifications';
// import packageInfo from 'packageJson';
// import { asapScheduler, interval, Subscription } from 'rxjs';
// import { UserStateService } from '@generated/swagger/isa-api';
// import { IsaLogProvider } from './providers';
// import { EnvironmentService } from '@core/environment';
// import { AuthService, LoginStrategy } from '@core/auth';
// import { UiMessageModalComponent, UiModalService } from '@ui/modal';
// import { injectNetworkStatus } from '@isa/core/connectivity';
// import { animate, style, transition, trigger } from '@angular/animations';
// @Component({
// selector: 'app-root',
// templateUrl: './app.component.html',
// styleUrls: ['./app.component.scss'],
// animations: [
// trigger('fadeInOut', [
// transition(':enter', [
// // :enter wird ausgelöst, wenn das Element zum DOM hinzugefügt wird
// style({ opacity: 0, transform: 'translateY(-100%)' }),
// animate('300ms', style({ opacity: 1, transform: 'translateY(0)' })),
// ]),
// transition(':leave', [
// // :leave wird ausgelöst, wenn das Element aus dem DOM entfernt wird
// animate('300ms', style({ opacity: 0, transform: 'translateY(-100%)' })),
// ]),
// ]),
// ],
// standalone: false,
// })
// export class AppComponent implements OnInit {
// readonly injector = inject(Injector);
// $networkStatus = injectNetworkStatus();
// $offlineBannerVisible = signal(false);
// $onlineBannerVisible = signal(false);
// private onlineBannerDismissTimeout: any;
// onlineEffects = effect(() => {
// const status = this.$networkStatus();
// const online = status === 'online';
// const offlineBannerVisible = this.$offlineBannerVisible();
// untracked(() => {
// this.$offlineBannerVisible.set(!online);
// if (!online) {
// this.$onlineBannerVisible.set(false);
// clearTimeout(this.onlineBannerDismissTimeout);
// }
// if (offlineBannerVisible && online) {
// this.$onlineBannerVisible.set(true);
// this.onlineBannerDismissTimeout = setTimeout(() => this.$onlineBannerVisible.set(false), 5000);
// }
// });
// });
// private _checkForUpdates: number = this._config.get('checkForUpdates');
// get checkForUpdates(): number {
// return this._checkForUpdates ?? 60 * 60 * 1000; // default 1 hour
// }
// // For Unit Testing
// set checkForUpdates(time: number) {
// this._checkForUpdates = time;
// }
// subscriptions = new Subscription();
// constructor(
// private readonly _config: Config,
// private readonly _title: Title,
// private readonly _appService: ApplicationService,
// @Inject(DOCUMENT) private readonly _document: Document,
// private readonly _renderer: Renderer2,
// private readonly _swUpdate: SwUpdate,
// private readonly _notifications: NotificationsHub,
// private infoService: UserStateService,
// private readonly _environment: EnvironmentService,
// private readonly _authService: AuthService,
// private readonly _modal: UiModalService,
// ) {
// this.updateClient();
// IsaLogProvider.InfoService = this.infoService;
// }
// ngOnInit() {
// this.setTitle();
// this.logVersion();
// asapScheduler.schedule(() => this.determinePlatform(), 250);
// this._appService.getSection$().subscribe(this.sectionChangeHandler.bind(this));
// this.setupSilentRefresh();
// }
// // Setup interval for silent refresh
// setupSilentRefresh() {
// const silentRefreshInterval = this._config.get('silentRefresh.interval');
// if (silentRefreshInterval > 0) {
// interval(silentRefreshInterval).subscribe(() => {
// if (this._authService.isAuthenticated()) {
// this._authService.refresh();
// }
// });
// }
// }
// setTitle() {
// this._title.setTitle(this._config.get('title'));
// }
// logVersion() {
// console.log(
// `%c${this._config.get('title')}\r\nVersion: ${packageInfo.version}`,
// 'font-weight: bold; font-size: 20px;',
// );
// }
// determinePlatform() {
// if (this._environment.isNative()) {
// this._renderer.addClass(this._document.body, 'tablet-native');
// } else if (this._environment.isTablet()) {
// this._renderer.addClass(this._document.body, 'tablet-browser');
// }
// if (this._environment.isTablet()) {
// this._renderer.addClass(this._document.body, 'tablet');
// }
// if (this._environment.isDesktop()) {
// this._renderer.addClass(this._document.body, 'desktop');
// }
// }
// sectionChangeHandler(section: string) {
// if (section === 'customer') {
// this._renderer.removeClass(this._document.body, 'branch');
// this._renderer.addClass(this._document.body, 'customer');
// } else if (section === 'branch') {
// this._renderer.removeClass(this._document.body, 'customer');
// this._renderer.addClass(this._document.body, 'branch');
// }
// }
// updateClient() {
// if (!this._swUpdate.isEnabled) {
// return;
// }
// this.initialCheckForUpdate();
// this.checkForUpdate();
// }
// checkForUpdate() {
// interval(this._checkForUpdates).subscribe(() => {
// this._swUpdate.checkForUpdate().then((value) => {
// console.log('check for update', value);
// if (value) {
// this._notifications.updateNotification();
// }
// });
// });
// }
// initialCheckForUpdate() {
// this._swUpdate.checkForUpdate().then((value) => {
// console.log('initial check for update', value);
// if (value) {
// location.reload();
// }
// });
// }
// @HostListener('window:visibilitychange', ['$event'])
// onVisibilityChange(event: Event) {
// // refresh token when app is in background
// if (this._document.hidden && this._authService.isAuthenticated()) {
// this._authService.refresh();
// } else if (!this._authService.isAuthenticated()) {
// const strategy = this.injector.get(LoginStrategy);
// return strategy.login('Sie sind nicht mehr angemeldet');
// }
// }
// }

View File

@@ -1,51 +1,52 @@
import { version } from '../../../../package.json';
import { IsaTitleStrategy } from '@isa/common/title-management';
import {
HTTP_INTERCEPTORS,
HttpInterceptorFn,
provideHttpClient,
withInterceptors,
withInterceptorsFromDi,
} from '@angular/common/http';
import {
ApplicationConfig,
DEFAULT_CURRENCY_CODE,
ErrorHandler,
importProvidersFrom,
Injector,
LOCALE_ID,
NgModule,
inject,
provideAppInitializer,
provideZoneChangeDetection,
signal,
isDevMode,
} from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { PlatformModule } from '@angular/cdk/platform';
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
import {
provideRouter,
TitleStrategy,
withComponentInputBinding,
} from '@angular/router';
import { ActionReducer, MetaReducer, provideStore } from '@ngrx/store';
import { provideStoreDevtools } from '@ngrx/store-devtools';
import { Config } from '@core/config';
import { AuthModule, AuthService, LoginStrategy } from '@core/auth';
import { CoreCommandModule } from '@core/command';
import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component';
import {
ApplicationService,
ApplicationServiceAdapter,
CoreApplicationModule,
} from '@core/application';
import { AppStoreModule } from './app-store.module';
import { routes } from './app.routes';
import { rootReducer } from './store/root.reducer';
import { RootState } from './store/root.state';
import { ServiceWorkerModule } from '@angular/service-worker';
import { environment } from '../environments/environment';
import { AppSwaggerModule } from './app-swagger.module';
import { AppDomainModule } from './app-domain.module';
import { UiModalModule } from '@ui/modal';
import {
NotificationsHubModule,
NOTIFICATIONS_HUB_OPTIONS,
} from '@hub/notifications';
import { SignalRHubOptions } from '@core/signalr';
import { CoreBreadcrumbModule } from '@core/breadcrumb';
import { provideCoreBreadcrumb } from '@core/breadcrumb';
import { UiCommonModule } from '@ui/common';
import { registerLocaleData } from '@angular/common';
import localeDe from '@angular/common/locales/de';
import localeDeExtra from '@angular/common/locales/extra/de';
import { HttpErrorInterceptor } from './interceptors';
import { CoreLoggerModule, LOG_PROVIDER } from '@core/logger';
import { IsaLogProvider } from './providers';
@@ -56,10 +57,8 @@ import {
ScanditScanAdapterModule,
} from '@adapter/scan';
import * as Commands from './commands';
import { PreviewComponent } from './preview';
import { NativeContainerService } from '@external/native-container';
import { ShellModule } from '@shared/shell';
import { MainComponent } from './main.component';
import { IconModule } from '@shared/components/icon';
import { NgIconsModule } from '@ng-icons/core';
import {
@@ -67,10 +66,9 @@ import {
matWifi,
matWifiOff,
} from '@ng-icons/material-icons/baseline';
import { NetworkStatusService } from './services/network-status.service';
import { NetworkStatusService } from '@isa/core/connectivity';
import { debounceTime, filter, firstValueFrom, switchMap } from 'rxjs';
import { provideMatomo } from 'ngx-matomo-client';
import { withRouter, withRouteData } from 'ngx-matomo-client';
import { provideMatomo, withRouter, withRouteData } from 'ngx-matomo-client';
import {
provideLogging,
withLogLevel,
@@ -87,14 +85,58 @@ import {
import { Store } from '@ngrx/store';
import { OAuthService } from 'angular-oauth2-oidc';
import z from 'zod';
import { provideScrollPositionRestoration } from '@isa/utils/scroll-position';
import { TabNavigationService } from '@isa/core/tabs';
registerLocaleData(localeDe, localeDeExtra);
registerLocaleData(localeDe, 'de', localeDeExtra);
// Domain modules
import { provideDomainCheckout } from '@domain/checkout';
export function _appInitializerFactory(config: Config, injector: Injector) {
// Swagger API configurations
import { AvConfiguration } from '@generated/swagger/availability-api';
import { CatConfiguration } from '@generated/swagger/cat-search-api';
import { CheckoutConfiguration } from '@generated/swagger/checkout-api';
import { CrmConfiguration } from '@generated/swagger/crm-api';
import { EisConfiguration } from '@generated/swagger/eis-api';
import { IsaConfiguration } from '@generated/swagger/isa-api';
import { OmsConfiguration } from '@generated/swagger/oms-api';
import { PrintConfiguration } from '@generated/swagger/print-api';
import { RemiConfiguration } from '@generated/swagger/inventory-api';
import { WwsConfiguration } from '@generated/swagger/wws-api';
import { UiIconModule } from '@ui/icon';
// --- Store Configuration ---
function storeHydrateMetaReducer(
reducer: ActionReducer<RootState>,
): ActionReducer<RootState> {
return function (state, action) {
if (action.type === 'HYDRATE') {
return reducer(action['payload'], action);
}
return reducer(state, action);
};
}
const metaReducers: MetaReducer<RootState>[] = [storeHydrateMetaReducer];
// --- Swagger Configuration ---
const swaggerConfigSchema = z.object({ rootUrl: z.string() });
function createSwaggerConfigFactory(name: string) {
return function () {
return inject(Config).get(`@swagger/${name}`, swaggerConfigSchema);
};
}
const serviceWorkerBypassInterceptor: HttpInterceptorFn = (req, next) => {
return next(req.clone({ setHeaders: { 'ngsw-bypass': 'true' } }));
};
// --- App Initializer ---
function appInitializerFactory(_config: Config, injector: Injector) {
return async () => {
// Get logging service for initialization logging
const logger = loggerFactory(() => ({ service: 'AppInitializer' }));
const statusElement = document.querySelector('#init-status');
const laoderElement = document.querySelector('#init-loader');
@@ -105,7 +147,8 @@ export function _appInitializerFactory(config: Config, injector: Injector) {
let online = false;
const networkStatus = injector.get(NetworkStatusService);
while (!online) {
online = await firstValueFrom(networkStatus.online$);
const status = await firstValueFrom(networkStatus.status$);
online = status === 'online';
if (!online) {
logger.warn('Waiting for network connection');
@@ -160,7 +203,6 @@ export function _appInitializerFactory(config: Config, injector: Injector) {
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') });
@@ -170,7 +212,7 @@ export function _appInitializerFactory(config: Config, injector: Injector) {
reason: state ? 'version mismatch' : 'no stored state',
}));
}
// Subscribe on Store changes and save to user storage
auth.initialized$
.pipe(
filter((initialized) => initialized),
@@ -181,7 +223,6 @@ export function _appInitializerFactory(config: Config, injector: Injector) {
});
logger.info('Application initialization completed');
// Inject tab navigation service to initialize it
injector.get(TabNavigationService).init();
} catch (error) {
logger.error('Application initialization failed', error as Error, () => ({
@@ -222,7 +263,7 @@ export function _appInitializerFactory(config: Config, injector: Injector) {
};
}
export function _notificationsHubOptionsFactory(
function notificationsHubOptionsFactory(
config: Config,
auth: AuthService,
): SignalRHubOptions {
@@ -256,80 +297,151 @@ const USER_SUB_FACTORY = () => {
return signal(validation.data);
};
@NgModule({
declarations: [AppComponent, MainComponent],
bootstrap: [AppComponent],
imports: [
BrowserModule,
BrowserAnimationsModule,
ShellModule.forRoot(),
AppRoutingModule,
AppSwaggerModule,
AppDomainModule,
CoreBreadcrumbModule.forRoot(),
CoreCommandModule.forRoot(Object.values(Commands)),
CoreLoggerModule.forRoot(),
AppStoreModule,
PreviewComponent,
AuthModule.forRoot(),
CoreApplicationModule.forRoot(),
UiModalModule.forRoot(),
UiCommonModule.forRoot(),
NotificationsHubModule.forRoot(),
ServiceWorkerModule.register('ngsw-worker.js', {
enabled: environment.production,
registrationStrategy: 'registerWhenStable:30000',
}),
ScanAdapterModule.forRoot(),
ScanditScanAdapterModule.forRoot(),
PlatformModule,
IconModule.forRoot(),
NgIconsModule.withIcons({ matWifiOff, matClose, matWifi }),
],
export const appConfig: ApplicationConfig = {
providers: [
provideZoneChangeDetection({ eventCoalescing: true }),
provideAnimationsAsync('animations'),
provideRouter(routes, withComponentInputBinding()),
provideHttpClient(
withInterceptorsFromDi(),
withInterceptors([serviceWorkerBypassInterceptor]),
),
provideScrollPositionRestoration(),
// NgRx Store
provideStore(rootReducer, { metaReducers }),
provideCoreBreadcrumb(),
provideDomainCheckout(),
provideStoreDevtools({
name: 'ISA Ngrx Application Store',
connectInZone: true,
}),
// Swagger API configurations
{
provide: AvConfiguration,
useFactory: createSwaggerConfigFactory('av'),
},
{
provide: CatConfiguration,
useFactory: createSwaggerConfigFactory('cat'),
},
{
provide: CheckoutConfiguration,
useFactory: createSwaggerConfigFactory('checkout'),
},
{
provide: CrmConfiguration,
useFactory: createSwaggerConfigFactory('crm'),
},
{
provide: EisConfiguration,
useFactory: createSwaggerConfigFactory('eis'),
},
{
provide: IsaConfiguration,
useFactory: createSwaggerConfigFactory('isa'),
},
{
provide: OmsConfiguration,
useFactory: createSwaggerConfigFactory('oms'),
},
{
provide: PrintConfiguration,
useFactory: createSwaggerConfigFactory('print'),
},
{
provide: RemiConfiguration,
useFactory: createSwaggerConfigFactory('remi'),
},
{
provide: WwsConfiguration,
useFactory: createSwaggerConfigFactory('wws'),
},
// App initializer
provideAppInitializer(() => {
const initializerFn = _appInitializerFactory(
const initializerFn = appInitializerFactory(
inject(Config),
inject(Injector),
);
return initializerFn();
}),
// Notifications hub
{
provide: NOTIFICATIONS_HUB_OPTIONS,
useFactory: _notificationsHubOptionsFactory,
useFactory: notificationsHubOptionsFactory,
deps: [Config, AuthService],
},
// HTTP interceptors
{
provide: HTTP_INTERCEPTORS,
useClass: HttpErrorInterceptor,
multi: true,
},
// Logging
{
provide: LOG_PROVIDER,
useClass: IsaLogProvider,
multi: true,
},
provideLogging(
withLogLevel(isDevMode() ? LogLevel.Debug : LogLevel.Info),
withSink(ConsoleLogSink),
),
// Error handling
{
provide: ErrorHandler,
useClass: IsaErrorHandler,
},
{
provide: ApplicationService,
useClass: ApplicationServiceAdapter,
},
// Locale settings
{ provide: LOCALE_ID, useValue: 'de-DE' },
provideHttpClient(withInterceptorsFromDi()),
{ provide: DEFAULT_CURRENCY_CODE, useValue: 'EUR' },
// Analytics
provideMatomo(
{ trackerUrl: 'https://matomo.paragon-data.net', siteId: '1' },
withRouter(),
withRouteData(),
),
provideLogging(withLogLevel(LogLevel.Debug), withSink(ConsoleLogSink)),
{
provide: DEFAULT_CURRENCY_CODE,
useValue: 'EUR',
},
// User storage
provideUserSubFactory(USER_SUB_FACTORY),
// Title strategy
{ provide: TitleStrategy, useClass: IsaTitleStrategy },
// Import providers from NgModules
importProvidersFrom(
// Core modules
CoreCommandModule.forRoot(Object.values(Commands)),
CoreLoggerModule.forRoot(),
AuthModule.forRoot(),
// UI modules
UiModalModule.forRoot(),
UiCommonModule.forRoot(),
// Hub modules
NotificationsHubModule.forRoot(),
// Service Worker
ServiceWorkerModule.register('ngsw-worker.js', {
enabled: environment.production,
registrationStrategy: 'registerWhenStable:30000',
}),
// Scan adapter
ScanAdapterModule.forRoot(),
ScanditScanAdapterModule.forRoot(),
UiIconModule.forRoot(),
IconModule.forRoot(),
),
],
})
export class AppModule {}
};

View File

View File

@@ -0,0 +1,3 @@
<shell-layout>
<router-outlet />
</shell-layout>

View File

@@ -1,5 +1,4 @@
import { isDevMode, NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { Routes } from '@angular/router';
import {
CanActivateCartGuard,
CanActivateCartWithProcessIdGuard,
@@ -7,23 +6,16 @@ import {
CanActivateCustomerOrdersGuard,
CanActivateCustomerOrdersWithProcessIdGuard,
CanActivateCustomerWithProcessIdGuard,
CanActivateGoodsInGuard,
CanActivateProductGuard,
CanActivateProductWithProcessIdGuard,
CanActivateTaskCalendarGuard,
IsAuthenticatedGuard,
} from './guards';
import { CanActivateAssortmentGuard } from './guards/can-activate-assortment.guard';
import { CanActivatePackageInspectionGuard } from './guards/can-activate-package-inspection.guard';
import { MainComponent } from './main.component';
import { PreviewComponent } from './preview';
import {
BranchSectionResolver,
CustomerSectionResolver,
ProcessIdResolver,
} from './resolvers';
import { TokenLoginComponent, TokenLoginModule } from './token-login';
import { ProcessIdGuard } from './guards/process-id.guard';
import { TokenLoginComponent } from './token-login';
import {
ActivateProcessIdGuard,
ActivateProcessIdWithConfigKeyGuard,
@@ -34,9 +26,8 @@ import {
processResolverFn,
hasTabIdGuard,
} from '@isa/core/tabs';
import { provideScrollPositionRestoration } from '@isa/utils/scroll-position';
const routes: Routes = [
export const routes: Routes = [
{ path: '', redirectTo: 'kunde/dashboard', pathMatch: 'full' },
{
path: 'login',
@@ -51,7 +42,6 @@ const routes: Routes = [
children: [
{
path: 'kunde',
component: MainComponent,
children: [
{
path: 'dashboard',
@@ -74,7 +64,9 @@ const routes: Routes = [
loadChildren: () =>
import('@page/catalog').then((m) => m.PageCatalogModule),
canActivate: [CanActivateProductWithProcessIdGuard],
resolve: { processId: ProcessIdResolver },
resolve: {
processId: ProcessIdResolver,
},
},
{
path: 'order',
@@ -87,7 +79,9 @@ const routes: Routes = [
loadChildren: () =>
import('@page/customer-order').then((m) => m.CustomerOrderModule),
canActivate: [CanActivateCustomerOrdersWithProcessIdGuard],
resolve: { processId: ProcessIdResolver },
resolve: {
processId: ProcessIdResolver,
},
},
{
path: 'customer',
@@ -100,7 +94,9 @@ const routes: Routes = [
loadChildren: () =>
import('@page/customer').then((m) => m.CustomerModule),
canActivate: [CanActivateCustomerWithProcessIdGuard],
resolve: { processId: ProcessIdResolver },
resolve: {
processId: ProcessIdResolver,
},
},
{
path: 'cart',
@@ -113,11 +109,13 @@ const routes: Routes = [
loadChildren: () =>
import('@page/checkout').then((m) => m.PageCheckoutModule),
canActivate: [CanActivateCartWithProcessIdGuard],
resolve: {
processId: ProcessIdResolver,
},
},
{
path: 'pickup-shelf',
canActivate: [ProcessIdGuard],
// NOTE: This is a workaround for the canActivate guard not being called
canActivate: [ActivateProcessIdGuard],
loadChildren: () =>
import('@page/pickup-shelf').then((m) => m.PickupShelfOutModule),
},
@@ -126,6 +124,9 @@ const routes: Routes = [
canActivate: [ActivateProcessIdGuard],
loadChildren: () =>
import('@page/pickup-shelf').then((m) => m.PickupShelfOutModule),
resolve: {
processId: ProcessIdResolver,
},
},
{ path: '**', redirectTo: 'dashboard', pathMatch: 'full' },
],
@@ -133,7 +134,6 @@ const routes: Routes = [
},
{
path: 'filiale',
component: MainComponent,
children: [
{
path: 'task-calendar',
@@ -141,12 +141,11 @@ const routes: Routes = [
import('@page/task-calendar').then(
(m) => m.PageTaskCalendarModule,
),
canActivate: [CanActivateTaskCalendarGuard],
canActivate: [ActivateProcessIdWithConfigKeyGuard('taskCalendar')],
},
{
path: 'pickup-shelf',
canActivate: [ActivateProcessIdWithConfigKeyGuard('pickupShelf')],
// NOTE: This is a workaround for the canActivate guard not being called
loadChildren: () =>
import('@page/pickup-shelf').then((m) => m.PickupShelfInModule),
},
@@ -154,27 +153,23 @@ const routes: Routes = [
path: 'goods/in',
loadChildren: () =>
import('@page/goods-in').then((m) => m.GoodsInModule),
canActivate: [CanActivateGoodsInGuard],
canActivate: [ActivateProcessIdWithConfigKeyGuard('goodsIn')],
},
// {
// path: 'remission',
// loadChildren: () =>
// import('@page/remission').then((m) => m.PageRemissionModule),
// canActivate: [CanActivateRemissionGuard],
// },
{
path: 'package-inspection',
loadChildren: () =>
import('@page/package-inspection').then(
(m) => m.PackageInspectionModule,
),
canActivate: [CanActivatePackageInspectionGuard],
canActivate: [
ActivateProcessIdWithConfigKeyGuard('packageInspection'),
],
},
{
path: 'assortment',
loadChildren: () =>
import('@page/assortment').then((m) => m.AssortmentModule),
canActivate: [CanActivateAssortmentGuard],
canActivate: [ActivateProcessIdWithConfigKeyGuard('assortment')],
},
{ path: '**', redirectTo: 'task-calendar', pathMatch: 'full' },
],
@@ -184,7 +179,6 @@ const routes: Routes = [
},
{
path: ':tabId',
component: MainComponent,
resolve: { process: processResolverFn, tab: tabResolverFn },
canActivate: [IsAuthenticatedGuard, hasTabIdGuard],
children: [
@@ -214,7 +208,6 @@ const routes: Routes = [
},
],
},
{
path: 'return',
loadChildren: () =>
@@ -242,23 +235,3 @@ const routes: Routes = [
],
},
];
if (isDevMode()) {
routes.unshift({
path: 'preview',
component: PreviewComponent,
});
}
@NgModule({
imports: [
RouterModule.forRoot(routes, {
bindToComponentInputs: true,
enableTracing: false,
}),
TokenLoginModule,
],
exports: [RouterModule],
providers: [provideScrollPositionRestoration()],
})
export class AppRoutingModule {}

View File

@@ -0,0 +1,11 @@
import { Component } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { ShellLayoutComponent } from '@isa/shell/layout';
@Component({
selector: 'app-root',
templateUrl: './app.html',
styleUrls: ['./app.css'],
imports: [RouterOutlet, ShellLayoutComponent],
})
export class App {}

View File

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

View File

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

View File

@@ -5,7 +5,7 @@ import { ScanAdapterService } from '@adapter/scan';
import { AuthService as IsaAuthService } from '@generated/swagger/isa-api';
import { UiConfirmModalComponent, UiErrorModalComponent, UiModalResult, UiModalService } from '@ui/modal';
import { EnvironmentService } from '@core/environment';
import { injectNetworkStatus$ } from '../services/network-status.service';
import { injectNetworkStatus$ } from '@isa/core/connectivity';
import { toSignal } from '@angular/core/rxjs-interop';
@Injectable({ providedIn: 'root' })

View File

@@ -9,7 +9,7 @@ import {
import { from, NEVER, Observable, throwError } from 'rxjs';
import { catchError, filter, mergeMap, takeUntil } from 'rxjs/operators';
import { AuthService, LoginStrategy } from '@core/auth';
import { injectOnline$ } from '../services/network-status.service';
import { injectNetworkStatus$ } from '@isa/core/connectivity';
import { logger } from '@isa/core/logging';
@Injectable()
@@ -17,7 +17,7 @@ export class HttpErrorInterceptor implements HttpInterceptor {
#logger = logger(() => ({
'http-interceptor': 'HttpErrorInterceptor',
}));
#offline$ = injectOnline$().pipe(filter((online) => !online));
#offline$ = injectNetworkStatus$().pipe(filter((status) => status === 'offline'));
#injector = inject(Injector);
#auth = inject(AuthService);

View File

@@ -1,3 +0,0 @@
<shell-root>
<router-outlet></router-outlet>
</shell-root>

View File

@@ -1,11 +0,0 @@
import { Component, ChangeDetectionStrategy } from '@angular/core';
@Component({
selector: 'app-main',
templateUrl: 'main.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: false,
})
export class MainComponent {
constructor() {}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1 +0,0 @@
export * from './network-status.service';

View File

@@ -1,25 +0,0 @@
import { inject, Injectable } from '@angular/core';
import { map, Observable } from 'rxjs';
@Injectable({ providedIn: 'root' })
export class NetworkStatusService {
online$ = new Observable<boolean>((subscriber) => {
const handler = () => subscriber.next(navigator.onLine);
window.addEventListener('online', handler);
window.addEventListener('offline', handler);
handler();
return () => {
window.removeEventListener('online', handler);
window.removeEventListener('offline', handler);
};
});
status$ = this.online$.pipe(map((online) => (online ? 'online' : 'offline')));
}
export const injectNetworkStatus$ = () => inject(NetworkStatusService).status$;
export const injectOnline$ = () => inject(NetworkStatusService).online$;

View File

@@ -1,2 +1 @@
export * from './token-login.component';
export * from './token-login.module';

View File

@@ -1,29 +1,31 @@
import { Component, ChangeDetectionStrategy, OnInit } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { AuthService } from '@core/auth';
@Component({
selector: 'app-token-login',
templateUrl: 'token-login.component.html',
styleUrls: ['token-login.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: false,
})
export class TokenLoginComponent implements OnInit {
constructor(
private _route: ActivatedRoute,
private _authService: AuthService,
private _router: Router,
) {}
ngOnInit() {
if (this._route.snapshot.params.token && !this._authService.isAuthenticated()) {
this._authService.setKeyCardToken(this._route.snapshot.params.token);
this._authService.login();
} else if (!this._authService.isAuthenticated()) {
this._authService.login();
} else if (this._authService.isAuthenticated()) {
this._router.navigate(['/']);
}
}
}
import { Component, ChangeDetectionStrategy, OnInit } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { AuthService } from '@core/auth';
@Component({
selector: 'app-token-login',
templateUrl: 'token-login.component.html',
styleUrls: ['token-login.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class TokenLoginComponent implements OnInit {
constructor(
private _route: ActivatedRoute,
private _authService: AuthService,
private _router: Router,
) {}
ngOnInit() {
if (
this._route.snapshot.params.token &&
!this._authService.isAuthenticated()
) {
this._authService.setKeyCardToken(this._route.snapshot.params.token);
this._authService.login();
} else if (!this._authService.isAuthenticated()) {
this._authService.login();
} else if (this._authService.isAuthenticated()) {
this._router.navigate(['/']);
}
}
}

View File

@@ -1,11 +0,0 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { TokenLoginComponent } from './token-login.component';
@NgModule({
imports: [CommonModule],
exports: [TokenLoginComponent],
declarations: [TokenLoginComponent],
})
export class TokenLoginModule {}

View File

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

View File

@@ -1,23 +0,0 @@
import { NgModule, ModuleWithProviders } from '@angular/core';
import { StoreModule } from '@ngrx/store';
import { applicationReducer } from './store';
import { ApplicationService } from './application.service';
@NgModule({
declarations: [],
imports: [],
exports: [],
})
export class CoreApplicationModule {
static forRoot(): ModuleWithProviders<CoreApplicationModule> {
return {
ngModule: RootCoreApplicationModule,
};
}
}
@NgModule({
imports: [StoreModule.forFeature('core-application', applicationReducer)],
providers: [ApplicationService],
})
export class RootCoreApplicationModule {}

View File

@@ -1,337 +0,0 @@
import { inject, Injectable } from '@angular/core';
import { BehaviorSubject, Observable, of, firstValueFrom } from 'rxjs';
import { map, filter, withLatestFrom } from 'rxjs/operators';
import { BranchDTO } from '@generated/swagger/checkout-api';
import { isBoolean, isNumber } from '@utils/common';
import { ApplicationService } from './application.service';
import { TabService } from '@isa/core/tabs';
import { ApplicationProcess } from './defs/application-process';
import { Tab, TabMetadata } from '@isa/core/tabs';
import { toObservable } from '@angular/core/rxjs-interop';
import { Store } from '@ngrx/store';
import { removeProcess } from './store/application.actions';
/**
* Adapter service that bridges the old ApplicationService interface with the new TabService.
*
* This adapter allows existing code that depends on ApplicationService to work with the new
* TabService without requiring immediate code changes. It maps ApplicationProcess concepts
* to Tab entities, storing process-specific data in tab metadata.
*
* Key mappings:
* - ApplicationProcess.id <-> Tab.id
* - ApplicationProcess.name <-> Tab.name
* - ApplicationProcess metadata (section, type, etc.) <-> Tab.metadata with 'process_' prefix
* - ApplicationProcess.data <-> Tab.metadata with 'data_' prefix
*
* @example
* ```typescript
* // Inject the adapter instead of the original service
* constructor(private applicationService: ApplicationServiceAdapter) {}
*
* // Use the same API as before
* const process = await this.applicationService.createCustomerProcess();
* this.applicationService.activateProcess(process.id);
* ```
*/
@Injectable({ providedIn: 'root' })
export class ApplicationServiceAdapter extends ApplicationService {
#store = inject(Store);
#tabService = inject(TabService);
#activatedProcessId$ = toObservable(this.#tabService.activatedTabId);
#tabs$ = toObservable(this.#tabService.entities);
#processes$ = this.#tabs$.pipe(
map((tabs) => tabs.map((tab) => this.mapTabToProcess(tab))),
);
#section = new BehaviorSubject<'customer' | 'branch'>('customer');
readonly REGEX_PROCESS_NAME = /^Vorgang \d+$/;
get activatedProcessId() {
return this.#tabService.activatedTabId();
}
get activatedProcessId$() {
return this.#activatedProcessId$;
}
getProcesses$(
section?: 'customer' | 'branch',
): Observable<ApplicationProcess[]> {
return this.#processes$.pipe(
map((processes) =>
processes.filter((process) =>
section ? process.section === section : true,
),
),
);
}
getProcessById$(processId: number): Observable<ApplicationProcess> {
return this.#processes$.pipe(
map((processes) => processes.find((process) => process.id === processId)),
);
}
getSection$(): Observable<'customer' | 'branch'> {
return this.#section.asObservable();
}
getTitle$(): Observable<'Kundenbereich' | 'Filialbereich'> {
return this.getSection$().pipe(
map((section) =>
section === 'customer' ? 'Kundenbereich' : 'Filialbereich',
),
);
}
/** @deprecated */
getActivatedProcessId$(): Observable<number> {
return this.activatedProcessId$;
}
activateProcess(activatedProcessId: number): void {
this.#tabService.activateTab(activatedProcessId);
}
removeProcess(processId: number): void {
this.#tabService.removeTab(processId);
this.#store.dispatch(removeProcess({ processId }));
}
patchProcess(processId: number, changes: Partial<ApplicationProcess>): void {
const tabChanges: {
name?: string;
tags?: string[];
metadata?: Record<string, unknown>;
} = {};
if (changes.name) {
tabChanges.name = changes.name;
}
// Store other ApplicationProcess properties in metadata
const metadataKeys = [
'section',
'type',
'closeable',
'confirmClosing',
'created',
'activated',
'data',
];
metadataKeys.forEach((key) => {
if (tabChanges.metadata === undefined) {
tabChanges.metadata = {};
}
if (changes[key as keyof ApplicationProcess] !== undefined) {
tabChanges.metadata[`process_${key}`] =
changes[key as keyof ApplicationProcess];
}
});
// Apply the changes to the tab
this.#tabService.patchTab(processId, tabChanges);
}
patchProcessData(processId: number, data: Record<string, unknown>): void {
const currentProcess = this.#tabService.entityMap()[processId];
const currentData: TabMetadata =
(currentProcess?.metadata?.['process_data'] as TabMetadata) ?? {};
this.#tabService.patchTab(processId, {
metadata: { [`process_data`]: { ...currentData, ...data } },
});
}
getSelectedBranch$(): Observable<BranchDTO> {
return this.#processes$.pipe(
withLatestFrom(this.#activatedProcessId$),
map(([processes, activatedProcessId]) =>
processes.find((process) => process.id === activatedProcessId),
),
filter((process): process is ApplicationProcess => !!process),
map((process) => process.data?.selectedBranch as BranchDTO),
);
}
async createCustomerProcess(processId?: number): Promise<ApplicationProcess> {
const processes = await firstValueFrom(this.getProcesses$('customer'));
const processIds = processes
.filter((x) => this.REGEX_PROCESS_NAME.test(x.name))
.map((x) => +x.name.split(' ')[1]);
const maxId = processIds.length > 0 ? Math.max(...processIds) : 0;
const process: ApplicationProcess = {
id: processId ?? Date.now(),
type: 'cart',
name: `Vorgang ${maxId + 1}`,
section: 'customer',
closeable: true,
};
await this.createProcess(process);
return process;
}
/**
* Creates a new ApplicationProcess by first creating a Tab and then storing
* process-specific properties in the tab's metadata.
*
* @param process - The ApplicationProcess to create
* @throws {Error} If process ID already exists or is invalid
*/
async createProcess(process: ApplicationProcess): Promise<void> {
const existingProcess = this.#tabService.entityMap()[process.id];
if (existingProcess?.id === process?.id) {
throw new Error('Process Id existiert bereits');
}
if (!isNumber(process.id)) {
throw new Error('Process Id nicht gesetzt');
}
if (!isBoolean(process.closeable)) {
process.closeable = true;
}
if (!isBoolean(process.confirmClosing)) {
process.confirmClosing = true;
}
process.created = this.createTimestamp();
process.activated = 0;
// Create tab with process data and preserve the process ID
this.#tabService.addTab({
id: process.id,
name: process.name,
tags: [process.section, process.type].filter(Boolean),
metadata: {
process_section: process.section,
process_type: process.type,
process_closeable: process.closeable,
process_confirmClosing: process.confirmClosing,
process_created: process.created,
process_activated: process.activated,
process_data: process.data,
},
});
}
setSection(section: 'customer' | 'branch'): void {
this.#section.next(section);
}
getLastActivatedProcessWithSectionAndType$(
section: 'customer' | 'branch',
type: string,
): Observable<ApplicationProcess> {
return this.getProcesses$(section).pipe(
map((processes) =>
processes
?.filter((process) => process.type === type)
?.reduce((latest, current) => {
if (!latest) {
return current;
}
return latest?.activated > current?.activated ? latest : current;
}, undefined),
),
);
}
getLastActivatedProcessWithSection$(
section: 'customer' | 'branch',
): Observable<ApplicationProcess> {
return this.getProcesses$(section).pipe(
map((processes) =>
processes?.reduce((latest, current) => {
if (!latest) {
return current;
}
return latest?.activated > current?.activated ? latest : current;
}, undefined),
),
);
}
/**
* Maps Tab entities to ApplicationProcess objects by extracting process-specific
* metadata and combining it with tab properties.
*
* @param tab - The tab entity to convert
* @returns The corresponding ApplicationProcess object
*/
private mapTabToProcess(tab: Tab): ApplicationProcess {
return {
id: tab.id,
name: tab.name,
created:
this.getMetadataValue<number>(tab.metadata, 'process_created') ??
tab.createdAt,
activated:
this.getMetadataValue<number>(tab.metadata, 'process_activated') ??
tab.activatedAt ??
0,
section:
this.getMetadataValue<'customer' | 'branch'>(
tab.metadata,
'process_section',
) ?? 'customer',
type: this.getMetadataValue<string>(tab.metadata, 'process_type'),
closeable:
this.getMetadataValue<boolean>(tab.metadata, 'process_closeable') ??
true,
confirmClosing:
this.getMetadataValue<boolean>(
tab.metadata,
'process_confirmClosing',
) ?? true,
data: this.extractDataFromMetadata(tab.metadata),
};
}
/**
* Extracts ApplicationProcess data properties from tab metadata.
* Data properties are stored with a 'data_' prefix in tab metadata.
*
* @param metadata - The tab metadata object
* @returns The extracted data object or undefined if no data properties exist
*/
private extractDataFromMetadata(
metadata: TabMetadata,
): Record<string, unknown> | undefined {
// Return the complete data object stored under 'process_data'
const processData = metadata?.['process_data'];
if (
processData &&
typeof processData === 'object' &&
processData !== null
) {
return processData as Record<string, unknown>;
}
return undefined;
}
private getMetadataValue<T>(
metadata: TabMetadata,
key: string,
): T | undefined {
return metadata?.[key] as T | undefined;
}
private createTimestamp(): number {
return Date.now();
}
}

View File

@@ -1,233 +0,0 @@
// import { createServiceFactory, SpectatorService, SpyObject } from '@ngneat/spectator';
// import { Store } from '@ngrx/store';
// import { Observable, of } from 'rxjs';
// import { first } from 'rxjs/operators';
// import { ApplicationProcess } from './defs';
// import { ApplicationService } from './application.service';
// import * as actions from './store/application.actions';
// describe('ApplicationService', () => {
// let spectator: SpectatorService<ApplicationService>;
// let store: SpyObject<Store>;
// const createService = createServiceFactory({
// service: ApplicationService,
// mocks: [Store],
// });
// beforeEach(() => {
// spectator = createService({});
// store = spectator.inject(Store);
// });
// it('should be created', () => {
// expect(spectator.service).toBeTruthy();
// });
// describe('activatedProcessId$', () => {
// it('should return an observable', () => {
// expect(spectator.service.activatedProcessId$).toBeInstanceOf(Observable);
// });
// });
// describe('activatedProcessId', () => {
// it('should return the process id as a number', () => {
// spyOnProperty(spectator.service['activatedProcessIdSubject'] as any, 'value').and.returnValue(2);
// expect(spectator.service.activatedProcessId).toBe(2);
// });
// });
// describe('getProcesses$()', () => {
// it('should call select on store and return all selected processes', async () => {
// const processes: ApplicationProcess[] = [
// { id: 1, name: 'Vorgang', type: 'cart', section: 'customer', data: { count: 1 } },
// { id: 2, name: 'Vorgang', type: 'task-calendar', section: 'branch' },
// ];
// store.select.and.returnValue(of(processes));
// const result = await spectator.service.getProcesses$().pipe(first()).toPromise();
// expect(result).toEqual(processes);
// expect(store.select).toHaveBeenCalled();
// });
// it('should call select on store and return all section customer processes', async () => {
// const processes: ApplicationProcess[] = [
// { id: 1, name: 'Vorgang', type: 'cart', section: 'customer', data: { count: 1 } },
// { id: 2, name: 'Vorgang', type: 'task-calendar', section: 'branch' },
// ];
// store.select.and.returnValue(of(processes));
// const result = await spectator.service.getProcesses$('customer').pipe(first()).toPromise();
// expect(result).toEqual([processes[0]]);
// expect(store.select).toHaveBeenCalled();
// });
// it('should call select on store and return all section branch processes', async () => {
// const processes: ApplicationProcess[] = [
// { id: 1, name: 'Vorgang', type: 'cart', section: 'customer', data: { count: 1 } },
// { id: 2, name: 'Vorgang', type: 'task-calendar', section: 'branch' },
// ];
// store.select.and.returnValue(of(processes));
// const result = await spectator.service.getProcesses$('branch').pipe(first()).toPromise();
// expect(result).toEqual([processes[1]]);
// expect(store.select).toHaveBeenCalled();
// });
// });
// describe('getProcessById$()', () => {
// it('should return the process by id', async () => {
// const processes: ApplicationProcess[] = [
// { id: 1, name: 'Vorgang 1', section: 'customer' },
// { id: 2, name: 'Vorgang 2', section: 'customer' },
// ];
// spyOn(spectator.service, 'getProcesses$').and.returnValue(of(processes));
// const process = await spectator.service.getProcessById$(1).toPromise();
// expect(process.id).toBe(1);
// });
// });
// describe('getSection$()', () => {
// it('should return the selected section branch', async () => {
// const section = 'branch';
// store.select.and.returnValue(of(section));
// const result = await spectator.service.getSection$().pipe(first()).toPromise();
// expect(result).toEqual(section);
// expect(store.select).toHaveBeenCalled();
// });
// });
// describe('getActivatedProcessId$', () => {
// it('should return the current selected activated process id', async () => {
// const activatedProcessId = 2;
// store.select.and.returnValue(of({ id: activatedProcessId }));
// const result = await spectator.service.getActivatedProcessId$().pipe(first()).toPromise();
// expect(result).toEqual(activatedProcessId);
// expect(store.select).toHaveBeenCalled();
// });
// });
// describe('activateProcess()', () => {
// it('should dispatch action setActivatedProcess with argument activatedProcessId and action type', () => {
// const activatedProcessId = 2;
// spectator.service.activateProcess(activatedProcessId);
// expect(store.dispatch).toHaveBeenCalledWith({ activatedProcessId, type: actions.setActivatedProcess.type });
// });
// });
// describe('removeProcess()', () => {
// it('should dispatch action removeProcess with argument processId and action type', () => {
// const processId = 2;
// spectator.service.removeProcess(processId);
// expect(store.dispatch).toHaveBeenCalledWith({ processId, type: actions.removeProcess.type });
// });
// });
// describe('createProcess()', () => {
// it('should dispatch action addProcess with process', async () => {
// const process: ApplicationProcess = {
// id: 1,
// name: 'Vorgang 1',
// section: 'customer',
// type: 'cart',
// };
// const timestamp = 100;
// spyOn(spectator.service as any, '_createTimestamp').and.returnValue(timestamp);
// spyOn(spectator.service, 'getProcessById$').and.returnValue(of(undefined));
// await spectator.service.createProcess(process);
// expect(store.dispatch).toHaveBeenCalledWith({
// type: actions.addProcess.type,
// process: {
// ...process,
// activated: 0,
// created: timestamp,
// },
// });
// });
// it('should throw an error if the process id is already existing', async () => {
// const process: ApplicationProcess = {
// id: 1,
// name: 'Vorgang 1',
// section: 'customer',
// type: 'cart',
// };
// spyOn(spectator.service, 'getProcessById$').and.returnValue(of(process));
// await expectAsync(spectator.service.createProcess(process)).toBeRejectedWithError('Process Id existiert bereits');
// });
// it('should throw an error if the process id is not a number', async () => {
// const process: ApplicationProcess = {
// id: undefined,
// name: 'Vorgang 1',
// section: 'customer',
// type: 'cart',
// };
// spyOn(spectator.service, 'getProcessById$').and.returnValue(of({ id: 5, name: 'Vorgang 2', section: 'customer' }));
// await expectAsync(spectator.service.createProcess(process)).toBeRejectedWithError('Process Id nicht gesetzt');
// });
// });
// describe('patchProcess', () => {
// it('should dispatch action patchProcess with changes', async () => {
// const process: ApplicationProcess = {
// id: 1,
// name: 'Vorgang 1',
// section: 'customer',
// type: 'cart',
// };
// await spectator.service.patchProcess(process.id, process);
// expect(store.dispatch).toHaveBeenCalledWith({
// type: actions.patchProcess.type,
// processId: process.id,
// changes: {
// ...process,
// },
// });
// });
// });
// describe('setSection()', () => {
// it('should dispatch action setSection with argument section and action type', () => {
// const section = 'customer';
// spectator.service.setSection(section);
// expect(store.dispatch).toHaveBeenCalledWith({ section, type: actions.setSection.type });
// });
// });
// describe('getLastActivatedProcessWithSectionAndType()', () => {
// it('should return the last activated process by section and type', async () => {
// const processes: ApplicationProcess[] = [
// { id: 1, name: 'Vorgang 1', section: 'customer', type: 'cart', activated: 100 },
// { id: 2, name: 'Vorgang 2', section: 'customer', type: 'cart', activated: 200 },
// { id: 3, name: 'Vorgang 3', section: 'customer', type: 'goodsOut', activated: 300 },
// ];
// spyOn(spectator.service, 'getProcesses$').and.returnValue(of(processes));
// expect(await spectator.service.getLastActivatedProcessWithSectionAndType$('customer', 'cart').pipe(first()).toPromise()).toBe(
// processes[1]
// );
// });
// });
// describe('getLastActivatedProcessWithSection()', () => {
// it('should return the last activated process by section', async () => {
// const processes: ApplicationProcess[] = [
// { id: 1, name: 'Vorgang 1', section: 'customer', activated: 100 },
// { id: 2, name: 'Vorgang 2', section: 'customer', activated: 200 },
// { id: 3, name: 'Vorgang 3', section: 'customer', activated: 300 },
// ];
// spyOn(spectator.service, 'getProcesses$').and.returnValue(of(processes));
// expect(await spectator.service.getLastActivatedProcessWithSection$('customer').pipe(first()).toPromise()).toBe(processes[2]);
// });
// });
// describe('_createTimestamp', () => {
// it('should return the current timestamp in ms', () => {
// expect(spectator.service['_createTimestamp']()).toBeCloseTo(Date.now());
// });
// });
// });

View File

@@ -1,174 +1,336 @@
import { Injectable } from '@angular/core';
import { Store } from '@ngrx/store';
import { BranchDTO } from '@generated/swagger/checkout-api';
import { isBoolean, isNumber } from '@utils/common';
import { BehaviorSubject, Observable } from 'rxjs';
import { first, map, switchMap } from 'rxjs/operators';
import { ApplicationProcess } from './defs';
import {
removeProcess,
selectSection,
selectProcesses,
setSection,
addProcess,
setActivatedProcess,
selectActivatedProcess,
patchProcess,
patchProcessData,
selectTitle,
setTitle,
} from './store';
@Injectable()
export class ApplicationService {
private activatedProcessIdSubject = new BehaviorSubject<number>(undefined);
get activatedProcessId() {
return this.activatedProcessIdSubject.value;
}
get activatedProcessId$() {
return this.activatedProcessIdSubject.asObservable();
}
constructor(private store: Store) {}
getProcesses$(section?: 'customer' | 'branch') {
const processes$ = this.store.select(selectProcesses);
return processes$.pipe(
map((processes) => processes.filter((process) => (section ? process.section === section : true))),
);
}
getProcessById$(processId: number): Observable<ApplicationProcess> {
return this.getProcesses$().pipe(map((processes) => processes.find((process) => process.id === processId)));
}
getSection$() {
return this.store.select(selectSection);
}
getTitle$() {
return this.getSection$().pipe(
map((section) => {
return section === 'customer' ? 'Kundenbereich' : 'Filialbereich';
}),
);
}
/** @deprecated */
getActivatedProcessId$() {
return this.store.select(selectActivatedProcess).pipe(map((process) => process?.id));
}
activateProcess(activatedProcessId: number) {
this.store.dispatch(setActivatedProcess({ activatedProcessId }));
this.activatedProcessIdSubject.next(activatedProcessId);
}
removeProcess(processId: number) {
this.store.dispatch(removeProcess({ processId }));
}
patchProcess(processId: number, changes: Partial<ApplicationProcess>) {
this.store.dispatch(patchProcess({ processId, changes }));
}
patchProcessData(processId: number, data: Record<string, any>) {
this.store.dispatch(patchProcessData({ processId, data }));
}
getSelectedBranch$(processId?: number): Observable<BranchDTO> {
if (!processId) {
return this.activatedProcessId$.pipe(
switchMap((processId) => this.getProcessById$(processId).pipe(map((process) => process?.data?.selectedBranch))),
);
}
return this.getProcessById$(processId).pipe(map((process) => process?.data?.selectedBranch));
}
readonly REGEX_PROCESS_NAME = /^Vorgang \d+$/;
async createCustomerProcess(processId?: number): Promise<ApplicationProcess> {
const processes = await this.getProcesses$('customer').pipe(first()).toPromise();
const processIds = processes.filter((x) => this.REGEX_PROCESS_NAME.test(x.name)).map((x) => +x.name.split(' ')[1]);
const maxId = processIds.length > 0 ? Math.max(...processIds) : 0;
const process: ApplicationProcess = {
id: processId ?? Date.now(),
type: 'cart',
name: `Vorgang ${maxId + 1}`,
section: 'customer',
closeable: true,
};
await this.createProcess(process);
return process;
}
async createProcess(process: ApplicationProcess) {
const existingProcess = await this.getProcessById$(process?.id).pipe(first()).toPromise();
if (existingProcess?.id === process?.id) {
throw new Error('Process Id existiert bereits');
}
if (!isNumber(process.id)) {
throw new Error('Process Id nicht gesetzt');
}
if (!isBoolean(process.closeable)) {
process.closeable = true;
}
if (!isBoolean(process.confirmClosing)) {
process.confirmClosing = true;
}
process.created = this._createTimestamp();
process.activated = 0;
this.store.dispatch(addProcess({ process }));
}
setSection(section: 'customer' | 'branch') {
this.store.dispatch(setSection({ section }));
}
getLastActivatedProcessWithSectionAndType$(
section: 'customer' | 'branch',
type: string,
): Observable<ApplicationProcess> {
return this.getProcesses$(section).pipe(
map((processes) =>
processes
?.filter((process) => process.type === type)
?.reduce((latest, current) => {
if (!latest) {
return current;
}
return latest?.activated > current?.activated ? latest : current;
}, undefined),
),
);
}
getLastActivatedProcessWithSection$(section: 'customer' | 'branch'): Observable<ApplicationProcess> {
return this.getProcesses$(section).pipe(
map((processes) =>
processes?.reduce((latest, current) => {
if (!latest) {
return current;
}
return latest?.activated > current?.activated ? latest : current;
}, undefined),
),
);
}
private _createTimestamp() {
return Date.now();
}
}
import { inject, Injectable } from '@angular/core';
import { BehaviorSubject, Observable, of, firstValueFrom } from 'rxjs';
import { map, filter, withLatestFrom } from 'rxjs/operators';
import { BranchDTO } from '@generated/swagger/checkout-api';
import { isBoolean, isNumber } from '@utils/common';
import { TabService } from '@isa/core/tabs';
import { ApplicationProcess } from './defs/application-process';
import { Tab, TabMetadata } from '@isa/core/tabs';
import { toObservable } from '@angular/core/rxjs-interop';
import { Store } from '@ngrx/store';
import { removeProcess } from './store/application.actions';
/**
* Adapter service that bridges the old ApplicationService interface with the new TabService.
*
* This adapter allows existing code that depends on ApplicationService to work with the new
* TabService without requiring immediate code changes. It maps ApplicationProcess concepts
* to Tab entities, storing process-specific data in tab metadata.
*
* Key mappings:
* - ApplicationProcess.id <-> Tab.id
* - ApplicationProcess.name <-> Tab.name
* - ApplicationProcess metadata (section, type, etc.) <-> Tab.metadata with 'process_' prefix
* - ApplicationProcess.data <-> Tab.metadata with 'data_' prefix
*
* @example
* ```typescript
* // Inject the adapter instead of the original service
* constructor(private applicationService: ApplicationServiceAdapter) {}
*
* // Use the same API as before
* const process = await this.applicationService.createCustomerProcess();
* this.applicationService.activateProcess(process.id);
* ```
*/
@Injectable({ providedIn: 'root' })
export class ApplicationService {
#store = inject(Store);
#tabService = inject(TabService);
#activatedProcessId$ = toObservable(this.#tabService.activatedTabId);
#tabs$ = toObservable(this.#tabService.entities);
#processes$ = this.#tabs$.pipe(
map((tabs) => tabs.map((tab) => this.mapTabToProcess(tab))),
);
#section = new BehaviorSubject<'customer' | 'branch'>('customer');
readonly REGEX_PROCESS_NAME = /^Vorgang \d+$/;
get activatedProcessId() {
return this.#tabService.activatedTabId();
}
get activatedProcessId$() {
return this.#activatedProcessId$;
}
getProcesses$(
section?: 'customer' | 'branch',
): Observable<ApplicationProcess[]> {
return this.#processes$.pipe(
map((processes) =>
processes.filter((process) =>
section ? process.section === section : true,
),
),
);
}
getProcessById$(processId: number): Observable<ApplicationProcess> {
return this.#processes$.pipe(
map((processes) => processes.find((process) => process.id === processId)),
);
}
getSection$(): Observable<'customer' | 'branch'> {
return this.#section.asObservable();
}
getTitle$(): Observable<'Kundenbereich' | 'Filialbereich'> {
return this.getSection$().pipe(
map((section) =>
section === 'customer' ? 'Kundenbereich' : 'Filialbereich',
),
);
}
/** @deprecated */
getActivatedProcessId$(): Observable<number> {
return this.activatedProcessId$;
}
activateProcess(activatedProcessId: number): void {
this.#tabService.activateTab(activatedProcessId);
}
removeProcess(processId: number): void {
this.#tabService.removeTab(processId);
this.#store.dispatch(removeProcess({ processId }));
}
patchProcess(processId: number, changes: Partial<ApplicationProcess>): void {
const tabChanges: {
name?: string;
tags?: string[];
metadata?: Record<string, unknown>;
} = {};
if (changes.name) {
tabChanges.name = changes.name;
}
// Store other ApplicationProcess properties in metadata
const metadataKeys = [
'section',
'type',
'closeable',
'confirmClosing',
'created',
'activated',
'data',
];
metadataKeys.forEach((key) => {
if (tabChanges.metadata === undefined) {
tabChanges.metadata = {};
}
if (changes[key as keyof ApplicationProcess] !== undefined) {
tabChanges.metadata[`process_${key}`] =
changes[key as keyof ApplicationProcess];
}
});
// Apply the changes to the tab
this.#tabService.patchTab(processId, tabChanges);
}
patchProcessData(processId: number, data: Record<string, unknown>): void {
const currentProcess = this.#tabService.entityMap()[processId];
const currentData: TabMetadata =
(currentProcess?.metadata?.['process_data'] as TabMetadata) ?? {};
this.#tabService.patchTab(processId, {
metadata: { [`process_data`]: { ...currentData, ...data } },
});
}
getSelectedBranch$(): Observable<BranchDTO> {
return this.#processes$.pipe(
withLatestFrom(this.#activatedProcessId$),
map(([processes, activatedProcessId]) =>
processes.find((process) => process.id === activatedProcessId),
),
filter((process): process is ApplicationProcess => !!process),
map((process) => process.data?.selectedBranch as BranchDTO),
);
}
async createCustomerProcess(processId?: number): Promise<ApplicationProcess> {
const processes = await firstValueFrom(this.getProcesses$('customer'));
const processIds = processes
.filter((x) => this.REGEX_PROCESS_NAME.test(x.name))
.map((x) => +x.name.split(' ')[1]);
const maxId = processIds.length > 0 ? Math.max(...processIds) : 0;
const process: ApplicationProcess = {
id: processId ?? Date.now(),
type: 'cart',
name: `Vorgang ${maxId + 1}`,
section: 'customer',
closeable: true,
};
await this.createProcess(process);
return process;
}
/**
* Creates a new ApplicationProcess by first creating a Tab and then storing
* process-specific properties in the tab's metadata.
*
* @param process - The ApplicationProcess to create
* @throws {Error} If process ID already exists or is invalid
*/
async createProcess(process: ApplicationProcess): Promise<void> {
const existingProcess = this.#tabService.entityMap()[process.id];
if (existingProcess?.id === process?.id) {
throw new Error('Process Id existiert bereits');
}
if (!isNumber(process.id)) {
throw new Error('Process Id nicht gesetzt');
}
if (!isBoolean(process.closeable)) {
process.closeable = true;
}
if (!isBoolean(process.confirmClosing)) {
process.confirmClosing = true;
}
process.created = this.createTimestamp();
process.activated = 0;
// Create tab with process data and preserve the process ID
this.#tabService.addTab({
id: process.id,
name: process.name,
tags: [process.section, process.type].filter(Boolean),
metadata: {
process_section: process.section,
process_type: process.type,
process_closeable: process.closeable,
process_confirmClosing: process.confirmClosing,
process_created: process.created,
process_activated: process.activated,
process_data: process.data,
},
});
}
setSection(section: 'customer' | 'branch'): void {
this.#section.next(section);
}
getLastActivatedProcessWithSectionAndType$(
section: 'customer' | 'branch',
type: string,
): Observable<ApplicationProcess> {
return this.getProcesses$(section).pipe(
map((processes) =>
processes
?.filter((process) => process.type === type)
?.reduce((latest, current) => {
if (!latest) {
return current;
}
return latest?.activated > current?.activated ? latest : current;
}, undefined),
),
);
}
getLastActivatedProcessWithSection$(
section: 'customer' | 'branch',
): Observable<ApplicationProcess> {
return this.getProcesses$(section).pipe(
map((processes) =>
processes?.reduce((latest, current) => {
if (!latest) {
return current;
}
return latest?.activated > current?.activated ? latest : current;
}, undefined),
),
);
}
/**
* Maps Tab entities to ApplicationProcess objects by extracting process-specific
* metadata and combining it with tab properties.
*
* @param tab - The tab entity to convert
* @returns The corresponding ApplicationProcess object
*/
private mapTabToProcess(tab: Tab): ApplicationProcess {
return {
id: tab.id,
name: tab.name,
created:
this.getMetadataValue<number>(tab.metadata, 'process_created') ??
tab.createdAt,
activated:
this.getMetadataValue<number>(tab.metadata, 'process_activated') ??
tab.activatedAt ??
0,
section:
this.getMetadataValue<'customer' | 'branch'>(
tab.metadata,
'process_section',
) ?? 'customer',
type: this.getMetadataValue<string>(tab.metadata, 'process_type'),
closeable:
this.getMetadataValue<boolean>(tab.metadata, 'process_closeable') ??
true,
confirmClosing:
this.getMetadataValue<boolean>(
tab.metadata,
'process_confirmClosing',
) ?? true,
data: this.extractDataFromMetadata(tab.metadata),
};
}
/**
* Extracts ApplicationProcess data properties from tab metadata.
* Data properties are stored with a 'data_' prefix in tab metadata.
*
* @param metadata - The tab metadata object
* @returns The extracted data object or undefined if no data properties exist
*/
private extractDataFromMetadata(
metadata: TabMetadata,
): Record<string, unknown> | undefined {
// Return the complete data object stored under 'process_data'
const processData = metadata?.['process_data'];
if (
processData &&
typeof processData === 'object' &&
processData !== null
) {
return processData as Record<string, unknown>;
}
return undefined;
}
private getMetadataValue<T>(
metadata: TabMetadata,
key: string,
): T | undefined {
return metadata?.[key] as T | undefined;
}
private createTimestamp(): number {
return Date.now();
}
}

View File

@@ -1,5 +1,3 @@
export * from './application.module';
export * from './application.service';
export * from './application.service-adapter';
export * from './defs';
export * from './store';
export * from './application.service';
export * from './defs';
export * from './store/application.actions';

View File

@@ -1,27 +1,8 @@
import { createAction, props } from '@ngrx/store';
import { ApplicationProcess } from '..';
const prefix = '[CORE-APPLICATION]';
export const setTitle = createAction(`${prefix} Set Title`, props<{ title: string }>());
export const setSection = createAction(`${prefix} Set Section`, props<{ section: 'customer' | 'branch' }>());
export const addProcess = createAction(`${prefix} Add Process`, props<{ process: ApplicationProcess }>());
export const removeProcess = createAction(`${prefix} Remove Process`, props<{ processId: number }>());
export const setActivatedProcess = createAction(
`${prefix} Set Activated Process`,
props<{ activatedProcessId: number }>(),
);
export const patchProcess = createAction(
`${prefix} Patch Process`,
props<{ processId: number; changes: Partial<ApplicationProcess> }>(),
);
export const patchProcessData = createAction(
`${prefix} Patch Process Data`,
props<{ processId: number; data: Record<string, any> }>(),
);
import { createAction, props } from '@ngrx/store';
const prefix = '[CORE-APPLICATION]';
export const removeProcess = createAction(
`${prefix} Remove Process`,
props<{ processId: number }>(),
);

View File

@@ -1,200 +0,0 @@
import { INITIAL_APPLICATION_STATE } from './application.state';
import * as actions from './application.actions';
import { applicationReducer } from './application.reducer';
import { ApplicationProcess } from '../defs';
import { ApplicationState } from './application.state';
describe('applicationReducer', () => {
describe('setSection()', () => {
it('should return modified state with section customer', () => {
const initialState = INITIAL_APPLICATION_STATE;
const action = actions.setSection({ section: 'customer' });
const state = applicationReducer(initialState, action);
expect(state).toEqual({
...initialState,
section: 'customer',
});
});
it('should return modified state with section branch', () => {
const initialState = INITIAL_APPLICATION_STATE;
const action = actions.setSection({ section: 'branch' });
const state = applicationReducer(initialState, action);
expect(state).toEqual({
...initialState,
section: 'branch',
});
});
});
describe('addProcess()', () => {
it('should return modified state with new process if no processes are registered in the state', () => {
const initialState = INITIAL_APPLICATION_STATE;
const process: ApplicationProcess = {
id: 1,
name: 'Vorgang',
section: 'customer',
type: 'cart',
data: {},
};
const action = actions.addProcess({ process });
const state = applicationReducer(initialState, action);
expect(state.processes[0]).toEqual(process);
});
});
describe('patchProcess()', () => {
it('should return modified state with updated process when id is found', () => {
const initialState = INITIAL_APPLICATION_STATE;
const process: ApplicationProcess = {
id: 1,
name: 'Vorgang',
section: 'customer',
type: 'cart',
};
const action = actions.patchProcess({ processId: process.id, changes: { ...process, name: 'Test' } });
const state = applicationReducer(
{
...initialState,
processes: [process],
},
action,
);
expect(state.processes[0].name).toEqual('Test');
});
it('should return unmodified state when id is not existing', () => {
const initialState = INITIAL_APPLICATION_STATE;
const process: ApplicationProcess = {
id: 1,
name: 'Vorgang',
section: 'customer',
type: 'cart',
};
const action = actions.patchProcess({ processId: process.id, changes: { ...process, id: 2 } });
const state = applicationReducer(
{
...initialState,
processes: [process],
},
action,
);
expect(state.processes).toEqual([process]);
});
});
describe('removeProcess()', () => {
it('should return initial state if no processes are registered in the state', () => {
const initialState = INITIAL_APPLICATION_STATE;
const action = actions.removeProcess({ processId: 2 });
const state = applicationReducer(initialState, action);
expect(state).toEqual(initialState);
});
it('should return the unmodified state if processId not found', () => {
const initialState = INITIAL_APPLICATION_STATE;
const modifiedState: ApplicationState = {
...initialState,
section: 'customer',
processes: [
{
id: 1,
name: 'Vorgang',
section: 'customer',
type: 'cart',
},
{
id: 4,
name: 'Vorgang',
section: 'customer',
type: 'goods-out',
},
] as ApplicationProcess[],
};
const action = actions.removeProcess({ processId: 2 });
const state = applicationReducer(modifiedState, action);
expect(state).toEqual(modifiedState);
});
it('should return modified state, after process gets removed', () => {
const initialState = INITIAL_APPLICATION_STATE;
const modifiedState: ApplicationState = {
...initialState,
section: 'customer',
processes: [
{
id: 1,
name: 'Vorgang',
section: 'customer',
type: 'cart',
},
{
id: 2,
name: 'Vorgang',
section: 'customer',
type: 'goods-out',
},
] as ApplicationProcess[],
};
const action = actions.removeProcess({ processId: 2 });
const state = applicationReducer(modifiedState, action);
expect(state.processes).toEqual([
{
id: 1,
name: 'Vorgang',
section: 'customer',
type: 'cart',
},
]);
});
});
describe('setActivatedProcess()', () => {
it('should return modified state with process.activated value', () => {
const process: ApplicationProcess = {
id: 3,
name: 'Vorgang 3',
section: 'customer',
};
const initialState: ApplicationState = {
...INITIAL_APPLICATION_STATE,
processes: [process],
};
const action = actions.setActivatedProcess({ activatedProcessId: 3 });
const state = applicationReducer(initialState, action);
expect(state.processes[0].activated).toBeDefined();
});
it('should return modified state without process.activated value when activatedProcessId doesnt exist', () => {
const process: ApplicationProcess = {
id: 1,
name: 'Vorgang 3',
section: 'customer',
};
const initialState: ApplicationState = {
...INITIAL_APPLICATION_STATE,
processes: [process],
};
const action = actions.setActivatedProcess({ activatedProcessId: 3 });
const state = applicationReducer(initialState, action);
expect(state.processes[0].activated).toBeUndefined();
});
});
});

View File

@@ -1,56 +0,0 @@
import { Action, createReducer, on } from '@ngrx/store';
import {
setSection,
addProcess,
removeProcess,
setActivatedProcess,
patchProcess,
patchProcessData,
setTitle,
} from './application.actions';
import { ApplicationState, INITIAL_APPLICATION_STATE } from './application.state';
const _applicationReducer = createReducer(
INITIAL_APPLICATION_STATE,
on(setTitle, (state, { title }) => ({ ...state, title })),
on(setSection, (state, { section }) => ({ ...state, section })),
on(addProcess, (state, { process }) => ({ ...state, processes: [...state.processes, { data: {}, ...process }] })),
on(removeProcess, (state, { processId }) => {
const processes = state?.processes?.filter((process) => process.id !== processId) || [];
return { ...state, processes };
}),
on(setActivatedProcess, (state, { activatedProcessId }) => {
const processes = state.processes.map((process) => {
if (process.id === activatedProcessId) {
return { ...process, activated: Date.now() };
}
return process;
});
return { ...state, processes };
}),
on(patchProcess, (state, { processId, changes }) => {
const processes = state.processes.map((process) => {
if (process.id === processId) {
return { ...process, ...changes, id: processId };
}
return process;
});
return { ...state, processes };
}),
on(patchProcessData, (state, { processId, data }) => {
const processes = state.processes.map((process) => {
if (process.id === processId) {
return { ...process, data: { ...(process.data || {}), ...data } };
}
return process;
});
return { ...state, processes };
}),
);
export function applicationReducer(state: ApplicationState, action: Action) {
return _applicationReducer(state, action);
}

View File

@@ -1,35 +0,0 @@
// import { ApplicationState } from './application.state';
// import { ApplicationProcess } from '../defs';
// import * as selectors from './application.selectors';
// describe('applicationSelectors', () => {
// it('should select the processes', () => {
// const processes: ApplicationProcess[] = [{ id: 1, name: 'Vorgang 1', section: 'customer' }];
// const state: ApplicationState = {
// processes,
// section: 'customer',
// };
// expect(selectors.selectProcesses.projector(state)).toEqual(processes);
// });
// it('should select the section', () => {
// const state: ApplicationState = {
// processes: [],
// section: 'customer',
// };
// expect(selectors.selectSection.projector(state)).toEqual('customer');
// });
// it('should select the activatedProcess', () => {
// const processes: ApplicationProcess[] = [
// { id: 1, name: 'Vorgang 1', section: 'customer', activated: 100 },
// { id: 2, name: 'Vorgang 2', section: 'customer', activated: 300 },
// { id: 3, name: 'Vorgang 3', section: 'customer', activated: 200 },
// ];
// const state: ApplicationState = {
// processes,
// section: 'customer',
// };
// expect(selectors.selectActivatedProcess.projector(state)).toEqual(processes[1]);
// });
// });

View File

@@ -1,18 +0,0 @@
import { createFeatureSelector, createSelector } from '@ngrx/store';
import { ApplicationState } from './application.state';
export const selectApplicationState = createFeatureSelector<ApplicationState>('core-application');
export const selectTitle = createSelector(selectApplicationState, (s) => s.title);
export const selectSection = createSelector(selectApplicationState, (s) => s.section);
export const selectProcesses = createSelector(selectApplicationState, (s) => s.processes);
export const selectActivatedProcess = createSelector(selectApplicationState, (s) =>
s?.processes?.reduce((process, current) => {
if (!process) {
return current;
}
return process.activated > current.activated ? process : current;
}, undefined),
);

View File

@@ -1,13 +0,0 @@
import { ApplicationProcess } from '../defs';
export interface ApplicationState {
title: string;
processes: ApplicationProcess[];
section: 'customer' | 'branch';
}
export const INITIAL_APPLICATION_STATE: ApplicationState = {
title: '',
processes: [],
section: 'customer',
};

View File

@@ -1,6 +0,0 @@
// start:ng42.barrel
export * from './application.actions';
export * from './application.reducer';
export * from './application.selectors';
export * from './application.state';
// end:ng42.barrel

View File

@@ -3,7 +3,7 @@ import { inject, Injectable } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { EnvironmentService } from '@core/environment';
import { UiConfirmModalComponent, UiModalResult, UiModalService } from '@ui/modal';
import { injectNetworkStatus$ } from '../../app/services';
import { injectNetworkStatus$ } from '@isa/core/connectivity';
import { AuthService } from './auth.service';
import { AuthService as IsaAuthService } from '@generated/swagger/isa-api';
import { firstValueFrom, lastValueFrom } from 'rxjs';

View File

@@ -1,22 +1,15 @@
import { ModuleWithProviders, NgModule } from '@angular/core';
import { EffectsModule } from '@ngrx/effects';
import { StoreModule } from '@ngrx/store';
import { BreadcrumbService } from './breadcrumb.service';
import { BreadcrumbEffects } from './store/breadcrumb.effect';
import { breadcrumbReducer } from './store/breadcrumb.reducer';
import { featureName } from './store/breadcrumb.state';
@NgModule()
export class CoreBreadcrumbModule {
static forRoot(): ModuleWithProviders<CoreBreadcrumbModule> {
return {
ngModule: CoreBreadcrumbForRootModule,
};
}
}
@NgModule({
imports: [StoreModule.forFeature(featureName, breadcrumbReducer), EffectsModule.forFeature([BreadcrumbEffects])],
providers: [BreadcrumbService],
})
export class CoreBreadcrumbForRootModule {}
import { EnvironmentProviders, makeEnvironmentProviders } from '@angular/core';
import { provideEffects } from '@ngrx/effects';
import { provideState } from '@ngrx/store';
import { BreadcrumbService } from './breadcrumb.service';
import { BreadcrumbEffects } from './store/breadcrumb.effect';
import { breadcrumbReducer } from './store/breadcrumb.reducer';
import { featureName } from './store/breadcrumb.state';
export function provideCoreBreadcrumb(): EnvironmentProviders {
return makeEnvironmentProviders([
provideState({ name: featureName, reducer: breadcrumbReducer }),
provideEffects(BreadcrumbEffects),
BreadcrumbService,
]);
}

View File

@@ -1,12 +0,0 @@
import { ModuleWithProviders, NgModule } from '@angular/core';
import { DomainAvailabilityService } from './availability.service';
@NgModule()
export class DomainAvailabilityModule {
static forRoot(): ModuleWithProviders<DomainAvailabilityModule> {
return {
ngModule: DomainAvailabilityModule,
providers: [DomainAvailabilityService],
};
}
}

View File

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,3 @@
export * from './availability.module';
export * from './availability.service';
export * from './defs';
export * from './in-stock.service';

View File

@@ -1,18 +0,0 @@
import { ModuleWithProviders, NgModule } from '@angular/core';
import { DomainCatalogService } from './catalog.service';
import { ThumbnailUrlPipe } from './thumbnail-url.pipe';
import { DomainCatalogThumbnailService } from './thumbnail.service';
@NgModule({
declarations: [ThumbnailUrlPipe],
imports: [],
exports: [ThumbnailUrlPipe],
})
export class DomainCatalogModule {
static forRoot(): ModuleWithProviders<DomainCatalogModule> {
return {
ngModule: DomainCatalogModule,
providers: [DomainCatalogService, DomainCatalogThumbnailService],
};
}
}

View File

@@ -1,105 +1,111 @@
import { Injectable } from '@angular/core';
import { ApplicationService } from '@core/application';
import {
AutocompleteTokenDTO,
PromotionService,
QueryTokenDTO,
SearchService,
} from '@generated/swagger/cat-search-api';
import { memorize } from '@utils/common';
import { map, share, shareReplay } from 'rxjs/operators';
@Injectable()
export class DomainCatalogService {
constructor(
private searchService: SearchService,
private promotionService: PromotionService,
private applicationService: ApplicationService,
) {}
@memorize()
getFilters() {
return this.searchService.SearchSearchFilter().pipe(
map((res) => res.result),
shareReplay(),
);
}
@memorize()
getOrderBy() {
return this.searchService.SearchSearchSort().pipe(
map((res) => res.result),
shareReplay(),
);
}
getSearchHistory({ take }: { take: number }) {
return this.searchService.SearchHistory(take ?? 5).pipe(map((res) => res.result));
}
@memorize({ ttl: 120000 })
search({ queryToken }: { queryToken: QueryTokenDTO }) {
return this.searchService
.SearchSearch({
...queryToken,
stockId: null,
})
.pipe(share());
}
@memorize({ ttl: 120000 })
searchWithStockId({ queryToken }: { queryToken: QueryTokenDTO }) {
return this.searchService
.SearchSearch2({
queryToken,
stockId: queryToken?.stockId ?? null,
})
.pipe(share());
}
getDetailsById({ id }: { id: number }) {
return this.searchService.SearchDetail({
id,
});
}
getDetailsByEan({ ean }: { ean: string }) {
return this.searchService.SearchDetailByEAN(ean);
}
searchByIds({ ids }: { ids: number[] }) {
return this.searchService.SearchById(ids);
}
searchByEans({ eans }: { eans: string[] }) {
return this.searchService.SearchByEAN(eans);
}
searchTop({ queryToken }: { queryToken: QueryTokenDTO }) {
return this.searchService.SearchTop(queryToken);
}
searchComplete({ queryToken }: { queryToken: AutocompleteTokenDTO }) {
return this.searchService.SearchAutocomplete(queryToken);
}
@memorize()
getPromotionPoints({ items }: { items: { id: number; quantity: number; price?: number }[] }) {
return this.promotionService.PromotionLesepunkte(items).pipe(shareReplay());
}
@memorize()
getSettings() {
return this.searchService.SearchSettings().pipe(
map((res) => res.result),
shareReplay(),
);
}
getRecommendations({ digId }: { digId: number }) {
return this.searchService.SearchGetRecommendations({
digId: digId + '',
sessionId: this.applicationService.activatedProcessId + '',
});
}
}
import { Injectable } from '@angular/core';
import { ApplicationService } from '@core/application';
import {
AutocompleteTokenDTO,
PromotionService,
QueryTokenDTO,
SearchService,
} from '@generated/swagger/cat-search-api';
import { memorize } from '@utils/common';
import { map, share, shareReplay } from 'rxjs/operators';
@Injectable({ providedIn: 'root' })
export class DomainCatalogService {
constructor(
private searchService: SearchService,
private promotionService: PromotionService,
private applicationService: ApplicationService,
) {}
@memorize()
getFilters() {
return this.searchService.SearchSearchFilter().pipe(
map((res) => res.result),
shareReplay(),
);
}
@memorize()
getOrderBy() {
return this.searchService.SearchSearchSort().pipe(
map((res) => res.result),
shareReplay(),
);
}
getSearchHistory({ take }: { take: number }) {
return this.searchService
.SearchHistory(take ?? 5)
.pipe(map((res) => res.result));
}
@memorize({ ttl: 120000 })
search({ queryToken }: { queryToken: QueryTokenDTO }) {
return this.searchService
.SearchSearch({
...queryToken,
stockId: null,
})
.pipe(share());
}
@memorize({ ttl: 120000 })
searchWithStockId({ queryToken }: { queryToken: QueryTokenDTO }) {
return this.searchService
.SearchSearch2({
queryToken,
stockId: queryToken?.stockId ?? null,
})
.pipe(share());
}
getDetailsById({ id }: { id: number }) {
return this.searchService.SearchDetail({
id,
});
}
getDetailsByEan({ ean }: { ean: string }) {
return this.searchService.SearchDetailByEAN(ean);
}
searchByIds({ ids }: { ids: number[] }) {
return this.searchService.SearchById(ids);
}
searchByEans({ eans }: { eans: string[] }) {
return this.searchService.SearchByEAN(eans);
}
searchTop({ queryToken }: { queryToken: QueryTokenDTO }) {
return this.searchService.SearchTop(queryToken);
}
searchComplete({ queryToken }: { queryToken: AutocompleteTokenDTO }) {
return this.searchService.SearchAutocomplete(queryToken);
}
@memorize()
getPromotionPoints({
items,
}: {
items: { id: number; quantity: number; price?: number }[];
}) {
return this.promotionService.PromotionLesepunkte(items).pipe(shareReplay());
}
@memorize()
getSettings() {
return this.searchService.SearchSettings().pipe(
map((res) => res.result),
shareReplay(),
);
}
getRecommendations({ digId }: { digId: number }) {
return this.searchService.SearchGetRecommendations({
digId: digId + '',
sessionId: this.applicationService.activatedProcessId + '',
});
}
}

View File

@@ -1,4 +1,3 @@
export * from './catalog.module';
export * from './catalog.service';
export * from './thumbnail-url.pipe';
export * from './thumbnail.service';

View File

@@ -6,7 +6,7 @@ import { DomainCatalogThumbnailService } from './thumbnail.service';
@Pipe({
name: 'thumbnailUrl',
pure: false,
standalone: false,
standalone: true,
})
export class ThumbnailUrlPipe implements PipeTransform, OnDestroy {
private input$ = new BehaviorSubject<{ width?: number; height?: number; ean?: string }>(undefined);

View File

@@ -1,20 +1,28 @@
import { Injectable } from '@angular/core';
import { memorize } from '@utils/common';
import { map, shareReplay } from 'rxjs/operators';
import { DomainCatalogService } from './catalog.service';
@Injectable()
export class DomainCatalogThumbnailService {
constructor(private domainCatalogService: DomainCatalogService) {}
@memorize()
getThumnaulUrl({ ean, height, width }: { width?: number; height?: number; ean?: string }) {
return this.domainCatalogService.getSettings().pipe(
map((settings) => {
let thumbnailUrl = settings.imageUrl.replace(/{ean}/, ean);
return thumbnailUrl;
}),
shareReplay(),
);
}
}
import { Injectable } from '@angular/core';
import { memorize } from '@utils/common';
import { map, shareReplay } from 'rxjs/operators';
import { DomainCatalogService } from './catalog.service';
@Injectable({ providedIn: 'root' })
export class DomainCatalogThumbnailService {
constructor(private domainCatalogService: DomainCatalogService) {}
@memorize()
getThumnaulUrl({
ean,
height,
width,
}: {
width?: number;
height?: number;
ean?: string;
}) {
return this.domainCatalogService.getSettings().pipe(
map((settings) => {
const thumbnailUrl = settings.imageUrl.replace(/{ean}/, ean);
return thumbnailUrl;
}),
shareReplay(),
);
}
}

View File

@@ -1,29 +1,15 @@
import { ModuleWithProviders, NgModule } from '@angular/core';
import { StoreModule } from '@ngrx/store';
import { DomainCheckoutService } from './checkout.service';
import { domainCheckoutReducer } from './store/domain-checkout.reducer';
import { storeFeatureName } from './store/domain-checkout.state';
import { EffectsModule } from '@ngrx/effects';
import { DomainCheckoutEffects } from './store/domain-checkout.effects';
@NgModule({
declarations: [],
imports: [StoreModule.forFeature(storeFeatureName, domainCheckoutReducer)],
providers: [DomainCheckoutService],
})
export class DomainCheckoutModule {
static forRoot(): ModuleWithProviders<DomainCheckoutModule> {
return {
ngModule: RootDomainCheckoutModule,
providers: [DomainCheckoutService],
};
}
}
@NgModule({
imports: [
StoreModule.forFeature(storeFeatureName, domainCheckoutReducer),
EffectsModule.forFeature([DomainCheckoutEffects]),
],
})
export class RootDomainCheckoutModule {}
import { EnvironmentProviders, makeEnvironmentProviders } from '@angular/core';
import { provideEffects } from '@ngrx/effects';
import { provideState } from '@ngrx/store';
import { DomainCheckoutService } from './checkout.service';
import { DomainCheckoutEffects } from './store/domain-checkout.effects';
import { domainCheckoutReducer } from './store/domain-checkout.reducer';
import { storeFeatureName } from './store/domain-checkout.state';
export function provideDomainCheckout(): EnvironmentProviders {
return makeEnvironmentProviders([
provideState({ name: storeFeatureName, reducer: domainCheckoutReducer }),
provideEffects(DomainCheckoutEffects),
DomainCheckoutService,
]);
}

View File

@@ -1071,7 +1071,7 @@ export class DomainCheckoutService {
});
} else if (orderType === 'B2B-Versand') {
const branch = await this.applicationService
.getSelectedBranch$(processId)
.getSelectedBranch$()
.pipe(first())
.toPromise();
availability$ =
@@ -1251,7 +1251,12 @@ export class DomainCheckoutService {
await this.updateItemInShoppingCart({
processId,
update: { availability },
update: {
availability: {
...availability,
price: item?.availability?.price ?? availability?.price,
}, // #5488 After Refreshing Availabilities in Cart make sure to keep the original selected price from purchasing options modal
},
shoppingCartItemId: item.id,
}).toPromise();

View File

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

View File

@@ -1,11 +1,11 @@
import { Injectable } from '@angular/core';
import { InfoService } from '@generated/swagger/isa-api';
@Injectable()
export class DomainDashboardService {
constructor(private readonly _infoService: InfoService) {}
feed() {
return this._infoService.InfoInfo({});
}
}
import { Injectable } from '@angular/core';
import { InfoService } from '@generated/swagger/isa-api';
@Injectable({ providedIn: 'root' })
export class DomainDashboardService {
constructor(private readonly _infoService: InfoService) {}
feed() {
return this._infoService.InfoInfo({});
}
}

View File

@@ -1,12 +0,0 @@
import { ModuleWithProviders, NgModule } from '@angular/core';
import { DomainDashboardService } from './dashboard.service';
@NgModule({})
export class DomainIsaModule {
static forRoot(): ModuleWithProviders<DomainIsaModule> {
return {
ngModule: DomainIsaModule,
providers: [DomainDashboardService],
};
}
}

View File

@@ -1,3 +1,2 @@
export * from './dashboard.service';
export * from './defs';
export * from './domain-isa.module';

View File

@@ -1,116 +1,130 @@
import { Injectable } from '@angular/core';
import { AbholfachService, AutocompleteTokenDTO, QueryTokenDTO } from '@generated/swagger/oms-api';
import { DateAdapter } from '@ui/common';
import { memorize } from '@utils/common';
import { shareReplay } from 'rxjs/operators';
@Injectable()
export class DomainGoodsService {
constructor(
private abholfachService: AbholfachService,
private dateAdapter: DateAdapter,
) {}
searchWareneingang(queryToken: QueryTokenDTO) {
return this.abholfachService.AbholfachWareneingang(queryToken);
}
searchWarenausgabe(queryToken: QueryTokenDTO) {
return this.abholfachService.AbholfachWarenausgabe(queryToken);
}
wareneingangComplete(autocompleteToken: AutocompleteTokenDTO) {
return this.abholfachService.AbholfachWareneingangAutocomplete(autocompleteToken);
}
warenausgabeComplete(autocompleteToken: AutocompleteTokenDTO) {
return this.abholfachService.AbholfachWarenausgabeAutocomplete(autocompleteToken);
}
getWareneingangItemByOrderNumber(orderNumber: string) {
return this.abholfachService.AbholfachWareneingang({
filter: { all_branches: 'true', archive: 'true' },
input: {
qs: orderNumber,
},
});
}
getWarenausgabeItemByOrderNumber(orderNumber: string, archive: boolean) {
return this.abholfachService.AbholfachWarenausgabe({
filter: { all_branches: 'true', archive: `${archive}` },
input: {
qs: orderNumber,
},
});
}
getWarenausgabeItemByCompartment(compartmentCode: string, archive: boolean) {
return this.abholfachService.AbholfachWarenausgabe({
filter: { all_branches: 'true', archive: `${archive}` },
input: {
qs: compartmentCode,
},
});
}
getWareneingangItemByCustomerNumber(customerNumber: string) {
// Suche anhand der Kundennummer mit Status Bestellt, nachbestellt, eingetroffen, weitergeleitet intern
return this.abholfachService.AbholfachWareneingang({
filter: { orderitemprocessingstatus: '16;128;8192;1048576' },
input: {
customer_no: customerNumber,
},
});
}
list() {
const base = this.dateAdapter.today();
const startDate = this.dateAdapter.addCalendarDays(base, -5);
const endDate = this.dateAdapter.addCalendarDays(base, 1);
const queryToken: QueryTokenDTO = {
filter: {
orderitemprocessingstatus: '16;8192;1024;512;2048',
estimatedshippingdate: `"${startDate.toJSON()}"-"${endDate.toJSON()}"`,
},
orderBy: [{ by: 'estimatedshippingdate' }],
skip: 0,
take: 20,
};
return this.searchWareneingang(queryToken);
}
@memorize()
goodsInQuerySettings() {
return this.abholfachService.AbholfachWareneingangQuerySettings().pipe(shareReplay());
}
@memorize()
goodsOutQuerySettings() {
return this.abholfachService.AbholfachWarenausgabeQuerySettings().pipe(shareReplay());
}
goodsInList(queryToken: QueryTokenDTO) {
return this.abholfachService.AbholfachWareneingangsliste(queryToken);
}
@memorize()
goodsInListQuerySettings() {
return this.abholfachService.AbholfachWareneingangslisteQuerySettings().pipe(shareReplay());
}
goodsInCleanupList() {
return this.abholfachService.AbholfachAbholfachbereinigungsliste();
}
goodsInReservationList(queryToken: QueryTokenDTO) {
return this.abholfachService.AbholfachReservierungen(queryToken);
}
goodsInRemissionPreviewList() {
return this.abholfachService.AbholfachAbholfachremissionsvorschau();
}
createGoodsInRemissionFromPreviewList() {
return this.abholfachService.AbholfachCreateAbholfachremission();
}
}
import { Injectable } from '@angular/core';
import {
AbholfachService,
AutocompleteTokenDTO,
QueryTokenDTO,
} from '@generated/swagger/oms-api';
import { DateAdapter } from '@ui/common';
import { memorize } from '@utils/common';
import { shareReplay } from 'rxjs/operators';
@Injectable({ providedIn: 'root' })
export class DomainGoodsService {
constructor(
private abholfachService: AbholfachService,
private dateAdapter: DateAdapter,
) {}
searchWareneingang(queryToken: QueryTokenDTO) {
return this.abholfachService.AbholfachWareneingang(queryToken);
}
searchWarenausgabe(queryToken: QueryTokenDTO) {
return this.abholfachService.AbholfachWarenausgabe(queryToken);
}
wareneingangComplete(autocompleteToken: AutocompleteTokenDTO) {
return this.abholfachService.AbholfachWareneingangAutocomplete(
autocompleteToken,
);
}
warenausgabeComplete(autocompleteToken: AutocompleteTokenDTO) {
return this.abholfachService.AbholfachWarenausgabeAutocomplete(
autocompleteToken,
);
}
getWareneingangItemByOrderNumber(orderNumber: string) {
return this.abholfachService.AbholfachWareneingang({
filter: { all_branches: 'true', archive: 'true' },
input: {
qs: orderNumber,
},
});
}
getWarenausgabeItemByOrderNumber(orderNumber: string, archive: boolean) {
return this.abholfachService.AbholfachWarenausgabe({
filter: { all_branches: 'true', archive: `${archive}` },
input: {
qs: orderNumber,
},
});
}
getWarenausgabeItemByCompartment(compartmentCode: string, archive: boolean) {
return this.abholfachService.AbholfachWarenausgabe({
filter: { all_branches: 'true', archive: `${archive}` },
input: {
qs: compartmentCode,
},
});
}
getWareneingangItemByCustomerNumber(customerNumber: string) {
// Suche anhand der Kundennummer mit Status Bestellt, nachbestellt, eingetroffen, weitergeleitet intern
return this.abholfachService.AbholfachWareneingang({
filter: { orderitemprocessingstatus: '16;128;8192;1048576' },
input: {
customer_no: customerNumber,
},
});
}
list() {
const base = this.dateAdapter.today();
const startDate = this.dateAdapter.addCalendarDays(base, -5);
const endDate = this.dateAdapter.addCalendarDays(base, 1);
const queryToken: QueryTokenDTO = {
filter: {
orderitemprocessingstatus: '16;8192;1024;512;2048',
estimatedshippingdate: `"${startDate.toJSON()}"-"${endDate.toJSON()}"`,
},
orderBy: [{ by: 'estimatedshippingdate' }],
skip: 0,
take: 20,
};
return this.searchWareneingang(queryToken);
}
@memorize()
goodsInQuerySettings() {
return this.abholfachService
.AbholfachWareneingangQuerySettings()
.pipe(shareReplay());
}
@memorize()
goodsOutQuerySettings() {
return this.abholfachService
.AbholfachWarenausgabeQuerySettings()
.pipe(shareReplay());
}
goodsInList(queryToken: QueryTokenDTO) {
return this.abholfachService.AbholfachWareneingangsliste(queryToken);
}
@memorize()
goodsInListQuerySettings() {
return this.abholfachService
.AbholfachWareneingangslisteQuerySettings()
.pipe(shareReplay());
}
goodsInCleanupList() {
return this.abholfachService.AbholfachAbholfachbereinigungsliste();
}
goodsInReservationList(queryToken: QueryTokenDTO) {
return this.abholfachService.AbholfachReservierungen(queryToken);
}
goodsInRemissionPreviewList() {
return this.abholfachService.AbholfachAbholfachremissionsvorschau();
}
createGoodsInRemissionFromPreviewList() {
return this.abholfachService.AbholfachCreateAbholfachremission();
}
}

View File

@@ -2,6 +2,5 @@ export * from './action-handler-services';
export * from './action-handlers';
export * from './customer-order.service';
export * from './goods.service';
export * from './oms.module';
export * from './oms.service';
export * from './receipt.service';

View File

@@ -1,14 +0,0 @@
import { ModuleWithProviders, NgModule } from '@angular/core';
import { DomainGoodsService } from './goods.service';
import { DomainOmsService } from './oms.service';
import { DomainReceiptService } from './receipt.service';
@NgModule()
export class DomainOmsModule {
static forRoot(): ModuleWithProviders<DomainOmsModule> {
return {
ngModule: DomainOmsModule,
providers: [DomainOmsService, DomainGoodsService, DomainReceiptService],
};
}
}

View File

@@ -1,316 +1,381 @@
import { Injectable } from '@angular/core';
import {
BranchService,
BuyerDTO,
ChangeStockStatusCodeValues,
HistoryDTO,
NotificationChannel,
OrderCheckoutService,
OrderDTO,
OrderItemDTO,
OrderItemSubsetDTO,
OrderListItemDTO,
OrderService,
ReceiptService,
StatusValues,
StockStatusCodeService,
ValueTupleOfLongAndReceiptTypeAndEntityDTOContainerOfReceiptDTO,
ValueTupleOfOrderItemSubsetDTOAndOrderItemSubsetDTO,
VATService,
} from '@generated/swagger/oms-api';
import { memorize } from '@utils/common';
import { Observable } from 'rxjs';
import { map, shareReplay } from 'rxjs/operators';
@Injectable()
export class DomainOmsService {
constructor(
private orderService: OrderService,
private receiptService: ReceiptService,
private branchService: BranchService,
private vatService: VATService,
private stockStatusCodeService: StockStatusCodeService,
private _orderCheckoutService: OrderCheckoutService,
) {}
getOrderItemsByCustomerNumber(customerNumber: string, skip: number): Observable<OrderListItemDTO[]> {
return this.orderService
.OrderGetOrdersByBuyerNumber({ buyerNumber: customerNumber, take: 20, skip })
.pipe(map((orders) => orders.result));
}
getOrder(orderId: number): Observable<OrderDTO> {
return this.orderService.OrderGetOrder(orderId).pipe(map((o) => o.result));
}
getBranches() {
return this.branchService.BranchGetBranches({});
}
getHistory(orderItemSubsetId: number): Observable<HistoryDTO[]> {
return this.orderService
.OrderGetOrderItemStatusHistory({ orderItemSubsetId })
.pipe(map((response) => response.result));
}
getReceipts(
orderItemSubsetIds: number[],
): Observable<ValueTupleOfLongAndReceiptTypeAndEntityDTOContainerOfReceiptDTO[]> {
return this.receiptService
.ReceiptGetReceiptsByOrderItemSubset({
payload: {
receiptType: 65 as unknown as any,
ids: orderItemSubsetIds,
eagerLoading: 1,
},
})
.pipe(map((response) => response.result));
}
getReorderReasons() {
return this._orderCheckoutService.OrderCheckoutGetReorderReasons().pipe(map((response) => response.result));
}
@memorize()
getVATs() {
return this.vatService.VATGetVATs({}).pipe(map((response) => response.result));
}
// ttl 4 Stunden
@memorize({ ttl: 14400000 })
getStockStatusCodes({ supplierId, eagerLoading = 0 }: { supplierId: number; eagerLoading?: number }) {
return this.stockStatusCodeService.StockStatusCodeGetStockStatusCodes({ supplierId, eagerLoading }).pipe(
map((response) => response.result),
shareReplay(),
);
}
patchOrderItem(payload: { orderItemId: number; orderId: number; orderItem: Partial<OrderItemDTO> }) {
return this.orderService.OrderPatchOrderItem(payload).pipe(map((response) => response.result));
}
patchOrderItemSubset(payload: {
orderItemSubsetId: number;
orderItemId: number;
orderId: number;
orderItemSubset: Partial<OrderItemSubsetDTO>;
}) {
return this.orderService.OrderPatchOrderItemSubset(payload).pipe(map((response) => response.result));
}
patchComment({
orderId,
orderItemId,
orderItemSubsetId,
specialComment,
}: {
orderId: number;
orderItemId: number;
orderItemSubsetId: number;
specialComment: string;
}) {
return this.orderService
.OrderPatchOrderItemSubset({
orderId,
orderItemId,
orderItemSubsetId,
orderItemSubset: {
specialComment,
},
})
.pipe(map((response) => response.result));
}
changeOrderStatus(
orderId: number,
orderItemId: number,
orderItemSubsetId: number,
data: StatusValues,
): Observable<ValueTupleOfOrderItemSubsetDTOAndOrderItemSubsetDTO> {
return this.orderService
.OrderChangeStatus({
data,
orderId,
orderItemId,
orderItemSubsetId,
})
.pipe(map((o) => o.result));
}
setEstimatedShippingDate(
orderId: number,
orderItemId: number,
orderItemSubsetId: number,
estimatedShippingDate: Date | string,
) {
return this.orderService
.OrderPatchOrderItemSubset({
orderId,
orderItemId,
orderItemSubsetId,
orderItemSubset: {
estimatedShippingDate:
estimatedShippingDate instanceof Date ? estimatedShippingDate.toJSON() : estimatedShippingDate,
},
})
.pipe(map((response) => response.result));
}
setPickUpDeadline(orderId: number, orderItemId: number, orderItemSubsetId: number, pickUpDeadline: string) {
return this.orderService
.OrderPatchOrderItemSubset({
orderId,
orderItemId,
orderItemSubsetId,
orderItemSubset: {
compartmentStop: pickUpDeadline,
},
})
.pipe(map((response) => response.result));
}
setPreferredPickUpDate({ data }: { data: { [key: string]: string } }) {
return this.orderService.OrderSetPreferredPickUpDate({ data });
}
changeOrderItemStatus(data: OrderService.OrderChangeStatusParams) {
return this.orderService.OrderChangeStatus(data);
}
changeStockStatusCode(payload: ChangeStockStatusCodeValues[]) {
return this.orderService.OrderChangeStockStatusCode(payload).pipe(map((response) => response.result));
}
orderAtSupplier({
orderId,
orderItemId,
orderItemSubsetId,
}: {
orderId: number;
orderItemId: number;
orderItemSubsetId: number;
}) {
return this._orderCheckoutService.OrderCheckoutOrderSubsetItemAtSupplier({
orderId,
orderItemId,
orderItemSubsetId,
});
}
getNotifications(orderId: number): Observable<{ selected: NotificationChannel; email: string; mobile: string }> {
return this.getOrder(orderId).pipe(
map((order) => ({
selected: order.notificationChannels,
email: order.buyer?.communicationDetails?.email,
mobile: order.buyer?.communicationDetails?.mobile,
})),
);
}
getOrderSource(orderId: number): Observable<string> {
return this.getOrder(orderId).pipe(map((order) => order?.features?.orderSource));
}
updateNotifications(orderId: number, changes: { selected: NotificationChannel; email: string; mobile: string }) {
const communicationDetails = {
email: changes.email,
mobile: changes.mobile,
};
if (!(changes.selected & 1)) {
delete communicationDetails.email;
}
if (!(changes.selected & 2)) {
delete communicationDetails.mobile;
}
return this.updateOrder({ orderId, notificationChannels: changes.selected, communicationDetails });
}
updateOrder({
orderId,
notificationChannels,
communicationDetails,
firstName,
lastName,
organisation,
}: {
orderId: number;
notificationChannels?: NotificationChannel;
communicationDetails?: { email?: string; mobile?: string };
lastName?: string;
firstName?: string;
organisation?: string;
}) {
const buyer: BuyerDTO = {};
if (communicationDetails) {
buyer.communicationDetails = { ...communicationDetails };
}
if (!!lastName || !!firstName) {
buyer.lastName = lastName;
buyer.firstName = firstName;
}
if (!!organisation && !!buyer.organisation) {
buyer.organisation = {
name: organisation,
};
}
return this.orderService
.OrderPatchOrder({
orderId: orderId,
order: {
notificationChannels,
buyer,
},
})
.pipe(map((res) => res.result));
}
generateNotifications({ orderId, taskTypes }: { orderId: number; taskTypes: string[] }) {
return this.orderService.OrderRegenerateOrderItemStatusTasks({
orderId,
taskTypes,
});
}
getCompletedTasks({
orderId,
orderItemId,
orderItemSubsetId,
take,
skip,
}: {
orderId: number;
orderItemId: number;
orderItemSubsetId: number;
take?: number;
skip?: number;
}): Observable<Record<string, Date[]>> {
return this.orderService
.OrderGetOrderItemSubsetTasks({
orderId,
orderItemId,
orderItemSubsetId,
completed: new Date(0).toISOString(),
take,
skip,
})
.pipe(
map((res) =>
res.result
.sort((a, b) => new Date(b.completed).getTime() - new Date(a.completed).getTime())
.reduce(
(data, result) => {
(data[result.name] = data[result.name] || []).push(new Date(result.completed));
return data;
},
{} as Record<string, Date[]>,
),
),
);
}
}
import { Injectable } from '@angular/core';
import {
BranchService,
BuyerDTO,
ChangeStockStatusCodeValues,
HistoryDTO,
NotificationChannel,
OrderCheckoutService,
OrderDTO,
OrderItemDTO,
OrderItemSubsetDTO,
OrderListItemDTO,
OrderService,
ReceiptService,
StatusValues,
StockStatusCodeService,
ValueTupleOfLongAndReceiptTypeAndEntityDTOContainerOfReceiptDTO,
ValueTupleOfOrderItemSubsetDTOAndOrderItemSubsetDTO,
VATService,
} from '@generated/swagger/oms-api';
import { memorize } from '@utils/common';
import { Observable } from 'rxjs';
import { map, shareReplay } from 'rxjs/operators';
@Injectable({ providedIn: 'root' })
export class DomainOmsService {
constructor(
private orderService: OrderService,
private receiptService: ReceiptService,
private branchService: BranchService,
private vatService: VATService,
private stockStatusCodeService: StockStatusCodeService,
private _orderCheckoutService: OrderCheckoutService,
) {}
getOrderItemsByCustomerNumber(
customerNumber: string,
skip: number,
): Observable<OrderListItemDTO[]> {
return this.orderService
.OrderGetOrdersByBuyerNumber({
buyerNumber: customerNumber,
take: 20,
skip,
})
.pipe(map((orders) => orders.result));
}
getOrder(orderId: number): Observable<OrderDTO> {
return this.orderService.OrderGetOrder(orderId).pipe(map((o) => o.result));
}
getBranches() {
return this.branchService.BranchGetBranches({});
}
getHistory(orderItemSubsetId: number): Observable<HistoryDTO[]> {
return this.orderService
.OrderGetOrderItemStatusHistory({ orderItemSubsetId })
.pipe(map((response) => response.result));
}
getReceipts(
orderItemSubsetIds: number[],
): Observable<
ValueTupleOfLongAndReceiptTypeAndEntityDTOContainerOfReceiptDTO[]
> {
return this.receiptService
.ReceiptGetReceiptsByOrderItemSubset({
payload: {
receiptType: 65 as unknown as any,
ids: orderItemSubsetIds,
eagerLoading: 1,
},
})
.pipe(map((response) => response.result));
}
getReorderReasons() {
return this._orderCheckoutService
.OrderCheckoutGetReorderReasons()
.pipe(map((response) => response.result));
}
@memorize()
getVATs() {
return this.vatService
.VATGetVATs({})
.pipe(map((response) => response.result));
}
// ttl 4 Stunden
@memorize({ ttl: 14400000 })
getStockStatusCodes({
supplierId,
eagerLoading = 0,
}: {
supplierId: number;
eagerLoading?: number;
}) {
return this.stockStatusCodeService
.StockStatusCodeGetStockStatusCodes({ supplierId, eagerLoading })
.pipe(
map((response) => response.result),
shareReplay(),
);
}
patchOrderItem(payload: {
orderItemId: number;
orderId: number;
orderItem: Partial<OrderItemDTO>;
}) {
return this.orderService
.OrderPatchOrderItem(payload)
.pipe(map((response) => response.result));
}
patchOrderItemSubset(payload: {
orderItemSubsetId: number;
orderItemId: number;
orderId: number;
orderItemSubset: Partial<OrderItemSubsetDTO>;
}) {
return this.orderService
.OrderPatchOrderItemSubset(payload)
.pipe(map((response) => response.result));
}
patchComment({
orderId,
orderItemId,
orderItemSubsetId,
specialComment,
}: {
orderId: number;
orderItemId: number;
orderItemSubsetId: number;
specialComment: string;
}) {
return this.orderService
.OrderPatchOrderItemSubset({
orderId,
orderItemId,
orderItemSubsetId,
orderItemSubset: {
specialComment,
},
})
.pipe(map((response) => response.result));
}
changeOrderStatus(
orderId: number,
orderItemId: number,
orderItemSubsetId: number,
data: StatusValues,
): Observable<ValueTupleOfOrderItemSubsetDTOAndOrderItemSubsetDTO> {
return this.orderService
.OrderChangeStatus({
data,
orderId,
orderItemId,
orderItemSubsetId,
})
.pipe(map((o) => o.result));
}
setEstimatedShippingDate(
orderId: number,
orderItemId: number,
orderItemSubsetId: number,
estimatedShippingDate: Date | string,
) {
return this.orderService
.OrderPatchOrderItemSubset({
orderId,
orderItemId,
orderItemSubsetId,
orderItemSubset: {
estimatedShippingDate:
estimatedShippingDate instanceof Date
? estimatedShippingDate.toJSON()
: estimatedShippingDate,
},
})
.pipe(map((response) => response.result));
}
setPickUpDeadline(
orderId: number,
orderItemId: number,
orderItemSubsetId: number,
pickUpDeadline: string,
) {
return this.orderService
.OrderPatchOrderItemSubset({
orderId,
orderItemId,
orderItemSubsetId,
orderItemSubset: {
compartmentStop: pickUpDeadline,
},
})
.pipe(map((response) => response.result));
}
setPreferredPickUpDate({ data }: { data: { [key: string]: string } }) {
return this.orderService.OrderSetPreferredPickUpDate({ data });
}
changeOrderItemStatus(data: OrderService.OrderChangeStatusParams) {
return this.orderService.OrderChangeStatus(data);
}
changeStockStatusCode(payload: ChangeStockStatusCodeValues[]) {
return this.orderService
.OrderChangeStockStatusCode(payload)
.pipe(map((response) => response.result));
}
orderAtSupplier({
orderId,
orderItemId,
orderItemSubsetId,
}: {
orderId: number;
orderItemId: number;
orderItemSubsetId: number;
}) {
return this._orderCheckoutService.OrderCheckoutOrderSubsetItemAtSupplier({
orderId,
orderItemId,
orderItemSubsetId,
});
}
getNotifications(
orderId: number,
): Observable<{
selected: NotificationChannel;
email: string;
mobile: string;
}> {
return this.getOrder(orderId).pipe(
map((order) => ({
selected: order.notificationChannels,
email: order.buyer?.communicationDetails?.email,
mobile: order.buyer?.communicationDetails?.mobile,
})),
);
}
getOrderSource(orderId: number): Observable<string> {
return this.getOrder(orderId).pipe(
map((order) => order?.features?.orderSource),
);
}
updateNotifications(
orderId: number,
changes: { selected: NotificationChannel; email: string; mobile: string },
) {
const communicationDetails = {
email: changes.email,
mobile: changes.mobile,
};
if (!(changes.selected & 1)) {
delete communicationDetails.email;
}
if (!(changes.selected & 2)) {
delete communicationDetails.mobile;
}
return this.updateOrder({
orderId,
notificationChannels: changes.selected,
communicationDetails,
});
}
updateOrder({
orderId,
notificationChannels,
communicationDetails,
firstName,
lastName,
organisation,
}: {
orderId: number;
notificationChannels?: NotificationChannel;
communicationDetails?: { email?: string; mobile?: string };
lastName?: string;
firstName?: string;
organisation?: string;
}) {
const buyer: BuyerDTO = {};
if (communicationDetails) {
buyer.communicationDetails = { ...communicationDetails };
}
if (!!lastName || !!firstName) {
buyer.lastName = lastName;
buyer.firstName = firstName;
}
if (!!organisation && !!buyer.organisation) {
buyer.organisation = {
name: organisation,
};
}
return this.orderService
.OrderPatchOrder({
orderId: orderId,
order: {
notificationChannels,
buyer,
},
})
.pipe(map((res) => res.result));
}
generateNotifications({
orderId,
taskTypes,
}: {
orderId: number;
taskTypes: string[];
}) {
return this.orderService.OrderRegenerateOrderItemStatusTasks({
orderId,
taskTypes,
});
}
getCompletedTasks({
orderId,
orderItemId,
orderItemSubsetId,
take,
skip,
}: {
orderId: number;
orderItemId: number;
orderItemSubsetId: number;
take?: number;
skip?: number;
}): Observable<Record<string, Date[]>> {
return this.orderService
.OrderGetOrderItemSubsetTasks({
orderId,
orderItemId,
orderItemSubsetId,
completed: new Date(0).toISOString(),
take,
skip,
})
.pipe(
map((res) =>
res.result
.sort(
(a, b) =>
new Date(b.completed).getTime() -
new Date(a.completed).getTime(),
)
.reduce(
(data, result) => {
(data[result.name] = data[result.name] || []).push(
new Date(result.completed),
);
return data;
},
{} as Record<string, Date[]>,
),
),
);
}
}

View File

@@ -1,22 +1,25 @@
import { Injectable } from '@angular/core';
import { ReceiptOrderItemSubsetReferenceValues, ReceiptService } from '@generated/swagger/oms-api';
import { memorize } from '@utils/common';
import { shareReplay } from 'rxjs/operators';
@Injectable()
export class DomainReceiptService {
constructor(private receiptService: ReceiptService) {}
createShippingNotes(params: ReceiptService.ReceiptCreateShippingNote2Params) {
return this.receiptService.ReceiptCreateShippingNote2(params);
}
@memorize({ ttl: 1000 })
getReceipts(payload: ReceiptOrderItemSubsetReferenceValues) {
return this.receiptService
.ReceiptGetReceiptsByOrderItemSubset({
payload: payload,
})
.pipe(shareReplay(1));
}
}
import { Injectable } from '@angular/core';
import {
ReceiptOrderItemSubsetReferenceValues,
ReceiptService,
} from '@generated/swagger/oms-api';
import { memorize } from '@utils/common';
import { shareReplay } from 'rxjs/operators';
@Injectable({ providedIn: 'root' })
export class DomainReceiptService {
constructor(private receiptService: ReceiptService) {}
createShippingNotes(params: ReceiptService.ReceiptCreateShippingNote2Params) {
return this.receiptService.ReceiptCreateShippingNote2(params);
}
@memorize({ ttl: 1000 })
getReceipts(payload: ReceiptOrderItemSubsetReferenceValues) {
return this.receiptService
.ReceiptGetReceiptsByOrderItemSubset({
payload: payload,
})
.pipe(shareReplay(1));
}
}

View File

@@ -1,4 +1,3 @@
export * from './defs';
export * from './mappings';
export * from './remission.module';
export * from './remission.service';

View File

@@ -1,17 +0,0 @@
import { ModuleWithProviders, NgModule } from '@angular/core';
import { DomainRemissionService } from './remission.service';
@NgModule({})
export class DomainRemissionModule {
static forRoot(): ModuleWithProviders<DomainRemissionModule> {
return {
ngModule: RootDomainRemissionModule,
};
}
}
@NgModule({
imports: [],
providers: [DomainRemissionService],
})
export class RootDomainRemissionModule {}

View File

File diff suppressed because it is too large Load Diff

View File

@@ -1,32 +1,43 @@
import { enableProdMode, isDevMode } from "@angular/core";
import { platformBrowserDynamic } from "@angular/platform-browser-dynamic";
import { CONFIG_DATA } from "@isa/core/config";
import { setDefaultOptions } from "date-fns";
import { de } from "date-fns/locale";
import * as moment from "moment";
import "moment/locale/de";
setDefaultOptions({ locale: de });
moment.locale("de");
import { AppModule } from "./app/app.module";
if (!isDevMode()) {
enableProdMode();
}
async function bootstrap() {
const configRes = await fetch("/config/config.json");
const config = await configRes.json();
platformBrowserDynamic([
{ provide: CONFIG_DATA, useValue: config },
]).bootstrapModule(AppModule);
}
try {
bootstrap();
} catch (error) {
console.error(error);
}
import { enableProdMode, isDevMode } from '@angular/core';
import { CONFIG_DATA } from '@isa/core/config';
import { setDefaultOptions } from 'date-fns';
import { de } from 'date-fns/locale';
import localeDe from '@angular/common/locales/de';
import localeDeExtra from '@angular/common/locales/extra/de';
import * as moment from 'moment';
import 'moment/locale/de';
setDefaultOptions({ locale: de });
moment.locale('de');
registerLocaleData(localeDe, localeDeExtra);
registerLocaleData(localeDe, 'de', localeDeExtra);
import { App } from './app/app';
import { appConfig } from './app/app.config';
import { bootstrapApplication } from '@angular/platform-browser';
import { registerLocaleData } from '@angular/common';
if (!isDevMode()) {
enableProdMode();
}
async function bootstrap() {
const configRes = await fetch('/config/config.json');
const config = await configRes.json();
await bootstrapApplication(App, {
...appConfig,
providers: [
{ provide: CONFIG_DATA, useValue: config },
...appConfig.providers,
],
});
}
try {
bootstrap();
} catch (error) {
console.error(error);
}

View File

@@ -26,7 +26,7 @@ export class ModalAvailabilitiesComponent {
item = this.modalRef.data.item;
itemId = this.modalRef.data.itemId || this.modalRef.data.item.id;
userbranch$ = combineLatest([
this.applicationService.getSelectedBranch$(this.applicationService.activatedProcessId),
this.applicationService.getSelectedBranch$(),
this.domainAvailabilityService.getDefaultBranch(),
]).pipe(map(([selectedBranch, defaultBranch]) => selectedBranch || defaultBranch));

View File

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

View File

@@ -192,11 +192,7 @@ export class ArticleDetailsComponent implements OnInit, OnDestroy {
}),
);
selectedBranchId$ = this.applicationService.activatedProcessId$.pipe(
switchMap((processId) =>
this.applicationService.getSelectedBranch$(processId),
),
);
selectedBranchId$ = this.applicationService.getSelectedBranch$();
get isTablet$() {
return this._environment.matchTablet$;
@@ -328,7 +324,7 @@ export class ArticleDetailsComponent implements OnInit, OnDestroy {
debounceTime(0),
switchMap((params) =>
this.applicationService
.getSelectedBranch$(Number(params.processId))
.getSelectedBranch$()
.pipe(map((selectedBranch) => ({ params, selectedBranch }))),
),
)

View File

@@ -98,11 +98,9 @@ export class ArticleSearchMainComponent implements OnInit, OnDestroy {
);
this.subscriptions.add(
this.application.activatedProcessId$
.pipe(
debounceTime(0),
switchMap((processId) => this.application.getSelectedBranch$(processId)),
)
this.application
.getSelectedBranch$()
.pipe(debounceTime(0))
.subscribe((selectedBranch) => {
const branchChanged = selectedBranch?.id !== this.searchService?.selectedBranch?.id;
if (branchChanged) {
@@ -143,7 +141,7 @@ export class ArticleSearchMainComponent implements OnInit, OnDestroy {
const clean = { ...params };
for (const key in clean) {
if (key === 'main_qs' || key?.includes('order_by')) {
if (key === 'main_qs') {
clean[key] = undefined;
} else if (key?.includes('order_by')) {
delete clean[key];

View File

@@ -40,7 +40,7 @@ export class SearchResultItemComponent extends ComponentStore<SearchResultItemCo
readonly item$ = this.select((s) => s.item);
@Input() selected: boolean = false;
@Input() selected = false;
@Input()
get selectable() {
@@ -91,9 +91,7 @@ export class SearchResultItemComponent extends ComponentStore<SearchResultItemCo
defaultBranch$ = this._availability.getDefaultBranch();
selectedBranchId$ = this.applicationService.activatedProcessId$.pipe(
switchMap((processId) => this.applicationService.getSelectedBranch$(processId)),
);
selectedBranchId$ = this.applicationService.getSelectedBranch$();
isOrderBranch$ = combineLatest([this.defaultBranch$, this.selectedBranchId$]).pipe(
map(([defaultBranch, selectedBranch]) => {

View File

@@ -157,7 +157,7 @@ export class ArticleSearchResultsComponent
.pipe(
debounceTime(0),
switchMap(([processId, queryParams]) =>
this.application.getSelectedBranch$(processId).pipe(
this.application.getSelectedBranch$().pipe(
map((selectedBranch) => ({
processId,
queryParams,

View File

@@ -3,7 +3,7 @@ import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { RouterModule } from '@angular/router';
import { DomainCatalogModule } from '@domain/catalog';
import { ThumbnailUrlPipe } from '@domain/catalog';
import { UiCommonModule } from '@ui/common';
import { UiIconModule } from '@ui/icon';
import { UiSelectBulletModule } from '@ui/select-bullet';
@@ -26,7 +26,7 @@ import { MatomoModule } from 'ngx-matomo-client';
CommonModule,
FormsModule,
RouterModule,
DomainCatalogModule,
ThumbnailUrlPipe,
UiCommonModule,
UiIconModule,
UiSelectBulletModule,

View File

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

View File

@@ -77,9 +77,7 @@ export class PageCatalogComponent implements OnInit, AfterViewInit, OnDestroy {
ngOnInit() {
this.activatedProcessId$ = this.application.activatedProcessId$.pipe(map((processId) => String(processId)));
this.selectedBranch$ = this.activatedProcessId$.pipe(
switchMap((processId) => this.application.getSelectedBranch$(Number(processId))),
);
this.selectedBranch$ = this.application.getSelectedBranch$();
this.stockTooltipText$ = combineLatest([this.defaultBranch$, this.selectedBranch$]).pipe(
map(([defaultBranch, selectedBranch]) => {

View File

@@ -1,28 +1,28 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { BranchSelectorComponent } from '@shared/components/branch-selector';
import { BreadcrumbModule } from '@shared/components/breadcrumb';
import { ArticleDetailsModule } from './article-details/article-details.module';
import { ArticleSearchModule } from './article-search/article-search.module';
import { PageCatalogRoutingModule } from './page-catalog-routing.module';
import { PageCatalogComponent } from './page-catalog.component';
import { SharedSplitscreenComponent } from '@shared/components/splitscreen';
import { UiCommonModule } from '@ui/common';
import { UiTooltipModule } from '@ui/tooltip';
@NgModule({
imports: [
CommonModule,
PageCatalogRoutingModule,
ArticleSearchModule,
ArticleDetailsModule,
BreadcrumbModule,
BranchSelectorComponent,
SharedSplitscreenComponent,
UiCommonModule,
UiTooltipModule,
],
exports: [],
declarations: [PageCatalogComponent],
})
export class PageCatalogModule {}
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { BranchSelectorComponent } from '@shared/components/branch-selector';
import { BreadcrumbModule } from '@shared/components/breadcrumb';
import { ArticleDetailsModule } from './article-details/article-details.module';
import { ArticleSearchModule } from './article-search/article-search.module';
import { PageCatalogRoutingModule } from './page-catalog-routing.module';
import { PageCatalogComponent } from './page-catalog.component';
import { SharedSplitscreenComponent } from '@shared/components/splitscreen';
import { UiCommonModule } from '@ui/common';
import { UiTooltipModule } from '@ui/tooltip';
@NgModule({
imports: [
CommonModule,
PageCatalogRoutingModule,
ArticleDetailsModule,
ArticleSearchModule,
BreadcrumbModule,
BranchSelectorComponent,
SharedSplitscreenComponent,
UiCommonModule,
UiTooltipModule,
],
exports: [],
declarations: [PageCatalogComponent],
})
export class PageCatalogModule {}

View File

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

View File

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

View File

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

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