mirror of
https://dev.azure.com/hugendubel/ISA/_git/ISA-Frontend
synced 2025-12-28 14:32:10 +01:00
Merged PR 1943: Implementation - Backwards Compatibility Process -> Tabs
Related work items: #5328
This commit is contained in:
committed by
Nino Righi
parent
e6dc08007b
commit
384952413b
438
.github/copilot-instructions.md
vendored
438
.github/copilot-instructions.md
vendored
@@ -1,23 +1,415 @@
|
||||
# Mentor Instructions
|
||||
|
||||
## Introduction
|
||||
|
||||
You are Mentor, an AI assistant focused on ensuring code quality, strict adherence to best practices, and development efficiency. **Your core function is to enforce the coding standards and guidelines established in this workspace.** Your goal is to help me produce professional, maintainable, and high-performing code.
|
||||
|
||||
**Always get the latest official documentation for Angular, Nx, or any related technology before implementing or when answering questions or providing feedback. Use Context7:**
|
||||
|
||||
## Tone and Personality
|
||||
|
||||
Maintain a professional, objective, and direct tone consistently:
|
||||
|
||||
- **Guideline Enforcement & Error Correction:** When code deviates from guidelines or contains errors, provide precise, technical feedback. Clearly state the issue, cite the relevant guideline or principle, and explain the required correction for optimal, maintainable results.
|
||||
- **Technical Consultation:** In discussions about architecture, best practices, or complex coding inquiries, remain formal and analytical. Provide clear, well-reasoned explanations and recommendations grounded in industry standards and the project's specific guidelines.
|
||||
|
||||
## Behavioral Guidelines
|
||||
|
||||
- **Actionable Feedback:** Prioritize constructive, actionable feedback aimed at improving code quality, maintainability, and adherence to standards. Avoid rewriting code; focus on explaining the necessary changes and their rationale based on guidelines.
|
||||
- **Strict Guideline Adherence:** Base _all_ feedback, suggestions, and explanations rigorously on the guidelines documented within this workspace. Cite specific rules and principles consistently.
|
||||
- **Demand Clarity:** If a query or code snippet lacks sufficient detail for a thorough, professional analysis, request clarification.
|
||||
- **Professional Framing:** Frame all feedback objectively, focusing on the technical aspects and the importance of meeting project standards for long-term success.
|
||||
- **Context-Specific Expertise:** Provide specific, context-aware advice tailored to the code or problem, always within the framework of our established guidelines.
|
||||
- **Enforce Standards:** Actively enforce project preferences for Type safety, Clean Code principles, and thorough documentation, as mandated by the workspace guidelines.
|
||||
## ISA Frontend – AI Assistant Working Rules
|
||||
|
||||
Concise, project-specific guidance so an AI agent can be productive quickly. Focus on THESE patterns; avoid generic boilerplate.
|
||||
|
||||
### 1. Monorepo & Tooling
|
||||
|
||||
- Nx workspace (Angular 20 + Libraries under `libs/**`, main app `apps/isa-app`).
|
||||
- Scripts (see `package.json`):
|
||||
- Dev serve: `npm start` (=> `nx serve isa-app --ssl`).
|
||||
- Library tests (exclude app): `npm test` (Jest + emerging Vitest). CI uses `npm run ci`.
|
||||
- Build dev: `npm run build`; prod: `npm run build-prod`.
|
||||
- Storybook: `npm run storybook`.
|
||||
- Swagger codegen: `npm run generate:swagger` then `npm run fix:files:swagger`.
|
||||
- Default branch in Nx: `develop` (`nx.json: defaultBase`). Use affected commands when adding libs.
|
||||
- Node >=22, TS 5.8, ESLint flat config (`eslint.config.js`).
|
||||
|
||||
### 1.a Project Tree (Detailed Overview)
|
||||
|
||||
```
|
||||
.
|
||||
├─ apps/
|
||||
│ └─ isa-app/ # Main Angular app (Jest). Legacy non-standalone root component pattern.
|
||||
│ ├─ project.json # Build/serve/test targets
|
||||
│ ├─ src/
|
||||
│ │ ├─ main.ts / index.html # Angular bootstrap
|
||||
│ │ ├─ app/main.component.ts # Root component (standalone:false)
|
||||
│ │ ├─ environments/ # Environment files (prod replace)
|
||||
│ │ ├─ assets/ # Static assets
|
||||
│ │ └─ config/ # Runtime config JSON (read via Config service)
|
||||
│ └─ .storybook/ # App Storybook config
|
||||
│
|
||||
├─ libs/ # All reusable code (grouped by domain / concern)
|
||||
│ ├─ core/ # Cross-cutting infrastructure
|
||||
│ │ ├─ logging/ # Logging service + providers + sinks
|
||||
│ │ │ ├─ src/lib/logging.service.ts
|
||||
│ │ │ ├─ src/lib/logging.providers.ts
|
||||
│ │ │ └─ README.md # Full API & patterns
|
||||
│ │ ├─ config/ # `Config` service (Zod validated lookup)
|
||||
│ │ └─ storage/ # User-scoped storage + signal store feature (`withStorage`)
|
||||
│ │ ├─ src/lib/signal-store-feature.ts
|
||||
│ │ └─ src/lib/storage.ts
|
||||
│ │
|
||||
│ ├─ shared/ # Shared UI/services not domain specific
|
||||
│ │ └─ scanner/ # Scandit integration (tokens, service, components, platform gating)
|
||||
│ │ ├─ src/lib/scanner.service.ts
|
||||
│ │ └─ src/lib/render-if-scanner-is-ready.directive.ts
|
||||
│ │
|
||||
│ ├─ remission/ # Remission domain features (newer pattern; Vitest)
|
||||
│ │ ├─ feature/
|
||||
│ │ │ ├─ remission-return-receipt-details/
|
||||
│ │ │ │ ├─ vite.config.mts # Signals + Vitest example
|
||||
│ │ │ │ └─ src/lib/resources/ # Resource factories (signals async pattern)
|
||||
│ │ │ └─ remission-return-receipt-list/
|
||||
│ │ └─ shared/ # Dialogs / shared remission UI pieces
|
||||
│ │
|
||||
│ ├─ common/ # Cross-domain utilities (decorators, print, data-access)
|
||||
│ ├─ utils/ # Narrow utility libs (ean-validation, z-safe-parse, etc.)
|
||||
│ ├─ ui/ # Generic UI components (presentational)
|
||||
│ ├─ icons/ # Icon sets / wrappers
|
||||
│ ├─ catalogue/ # Domain area (legacy Jest)
|
||||
│ ├─ customer/ # Domain area (legacy Jest)
|
||||
│ └─ oms/ # Domain area (legacy Jest)
|
||||
│
|
||||
├─ generated/swagger/ # Generated API clients (regen via scripts; do not hand edit)
|
||||
├─ tools/ # Helper scripts (e.g. swagger fix script)
|
||||
├─ testresults/ # JUnit XML (jest-junit). CI artifact pickup.
|
||||
├─ coverage/ # Per-project coverage outputs
|
||||
├─ tailwind-plugins/ # Custom Tailwind plugin modules used by `tailwind.config.js`
|
||||
├─ vitest.workspace.ts # Glob enabling multi-lib Vitest detection
|
||||
├─ nx.json / package.json # Workspace + scripts + defaultBase=develop
|
||||
└─ eslint.config.js # Flat ESLint root config
|
||||
```
|
||||
|
||||
Guidelines: create new code in the closest domain folder; expose public API via each lib `src/index.ts`; follow existing naming (`feature-name.type.ts`). Keep generated swagger untouched—extend via wrapper libs if needed.
|
||||
|
||||
### 1.b Import Path Aliases
|
||||
|
||||
Use existing TS path aliases (see `tsconfig.base.json`) instead of long relative paths:
|
||||
|
||||
Core / Cross-cutting:
|
||||
|
||||
- `@isa/core/logging`, `@isa/core/config`, `@isa/core/storage`, `@isa/core/tabs`, `@isa/core/notifications`
|
||||
|
||||
Domain & Features:
|
||||
|
||||
- Catalogue: `@isa/catalogue/data-access`
|
||||
- Customer: `@isa/customer/data-access`
|
||||
- OMS features: `@isa/oms/feature/return-details`, `.../return-process`, `.../return-review`, `.../return-search`, `.../return-summary`
|
||||
- OMS shared: `@isa/oms/shared/product-info`, `@isa/oms/shared/task-list`
|
||||
- Remission: `@isa/remission/data-access`, feature libs (`@isa/remission/feature/remission-return-receipt-details`, `...-list`) and shared (`@isa/remission/shared/remission-start-dialog`, `.../search-item-to-remit-dialog`, `.../return-receipt-actions`, `.../product`)
|
||||
|
||||
Shared / UI:
|
||||
|
||||
- Shared libs: `@isa/shared/scanner`, `@isa/shared/filter`, `@isa/shared/product-image`, `@isa/shared/product-router-link`, `@isa/shared/product-format`
|
||||
- UI components: `@isa/ui/buttons`, `@isa/ui/dialog`, `@isa/ui/input-controls`, `@isa/ui/layout`, `@isa/ui/menu`, `@isa/ui/toolbar`, etc. (one alias per folder under `libs/ui/*`)
|
||||
- Icons: `@isa/icons`
|
||||
|
||||
Utilities:
|
||||
|
||||
- `@isa/utils/ean-validation`, `@isa/utils/z-safe-parse`, `@isa/utils/scroll-position`
|
||||
|
||||
Generated Swagger Clients:
|
||||
|
||||
- `@generated/swagger/isa-api`, `@generated/swagger/oms-api`, `@generated/swagger/inventory-api`, etc. (one per subfolder). Never edit generated sources—wrap in a domain lib if extension needed.
|
||||
|
||||
App-local (only inside `apps/isa-app` context):
|
||||
|
||||
- Namespaced folders: `@adapter/*`, `@domain/*`, `@hub/*`, `@modal/*`, `@page/*`, `@shared/*` (and nested: `@shared/components/*`, `@shared/services/*`, etc.), `@ui/*`, `@utils/*`, `@swagger/*`.
|
||||
|
||||
Patterns:
|
||||
|
||||
- Always add new reusable code as a library then expose via an `@isa/...` alias; do not add new generic code under app-local aliases if it may be reused later.
|
||||
- When introducing a new library ensure its `src/index.ts` re-exports only stable public surface; internal helpers stay un-exported.
|
||||
- For new generated API groups, extend via thin wrappers in a domain `data-access` lib rather than patching generated code.
|
||||
|
||||
### 2. Testing Strategy
|
||||
|
||||
- Legacy tests: Jest (`@nx/jest:jest`). New feature libs (e.g. remission feature) use Vitest + Vite plugin (`vite.config.mts`).
|
||||
- When adding a new library today prefer Vitest unless consistency with existing Jest-only area is required.
|
||||
- Do NOT mix frameworks inside one lib. Check presence of `vite.config.*` to know it is Vitest-enabled.
|
||||
- App (`isa-app`) still uses Jest.
|
||||
|
||||
### 3. Architecture & Cross-Cutting Services
|
||||
|
||||
- Core libraries underpin features: `@isa/core/logging`, `@isa/core/config`, `@isa/core/storage`.
|
||||
- Feature domains grouped (e.g. `libs/remission/**`, `libs/shared/**`, `libs/common/**`). Keep domain-specific code there; UI-only pieces in `ui/` or `shared/`.
|
||||
- Prefer standalone components but some legacy components set `standalone: false` (see `MainComponent`). Maintain existing pattern unless doing a focused migration.
|
||||
|
||||
### 4. Logging (Critical Pattern)
|
||||
|
||||
- Central logging via `@isa/core/logging` (files: `logging.service.ts`, `logging.providers.ts`).
|
||||
- Configure once in app config using provider builders: `provideLogging(withLogLevel(...), withSink(ConsoleLogSink), withContext({...}))`.
|
||||
- Use factory `logger(() => ({ dynamicContext }))` (see README) rather than injecting `LoggingService` directly unless extending framework code.
|
||||
- Context hierarchy: global -> component (`provideLoggerContext`) -> instance (factory param) -> message (callback arg). Always pass context as lazy function `() => ({ ... })` for perf.
|
||||
- Respect log level threshold; do not perform expensive serialization before calling (let sinks handle it or gate behind dev checks).
|
||||
|
||||
### 5. Configuration Access
|
||||
|
||||
- Use `Config` service (`@isa/core/config/src/lib/config.ts`). Fetch values with Zod schema: `config.get('licence.scandit', z.string())` (see `SCANDIT_LICENSE` token). Avoid deprecated untyped access.
|
||||
|
||||
### 6. Storage & State Persistence
|
||||
|
||||
- Storage abstraction: `injectStorage(SomeProvider)` wraps a `StorageProvider` (local/session/indexedDB/custom user storage) and prefixes keys with current authenticated user `sub` (OAuth `sub` fallback 'anonymous').
|
||||
- When adding persisted signal stores, use `withStorage(storageKey, ProviderType)` feature (`signal-store-feature.ts`) to auto debounce-save (1s) + restore on init. Only pass plain serializable state.
|
||||
|
||||
### 7. Signals & State
|
||||
|
||||
- Internal state often via Angular signals & NgRx Signals (`@ngrx/signals`). Avoid manual subscriptions—prefer computed/signals and `rxMethod` for side effects.
|
||||
- When persisting, ensure objects are JSON-safe; validation via Zod if deserializing external data.
|
||||
|
||||
#### 7.a NgRx Signals Deep Dive
|
||||
|
||||
Core building blocks we use:
|
||||
|
||||
- `signalStore(...)` + features: `withState`, `withComputed`, `withMethods`, `withHooks`, `withStorage` (custom feature in `core/storage`).
|
||||
- `rxMethod` (from `@ngrx/signals/rxjs-interop`) to bridge imperative async flows (HTTP calls, debounce, switchMap) into store-driven mutations.
|
||||
- `getState`, `patchState` for immutable, shallow merges; avoid manually mutating nested objects—spread + patch.
|
||||
|
||||
Patterns:
|
||||
|
||||
1. Store Shape: Keep initial state small & serializable (no class instances, functions, DOM nodes). Derive heavy or view-specific projections with `withComputed`.
|
||||
2. Side Effects: Wrap fetch/update flows inside `rxMethod` pipes; ensure cancellation semantics (`switchMap`) to drop stale requests.
|
||||
3. Persistence: Apply `withStorage(key, Provider)` last so hooks run after other features; persisted state must be plain JSON (no Dates—convert to ISO strings). Debounce already handled (1s) in `withStorage`—do NOT add another debounce upstream unless burst traffic is extreme.
|
||||
4. Error Handling: Keep an `error` field in state for presentation; log via `logger()` at Warn/Error levels but do not store full Error object (serialize minimal fields: `message`, maybe `code`).
|
||||
5. Loading Flags: Prefer a boolean `loading` OR a discriminated union `status: 'idle'|'loading'|'success'|'error'` for richer UI; avoid multiple booleans that can drift.
|
||||
6. Computed Selectors: Name as `XComputed` or just semantic (e.g. `filteredItems`) using `computed(() => ...)` inside `withComputed`; never cause side-effects in a computed.
|
||||
7. Resource Factory Pattern: For remote data needed in multiple components, create a factory function returning an object with `value`, `isLoading`, `error` signals plus a `reload()` method; see remission `resources/` directory.
|
||||
|
||||
Store Lifecycle Hooks:
|
||||
|
||||
- Use `withHooks({ onInit() { ... }, onDestroy() { ... } })` for restoration, websockets, or timers. Pair cleanups explicitly.
|
||||
|
||||
Persistence Feature (`withStorage`):
|
||||
|
||||
- Implementation: Debounced `storeState` rxMethod listens to any state change, saves hashed user‑scoped key (see `hash.utils.ts`). On init it calls `restoreState()`.
|
||||
- Extending: If you need to blacklist transient fields from persistence, add a method wrapping `getState` and remove keys before `storage.set` (extend feature locally rather than editing shared code unless broadly needed).
|
||||
|
||||
Typical Store Template:
|
||||
|
||||
```ts
|
||||
// feature-x.store.ts
|
||||
import {
|
||||
signalStore,
|
||||
withState,
|
||||
withComputed,
|
||||
withMethods,
|
||||
withHooks,
|
||||
} from '@ngrx/signals';
|
||||
import { rxMethod } from '@ngrx/signals/rxjs-interop';
|
||||
import { debounceTime, switchMap, tap, catchError, of } from 'rxjs';
|
||||
import { withStorage } from '@isa/core/storage';
|
||||
import { logger } from '@isa/core/logging';
|
||||
|
||||
interface FeatureXState {
|
||||
items: ReadonlyArray<Item>;
|
||||
query: string;
|
||||
loading: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const initialState: FeatureXState = { items: [], query: '', loading: false };
|
||||
|
||||
export const FeatureXStore = signalStore(
|
||||
withState(initialState),
|
||||
withProps((store, logger = logger(() => ({ store: 'FeatureX' }))) => ({
|
||||
_logger: logger,
|
||||
})),
|
||||
withComputed(({ items, query }) => ({
|
||||
filtered: computed(() => items().filter((i) => i.name.includes(query()))),
|
||||
hasError: computed(() => !!query() && !items().length),
|
||||
})),
|
||||
withMethods((store) => ({
|
||||
setQuery: (q: string) => patchState(store, { query: q }),
|
||||
// rxMethod side effect to load items
|
||||
loadItems: rxMethod<string | void>(
|
||||
pipe(
|
||||
debounceTime(150),
|
||||
tap(() => patchState(store, { loading: true, error: undefined })),
|
||||
switchMap(() =>
|
||||
fetchItems(store.query()).pipe(
|
||||
tap((items) => patchState(store, { items, loading: false })),
|
||||
catchError((err) => {
|
||||
store._logger.error('Load failed', err as Error, () => ({
|
||||
query: store.query(),
|
||||
}));
|
||||
patchState(store, {
|
||||
loading: false,
|
||||
error: (err as Error).message,
|
||||
});
|
||||
return of([]);
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
})),
|
||||
withHooks((store) => ({
|
||||
onInit() {
|
||||
store.loadItems();
|
||||
},
|
||||
})),
|
||||
withStorage('feature-x', LocalStorageProvider),
|
||||
);
|
||||
```
|
||||
|
||||
Testing Signal Stores (Vitest or Jest):
|
||||
|
||||
- Use `runInInjectionContext(TestBed.inject(Injector), () => FeatureXStore)` or instantiate via exported factory if provided.
|
||||
- For async rxMethod flows, flush microtasks (`await vi.runAllTimersAsync()` if timers used) or rely on returned observable completion when you subscribe inside the test harness.
|
||||
- Snapshot only primitive slices (avoid full object snapshots with volatile ordering).
|
||||
|
||||
Migration Tips:
|
||||
|
||||
- Converting legacy NgRx reducers: Start by lifting static initial state + selectors into `withState` + `withComputed`; replace effects with `rxMethod` maintaining cancellation semantics (`switchMap` mirrors effect flattening strategy).
|
||||
- Keep action names only if externally observed (analytics, logging). Otherwise remove ceremony—call store methods directly.
|
||||
|
||||
Anti-Patterns to Avoid:
|
||||
|
||||
- Writing to signals inside a computed or inside another signal setter (causes cascading updates).
|
||||
- Storing large unnormalized arrays and then repeatedly filtering/sorting in multiple components—centralize that in computed selectors.
|
||||
- Persisting secrets or PII directly; hash keys already user-scoped but content still plain—sanitize if needed.
|
||||
- Returning raw subscriptions from store methods; expose signals or idempotent methods only.
|
||||
|
||||
#### 7.b Prefer Signals over Observables (Practical Rules)
|
||||
|
||||
Default to signals for all in-memory UI & derived state; keep Observables only at I/O edges.
|
||||
|
||||
Use Observables for:
|
||||
|
||||
- HTTP / WebSocket / SignalR streams at the boundary.
|
||||
- Timer / interval / external event sources.
|
||||
- Interop with legacy NgRx store pieces not yet migrated.
|
||||
|
||||
Immediately convert inbound Observables to signals:
|
||||
|
||||
```ts
|
||||
// Legacy service returning Observable<Item[]>
|
||||
items$ = http.get<Item[]>(url);
|
||||
// New pattern
|
||||
const items = toSignal(http.get<Item[]>(url), { initialValue: [] });
|
||||
```
|
||||
|
||||
Expose signals from stores & services:
|
||||
|
||||
```ts
|
||||
// BAD (forces template async pipe + subscription mgmt)
|
||||
getItems(): Observable<Item[]> { return this.http.get(...); }
|
||||
|
||||
// GOOD
|
||||
items = toSignal(this.http.get<Item[]>(url), { initialValue: [] });
|
||||
```
|
||||
|
||||
Bridge when needed:
|
||||
|
||||
```ts
|
||||
// Signal -> Observable (rare):
|
||||
const queryChanges$ = fromSignal(query, { requireSync: true });
|
||||
|
||||
// Observable -> Signal (preferred):
|
||||
const data = toSignal(data$, { initialValue: undefined });
|
||||
```
|
||||
|
||||
Side-effects: never subscribe manually—wrap in `rxMethod` (cancels stale work via `switchMap`).
|
||||
|
||||
```ts
|
||||
loadData: rxMethod<void>(
|
||||
pipe(
|
||||
switchMap(() =>
|
||||
this.api.fetch().pipe(tap((r) => patchState(store, { data: r }))),
|
||||
),
|
||||
),
|
||||
);
|
||||
```
|
||||
|
||||
Template usage: reference signals directly (`{{ item.name }}`) or in control flow; no `| async` needed.
|
||||
|
||||
Replacing combineLatest / map chains:
|
||||
|
||||
```ts
|
||||
// Before (Observable)
|
||||
vm$ = combineLatest([a$, b$]).pipe(map(([a, b]) => buildVm(a, b)));
|
||||
|
||||
// After (Signals)
|
||||
const vm = computed(() => buildVm(a(), b()));
|
||||
```
|
||||
|
||||
Debounce / throttle user input:
|
||||
Keep raw form value as a signal; create an rxMethod for debounced fetch instead of debouncing inside a computed.
|
||||
|
||||
```ts
|
||||
search = signal('');
|
||||
runSearch: rxMethod<string>(
|
||||
pipe(
|
||||
debounceTime(300),
|
||||
switchMap((term) =>
|
||||
this.api
|
||||
.search(term)
|
||||
.pipe(tap((results) => patchState(store, { results }))),
|
||||
),
|
||||
),
|
||||
);
|
||||
effect(() => {
|
||||
runSearch(this.search());
|
||||
});
|
||||
```
|
||||
|
||||
Avoid converting a signal back to an Observable just to use a single RxJS operator; prefer inline signal `computed` or small helper.
|
||||
|
||||
Migration heuristic:
|
||||
|
||||
1. Identify component `foo$` fields used only in template -> convert to signal via `toSignal`.
|
||||
2. Collapse chains of `combineLatest` + `map` into `computed`.
|
||||
3. Replace imperative `subscribe` side-effects with `rxMethod` + `patchState`.
|
||||
4. Add persistence last via `withStorage` if state must survive reload.
|
||||
|
||||
Performance tip: heavy derived computations (sorting large arrays) belong in a memoized `computed`; if expensive & infrequently needed, gate behind another signal flag.
|
||||
|
||||
### 8. Scanner Integration (Scandit)
|
||||
|
||||
- Barcode scanning encapsulated in `@isa/shared/scanner` (`scanner.service.ts`). Use provided injection tokens for license & defaults (override via DI if needed). Service auto-configures once; `ready` signal triggers `configure()` lazily.
|
||||
- Always catch and log errors with proper context; platform gating throws `PlatformNotSupportedError` which is downgraded to warn.
|
||||
|
||||
### 9. Styling
|
||||
|
||||
- Tailwind with custom semantic tokens (`tailwind.config.js`). Prefer design tokens like `text-isa-neutral-700`, spacing utilities with custom `px-*` scales rather than ad‑hoc raw values.
|
||||
- Global overlays rely on CDK classes; retain `@angular/cdk/overlay-prebuilt.css` in style arrays when creating new entrypoints or Storybook stories.
|
||||
|
||||
### 10. Library Conventions
|
||||
|
||||
- File naming: kebab-case; feature first then type (e.g. `return-receipt-list.component.ts`).
|
||||
- Provide public API via each lib `src/index.ts`. Export only stable symbols; keep internal utilities in subfolders not re-exported.
|
||||
- Add `project.json` with `test` & `lint` targets; for new Vitest libs include `vite.config.mts` and adjust `tsconfig.spec.json` references to vitest types.
|
||||
|
||||
### 11. Adding / Modifying Tests
|
||||
|
||||
- For Jest libs: standard `*.spec.ts` with `TestBed`. Spectator may appear in legacy code—do not introduce Spectator in new tests; use Angular Testing Utilities.
|
||||
- For Vitest libs: ensure `vite.config.mts` includes `setupFiles`. Use `describe/it` from `vitest` and Angular TestBed (see remission resource spec for pattern of using `runInInjectionContext`).
|
||||
- Prefer resource-style factories returning signals for async state (pattern in `createSupplierResource`).
|
||||
|
||||
### 12. Performance & Safety
|
||||
|
||||
- Logging: rely on lazy context function; avoid `JSON.stringify()` unless behind a dev guard.
|
||||
- Storage: hashing keys (see `hash.utils.ts`) ensures stable key space; do not bypass if you need consistent per-user scoping.
|
||||
- Scanner overlay: always clean up overlay + event listeners (follow existing `open` implementation for pattern).
|
||||
|
||||
### 13. CI / Coverage / Artifacts
|
||||
|
||||
- JUnit XML placed in `testresults/` (Jest configured with `jest-junit`). Keep filename stability for pipeline consumption; do not rename those outputs.
|
||||
- Coverage output under `coverage/libs/...`; respect Nx caching—avoid side effects outside project roots.
|
||||
|
||||
### 14. When Unsure
|
||||
|
||||
- Search existing domain folder for analogous implementation (e.g. new feature under remission: inspect sibling feature libs for structure).
|
||||
- Preserve existing DI token patterns instead of introducing new global singletons.
|
||||
|
||||
### 15. Quick Examples
|
||||
|
||||
```ts
|
||||
// New feature logger usage
|
||||
const log = logger(() => ({ feature: 'ReturnReceipt', action: 'init' }));
|
||||
log.info('Mount');
|
||||
|
||||
// Persisting a signal store slice
|
||||
export const FeatureStore = signalStore(
|
||||
withState(initState),
|
||||
withStorage('return:filters', LocalStorageProvider),
|
||||
);
|
||||
|
||||
// Fetch config value safely
|
||||
const apiBase = inject(Config).get('api.baseUrl', z.string().url());
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
Let me know if any area (e.g. auth flow, NgRx usage, Swagger generation details) needs deeper coverage and I can extend this file.
|
||||
|
||||
8
.github/prompts/plan.prompt.md
vendored
8
.github/prompts/plan.prompt.md
vendored
@@ -1,7 +1,8 @@
|
||||
---
|
||||
mode: agent
|
||||
tools: [ 'edit', 'search', 'usages', 'vscodeAPI', 'problems', 'changes', 'fetch', 'githubRepo', 'Nx Mcp Server', 'context7' ]
|
||||
tools: ['edit', 'search', 'usages', 'vscodeAPI', 'problems', 'changes', 'fetch', 'githubRepo', 'Nx Mcp Server', 'context7']
|
||||
description: Plan Mode - Research and create a detailed implementation plan before making any changes.
|
||||
model: Gemini 2.5 Pro (copilot)
|
||||
---
|
||||
|
||||
# Plan Mode
|
||||
@@ -171,6 +172,8 @@ Assistant: I'll add that right away!
|
||||
[Immediately starts editing files - WRONG!]
|
||||
```
|
||||
|
||||
# <<<<<<< HEAD
|
||||
|
||||
## Integration with Existing Copilot Instructions
|
||||
|
||||
This plan mode respects all existing project patterns:
|
||||
@@ -182,4 +185,5 @@ This plan mode respects all existing project patterns:
|
||||
- Adheres to logging and configuration patterns
|
||||
- Maintains library conventions and file naming
|
||||
|
||||
Remember: **RESEARCH FIRST, PLAN THOROUGHLY, WAIT FOR APPROVAL, THEN IMPLEMENT**
|
||||
> > > > > > > develop
|
||||
> > > > > > > Remember: **RESEARCH FIRST, PLAN THOROUGHLY, WAIT FOR APPROVAL, THEN IMPLEMENT**
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { isDevMode, NgModule } from '@angular/core';
|
||||
import { RouterModule, Routes } from '@angular/router';
|
||||
import { inject, isDevMode, NgModule } from '@angular/core';
|
||||
import { Location } from '@angular/common';
|
||||
import { RouterModule, Routes, Router } from '@angular/router';
|
||||
import {
|
||||
CanActivateCartGuard,
|
||||
CanActivateCartWithProcessIdGuard,
|
||||
@@ -30,7 +31,11 @@ import {
|
||||
ActivateProcessIdWithConfigKeyGuard,
|
||||
} from './guards/activate-process-id.guard';
|
||||
import { MatomoRouteData } from 'ngx-matomo-client';
|
||||
import { tabResolverFn } from '@isa/core/tabs';
|
||||
import {
|
||||
tabResolverFn,
|
||||
TabService,
|
||||
TabNavigationService,
|
||||
} from '@isa/core/tabs';
|
||||
import { provideScrollPositionRestoration } from '@isa/utils/scroll-position';
|
||||
|
||||
const routes: Routes = [
|
||||
@@ -227,10 +232,18 @@ if (isDevMode()) {
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
RouterModule.forRoot(routes, { bindToComponentInputs: true }),
|
||||
RouterModule.forRoot(routes, {
|
||||
bindToComponentInputs: true,
|
||||
enableTracing: false,
|
||||
}),
|
||||
TokenLoginModule,
|
||||
],
|
||||
exports: [RouterModule],
|
||||
providers: [provideScrollPositionRestoration()],
|
||||
})
|
||||
export class AppRoutingModule {}
|
||||
export class AppRoutingModule {
|
||||
constructor() {
|
||||
// Loading TabNavigationService to ensure tab state is synced with tab location
|
||||
inject(TabNavigationService);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,238 +1,253 @@
|
||||
import {
|
||||
HTTP_INTERCEPTORS,
|
||||
provideHttpClient,
|
||||
withInterceptorsFromDi,
|
||||
} from '@angular/common/http';
|
||||
import {
|
||||
DEFAULT_CURRENCY_CODE,
|
||||
ErrorHandler,
|
||||
Injector,
|
||||
LOCALE_ID,
|
||||
NgModule,
|
||||
inject,
|
||||
provideAppInitializer,
|
||||
} from '@angular/core';
|
||||
import { BrowserModule } from '@angular/platform-browser';
|
||||
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
|
||||
import { PlatformModule } from '@angular/cdk/platform';
|
||||
|
||||
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 { CoreApplicationModule } from '@core/application';
|
||||
import { AppStoreModule } from './app-store.module';
|
||||
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 { 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';
|
||||
import { IsaErrorHandler } from './providers/isa.error-handler';
|
||||
import {
|
||||
ScanAdapterModule,
|
||||
ScanAdapterService,
|
||||
ScanditScanAdapterModule,
|
||||
} from '@adapter/scan';
|
||||
import { RootStateService } from './store/root-state.service';
|
||||
import * as Commands from './commands';
|
||||
import { PreviewComponent } from './preview';
|
||||
import { NativeContainerService } from '@external/native-container';
|
||||
import { ShellModule } from '@shared/shell';
|
||||
import { MainComponent } from './main.component';
|
||||
import { IconModule } from '@shared/components/icon';
|
||||
import { NgIconsModule } from '@ng-icons/core';
|
||||
import {
|
||||
matClose,
|
||||
matWifi,
|
||||
matWifiOff,
|
||||
} from '@ng-icons/material-icons/baseline';
|
||||
import { NetworkStatusService } from './services/network-status.service';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { provideMatomo } from 'ngx-matomo-client';
|
||||
import { withRouter, withRouteData } from 'ngx-matomo-client';
|
||||
import {
|
||||
provideLogging,
|
||||
withLogLevel,
|
||||
LogLevel,
|
||||
withSink,
|
||||
ConsoleLogSink,
|
||||
} from '@isa/core/logging';
|
||||
|
||||
registerLocaleData(localeDe, localeDeExtra);
|
||||
registerLocaleData(localeDe, 'de', localeDeExtra);
|
||||
|
||||
export function _appInitializerFactory(config: Config, injector: Injector) {
|
||||
return async () => {
|
||||
const statusElement = document.querySelector('#init-status');
|
||||
const laoderElement = document.querySelector('#init-loader');
|
||||
|
||||
try {
|
||||
let online = false;
|
||||
const networkStatus = injector.get(NetworkStatusService);
|
||||
while (!online) {
|
||||
online = await firstValueFrom(networkStatus.online$);
|
||||
|
||||
if (!online) {
|
||||
statusElement.innerHTML =
|
||||
'<b>Warte auf Netzwerkverbindung (WLAN)</b><br><br>Bitte prüfen Sie die Netzwerkverbindung (WLAN).<br>Sobald eine Netzwerkverbindung besteht, wird die App automatisch neu geladen.';
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
}
|
||||
}
|
||||
|
||||
statusElement.innerHTML = 'Konfigurationen werden geladen...';
|
||||
|
||||
statusElement.innerHTML = 'Scanner wird initialisiert...';
|
||||
const scanAdapter = injector.get(ScanAdapterService);
|
||||
await scanAdapter.init();
|
||||
|
||||
statusElement.innerHTML = 'Authentifizierung wird geprüft...';
|
||||
|
||||
const auth = injector.get(AuthService);
|
||||
try {
|
||||
await auth.init();
|
||||
} catch (error) {
|
||||
statusElement.innerHTML = 'Authentifizierung wird durchgeführt...';
|
||||
const strategy = injector.get(LoginStrategy);
|
||||
await strategy.login();
|
||||
}
|
||||
|
||||
statusElement.innerHTML = 'App wird initialisiert...';
|
||||
const state = injector.get(RootStateService);
|
||||
await state.init();
|
||||
|
||||
statusElement.innerHTML = 'Native Container wird initialisiert...';
|
||||
const nativeContainer = injector.get(NativeContainerService);
|
||||
await nativeContainer.init();
|
||||
} catch (error) {
|
||||
laoderElement.remove();
|
||||
statusElement.classList.add('text-xl');
|
||||
statusElement.innerHTML +=
|
||||
'⚡<br><br><b>Fehler bei der Initialisierung</b><br><br>Bitte prüfen Sie die Netzwerkverbindung (WLAN).<br><br>';
|
||||
|
||||
const reload = document.createElement('button');
|
||||
reload.classList.add(
|
||||
'bg-brand',
|
||||
'text-white',
|
||||
'p-2',
|
||||
'rounded',
|
||||
'cursor-pointer',
|
||||
);
|
||||
reload.innerHTML = 'App neu laden';
|
||||
reload.onclick = () => window.location.reload();
|
||||
statusElement.appendChild(reload);
|
||||
|
||||
const preLabel = document.createElement('div');
|
||||
preLabel.classList.add('mt-12');
|
||||
preLabel.innerHTML = 'Fehlermeldung:';
|
||||
|
||||
statusElement.appendChild(preLabel);
|
||||
|
||||
const pre = document.createElement('pre');
|
||||
pre.classList.add('mt-4', 'text-wrap');
|
||||
pre.innerHTML = error.message;
|
||||
|
||||
statusElement.appendChild(pre);
|
||||
|
||||
console.error('Error during app initialization', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function _notificationsHubOptionsFactory(
|
||||
config: Config,
|
||||
auth: AuthService,
|
||||
): SignalRHubOptions {
|
||||
const options = { ...config.get('hubs').notifications };
|
||||
options.httpOptions.accessTokenFactory = () => auth.getToken();
|
||||
return options;
|
||||
}
|
||||
|
||||
@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 }),
|
||||
],
|
||||
providers: [
|
||||
provideAppInitializer(() => {
|
||||
const initializerFn = _appInitializerFactory(
|
||||
inject(Config),
|
||||
inject(Injector),
|
||||
);
|
||||
return initializerFn();
|
||||
}),
|
||||
{
|
||||
provide: NOTIFICATIONS_HUB_OPTIONS,
|
||||
useFactory: _notificationsHubOptionsFactory,
|
||||
deps: [Config, AuthService],
|
||||
},
|
||||
{
|
||||
provide: HTTP_INTERCEPTORS,
|
||||
useClass: HttpErrorInterceptor,
|
||||
multi: true,
|
||||
},
|
||||
{
|
||||
provide: LOG_PROVIDER,
|
||||
useClass: IsaLogProvider,
|
||||
multi: true,
|
||||
},
|
||||
{
|
||||
provide: ErrorHandler,
|
||||
useClass: IsaErrorHandler,
|
||||
},
|
||||
{ provide: LOCALE_ID, useValue: 'de-DE' },
|
||||
provideHttpClient(withInterceptorsFromDi()),
|
||||
provideMatomo(
|
||||
{ trackerUrl: 'https://matomo.paragon-data.net', siteId: '1' },
|
||||
withRouter(),
|
||||
withRouteData(),
|
||||
),
|
||||
provideLogging(withLogLevel(LogLevel.Debug), withSink(ConsoleLogSink)),
|
||||
{
|
||||
provide: DEFAULT_CURRENCY_CODE,
|
||||
useValue: 'EUR',
|
||||
},
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
import {
|
||||
HTTP_INTERCEPTORS,
|
||||
provideHttpClient,
|
||||
withInterceptorsFromDi,
|
||||
} from '@angular/common/http';
|
||||
import {
|
||||
DEFAULT_CURRENCY_CODE,
|
||||
ErrorHandler,
|
||||
Injector,
|
||||
LOCALE_ID,
|
||||
NgModule,
|
||||
inject,
|
||||
provideAppInitializer,
|
||||
} from '@angular/core';
|
||||
import { BrowserModule } from '@angular/platform-browser';
|
||||
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
|
||||
import { PlatformModule } from '@angular/cdk/platform';
|
||||
|
||||
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 { 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 { 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';
|
||||
import { IsaErrorHandler } from './providers/isa.error-handler';
|
||||
import {
|
||||
ScanAdapterModule,
|
||||
ScanAdapterService,
|
||||
ScanditScanAdapterModule,
|
||||
} from '@adapter/scan';
|
||||
import { RootStateService } from './store/root-state.service';
|
||||
import * as Commands from './commands';
|
||||
import { PreviewComponent } from './preview';
|
||||
import { NativeContainerService } from '@external/native-container';
|
||||
import { ShellModule } from '@shared/shell';
|
||||
import { MainComponent } from './main.component';
|
||||
import { IconModule } from '@shared/components/icon';
|
||||
import { NgIconsModule } from '@ng-icons/core';
|
||||
import {
|
||||
matClose,
|
||||
matWifi,
|
||||
matWifiOff,
|
||||
} from '@ng-icons/material-icons/baseline';
|
||||
import { NetworkStatusService } from './services/network-status.service';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { provideMatomo } from 'ngx-matomo-client';
|
||||
import { withRouter, withRouteData } from 'ngx-matomo-client';
|
||||
import {
|
||||
provideLogging,
|
||||
withLogLevel,
|
||||
LogLevel,
|
||||
withSink,
|
||||
ConsoleLogSink,
|
||||
} from '@isa/core/logging';
|
||||
import { IDBStorageProvider, UserStorageProvider } from '@isa/core/storage';
|
||||
|
||||
registerLocaleData(localeDe, localeDeExtra);
|
||||
registerLocaleData(localeDe, 'de', localeDeExtra);
|
||||
|
||||
export function _appInitializerFactory(config: Config, injector: Injector) {
|
||||
return async () => {
|
||||
const statusElement = document.querySelector('#init-status');
|
||||
const laoderElement = document.querySelector('#init-loader');
|
||||
|
||||
try {
|
||||
let online = false;
|
||||
const networkStatus = injector.get(NetworkStatusService);
|
||||
while (!online) {
|
||||
online = await firstValueFrom(networkStatus.online$);
|
||||
|
||||
if (!online) {
|
||||
statusElement.innerHTML =
|
||||
'<b>Warte auf Netzwerkverbindung (WLAN)</b><br><br>Bitte prüfen Sie die Netzwerkverbindung (WLAN).<br>Sobald eine Netzwerkverbindung besteht, wird die App automatisch neu geladen.';
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
}
|
||||
}
|
||||
|
||||
statusElement.innerHTML = 'Konfigurationen werden geladen...';
|
||||
|
||||
statusElement.innerHTML = 'Scanner wird initialisiert...';
|
||||
const scanAdapter = injector.get(ScanAdapterService);
|
||||
await scanAdapter.init();
|
||||
|
||||
statusElement.innerHTML = 'Authentifizierung wird geprüft...';
|
||||
|
||||
const auth = injector.get(AuthService);
|
||||
try {
|
||||
await auth.init();
|
||||
} catch (error) {
|
||||
statusElement.innerHTML = 'Authentifizierung wird durchgeführt...';
|
||||
const strategy = injector.get(LoginStrategy);
|
||||
await strategy.login();
|
||||
}
|
||||
|
||||
statusElement.innerHTML = 'App wird initialisiert...';
|
||||
const state = injector.get(RootStateService);
|
||||
await state.init();
|
||||
|
||||
statusElement.innerHTML = 'Native Container wird initialisiert...';
|
||||
const nativeContainer = injector.get(NativeContainerService);
|
||||
await nativeContainer.init();
|
||||
|
||||
statusElement.innerHTML = 'Datenbank wird initialisiert...';
|
||||
await injector.get(IDBStorageProvider).init();
|
||||
|
||||
statusElement.innerHTML = 'Benutzerzustand wird geladen...';
|
||||
await injector.get(UserStorageProvider).init();
|
||||
} catch (error) {
|
||||
laoderElement.remove();
|
||||
statusElement.classList.add('text-xl');
|
||||
statusElement.innerHTML +=
|
||||
'⚡<br><br><b>Fehler bei der Initialisierung</b><br><br>Bitte prüfen Sie die Netzwerkverbindung (WLAN).<br><br>';
|
||||
|
||||
const reload = document.createElement('button');
|
||||
reload.classList.add(
|
||||
'bg-brand',
|
||||
'text-white',
|
||||
'p-2',
|
||||
'rounded',
|
||||
'cursor-pointer',
|
||||
);
|
||||
reload.innerHTML = 'App neu laden';
|
||||
reload.onclick = () => window.location.reload();
|
||||
statusElement.appendChild(reload);
|
||||
|
||||
const preLabel = document.createElement('div');
|
||||
preLabel.classList.add('mt-12');
|
||||
preLabel.innerHTML = 'Fehlermeldung:';
|
||||
|
||||
statusElement.appendChild(preLabel);
|
||||
|
||||
const pre = document.createElement('pre');
|
||||
pre.classList.add('mt-4', 'text-wrap');
|
||||
pre.innerHTML = error.message;
|
||||
|
||||
statusElement.appendChild(pre);
|
||||
|
||||
console.error('Error during app initialization', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function _notificationsHubOptionsFactory(
|
||||
config: Config,
|
||||
auth: AuthService,
|
||||
): SignalRHubOptions {
|
||||
const options = { ...config.get('hubs').notifications };
|
||||
options.httpOptions.accessTokenFactory = () => auth.getToken();
|
||||
return options;
|
||||
}
|
||||
|
||||
@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 }),
|
||||
],
|
||||
providers: [
|
||||
provideAppInitializer(() => {
|
||||
const initializerFn = _appInitializerFactory(
|
||||
inject(Config),
|
||||
inject(Injector),
|
||||
);
|
||||
return initializerFn();
|
||||
}),
|
||||
{
|
||||
provide: NOTIFICATIONS_HUB_OPTIONS,
|
||||
useFactory: _notificationsHubOptionsFactory,
|
||||
deps: [Config, AuthService],
|
||||
},
|
||||
{
|
||||
provide: HTTP_INTERCEPTORS,
|
||||
useClass: HttpErrorInterceptor,
|
||||
multi: true,
|
||||
},
|
||||
{
|
||||
provide: LOG_PROVIDER,
|
||||
useClass: IsaLogProvider,
|
||||
multi: true,
|
||||
},
|
||||
{
|
||||
provide: ErrorHandler,
|
||||
useClass: IsaErrorHandler,
|
||||
},
|
||||
{
|
||||
provide: ApplicationService,
|
||||
useClass: ApplicationServiceAdapter,
|
||||
},
|
||||
{ provide: LOCALE_ID, useValue: 'de-DE' },
|
||||
provideHttpClient(withInterceptorsFromDi()),
|
||||
provideMatomo(
|
||||
{ trackerUrl: 'https://matomo.paragon-data.net', siteId: '1' },
|
||||
withRouter(),
|
||||
withRouteData(),
|
||||
),
|
||||
provideLogging(withLogLevel(LogLevel.Debug), withSink(ConsoleLogSink)),
|
||||
{
|
||||
provide: DEFAULT_CURRENCY_CODE,
|
||||
useValue: 'EUR',
|
||||
},
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
@@ -1,67 +1,74 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router';
|
||||
import { ApplicationProcess, ApplicationService } from '@core/application';
|
||||
import { BreadcrumbService } from '@core/breadcrumb';
|
||||
import { first } from 'rxjs/operators';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class CanActivateProductWithProcessIdGuard {
|
||||
constructor(
|
||||
private readonly _applicationService: ApplicationService,
|
||||
private readonly _breadcrumbService: BreadcrumbService,
|
||||
) {}
|
||||
|
||||
async canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot) {
|
||||
const process = await this._applicationService.getProcessById$(+route.params.processId).pipe(first()).toPromise();
|
||||
|
||||
// if (!(process?.type === 'cart')) {
|
||||
// // TODO:
|
||||
// // Anderer Prozesstyp mit gleicher Id - Was soll gemacht werden?
|
||||
// return false;
|
||||
// }
|
||||
|
||||
if (!process) {
|
||||
const processes = await this._applicationService.getProcesses$('customer').pipe(first()).toPromise();
|
||||
await this._applicationService.createProcess({
|
||||
id: +route.params.processId,
|
||||
type: 'cart',
|
||||
section: 'customer',
|
||||
name: `Vorgang ${this.processNumber(processes.filter((process) => process.type === 'cart'))}`,
|
||||
});
|
||||
}
|
||||
|
||||
await this.removeBreadcrumbWithSameProcessId(route);
|
||||
this._applicationService.activateProcess(+route.params.processId);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Fix #3292: Alle Breadcrumbs die nichts mit dem aktuellen Prozess zu tun haben, müssen removed werden
|
||||
async removeBreadcrumbWithSameProcessId(route: ActivatedRouteSnapshot) {
|
||||
const crumbs = await this._breadcrumbService.getBreadcrumbByKey$(+route.params.processId).pipe(first()).toPromise();
|
||||
|
||||
// Entferne alle Crumbs die nichts mit der Artikelsuche zu tun haben
|
||||
if (crumbs.length > 1) {
|
||||
const crumbsToRemove = crumbs.filter((crumb) => crumb.tags.find((tag) => tag === 'catalog') === undefined);
|
||||
for (const crumb of crumbsToRemove) {
|
||||
await this._breadcrumbService.removeBreadcrumb(crumb.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
processNumber(processes: ApplicationProcess[]) {
|
||||
const processNumbers = processes?.map((process) => Number(process?.name?.replace(/\D/g, '')));
|
||||
return !!processNumbers && processNumbers.length > 0 ? this.findMissingNumber(processNumbers) : 1;
|
||||
}
|
||||
|
||||
findMissingNumber(processNumbers: number[]) {
|
||||
// Ticket #3272 Bei Klick auf "+" bzw. neuen Prozess hinzufügen soll der neue Tab immer die höchste Nummer haben (wie aktuell im Produktiv)
|
||||
// ----------------------------------------------------------------------------------------------------------------------------------------
|
||||
|
||||
// for (let missingNumber = 1; missingNumber < Math.max(...processNumbers); missingNumber++) {
|
||||
// if (!processNumbers.find((number) => number === missingNumber)) {
|
||||
// return missingNumber;
|
||||
// }
|
||||
// }
|
||||
return Math.max(...processNumbers) + 1;
|
||||
}
|
||||
}
|
||||
import { Injectable } from '@angular/core';
|
||||
import { ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router';
|
||||
import { ApplicationProcess, ApplicationService } from '@core/application';
|
||||
import { BreadcrumbService } from '@core/breadcrumb';
|
||||
import { first } from 'rxjs/operators';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class CanActivateProductWithProcessIdGuard {
|
||||
constructor(
|
||||
private readonly _applicationService: ApplicationService,
|
||||
private readonly _breadcrumbService: BreadcrumbService,
|
||||
) {}
|
||||
|
||||
async canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot) {
|
||||
const processId = +route.params.processId;
|
||||
const process = await this._applicationService
|
||||
.getProcessById$(processId)
|
||||
.pipe(first())
|
||||
.toPromise();
|
||||
|
||||
// if (!(process?.type === 'cart')) {
|
||||
// // TODO:
|
||||
// // Anderer Prozesstyp mit gleicher Id - Was soll gemacht werden?
|
||||
// return false;
|
||||
// }
|
||||
|
||||
if (!process) {
|
||||
await this._applicationService.createCustomerProcess(processId);
|
||||
}
|
||||
|
||||
await this.removeBreadcrumbWithSameProcessId(route);
|
||||
this._applicationService.activateProcess(+route.params.processId);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Fix #3292: Alle Breadcrumbs die nichts mit dem aktuellen Prozess zu tun haben, müssen removed werden
|
||||
async removeBreadcrumbWithSameProcessId(route: ActivatedRouteSnapshot) {
|
||||
const crumbs = await this._breadcrumbService
|
||||
.getBreadcrumbByKey$(+route.params.processId)
|
||||
.pipe(first())
|
||||
.toPromise();
|
||||
|
||||
// Entferne alle Crumbs die nichts mit der Artikelsuche zu tun haben
|
||||
if (crumbs.length > 1) {
|
||||
const crumbsToRemove = crumbs.filter(
|
||||
(crumb) => crumb.tags.find((tag) => tag === 'catalog') === undefined,
|
||||
);
|
||||
for (const crumb of crumbsToRemove) {
|
||||
await this._breadcrumbService.removeBreadcrumb(crumb.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
processNumber(processes: ApplicationProcess[]) {
|
||||
const processNumbers = processes?.map((process) =>
|
||||
Number(process?.name?.replace(/\D/g, '')),
|
||||
);
|
||||
return !!processNumbers && processNumbers.length > 0
|
||||
? this.findMissingNumber(processNumbers)
|
||||
: 1;
|
||||
}
|
||||
|
||||
findMissingNumber(processNumbers: number[]) {
|
||||
// Ticket #3272 Bei Klick auf "+" bzw. neuen Prozess hinzufügen soll der neue Tab immer die höchste Nummer haben (wie aktuell im Produktiv)
|
||||
// ----------------------------------------------------------------------------------------------------------------------------------------
|
||||
|
||||
// for (let missingNumber = 1; missingNumber < Math.max(...processNumbers); missingNumber++) {
|
||||
// if (!processNumbers.find((number) => number === missingNumber)) {
|
||||
// return missingNumber;
|
||||
// }
|
||||
// }
|
||||
return Math.max(...processNumbers) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { ActivatedRouteSnapshot } from '@angular/router';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ProcessIdResolver {
|
||||
resolve(route: ActivatedRouteSnapshot): Observable<number> | Promise<number> | number {
|
||||
return route.params.processId;
|
||||
}
|
||||
}
|
||||
|
||||
import { Injectable } from '@angular/core';
|
||||
import { ActivatedRouteSnapshot } from '@angular/router';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ProcessIdResolver {
|
||||
resolve(
|
||||
route: ActivatedRouteSnapshot,
|
||||
): Observable<number> | Promise<number> | number {
|
||||
return route.params.processId;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,128 +1,124 @@
|
||||
import { Injectable } from "@angular/core";
|
||||
import { Logger, LogLevel } from "@core/logger";
|
||||
import { Store } from "@ngrx/store";
|
||||
import { debounceTime, switchMap, takeUntil } from "rxjs/operators";
|
||||
import { RootState } from "./root.state";
|
||||
import packageInfo from "packageJson";
|
||||
import { environment } from "../../environments/environment";
|
||||
import { Subject } from "rxjs";
|
||||
import { AuthService } from "@core/auth";
|
||||
import { injectStorage, UserStorageProvider } from "@isa/core/storage";
|
||||
import { isEqual } from "lodash";
|
||||
|
||||
@Injectable({ providedIn: "root" })
|
||||
export class RootStateService {
|
||||
static LOCAL_STORAGE_KEY = "ISA_APP_INITIALSTATE";
|
||||
|
||||
#storage = injectStorage(UserStorageProvider);
|
||||
|
||||
private _cancelSave = new Subject<void>();
|
||||
|
||||
constructor(
|
||||
private readonly _authService: AuthService,
|
||||
private _logger: Logger,
|
||||
private _store: Store,
|
||||
) {
|
||||
if (!environment.production) {
|
||||
console.log(
|
||||
'Die UserState kann in der Konsole mit der Funktion "clearUserState()" geleert werden.',
|
||||
);
|
||||
}
|
||||
|
||||
window["clearUserState"] = () => {
|
||||
this.clear();
|
||||
};
|
||||
}
|
||||
|
||||
async init() {
|
||||
await this.load();
|
||||
this._store.dispatch({
|
||||
type: "HYDRATE",
|
||||
payload: RootStateService.LoadFromLocalStorage(),
|
||||
});
|
||||
this.initSave();
|
||||
}
|
||||
|
||||
initSave() {
|
||||
this._store
|
||||
.select((state) => state)
|
||||
.pipe(
|
||||
takeUntil(this._cancelSave),
|
||||
debounceTime(1000),
|
||||
switchMap((state) => {
|
||||
const data = {
|
||||
...state,
|
||||
version: packageInfo.version,
|
||||
sub: this._authService.getClaimByKey("sub"),
|
||||
};
|
||||
RootStateService.SaveToLocalStorageRaw(JSON.stringify(data));
|
||||
return this.#storage.set("state", data);
|
||||
}),
|
||||
)
|
||||
.subscribe();
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the initial state from local storage and returns true/false if state was changed
|
||||
*/
|
||||
async load(): Promise<boolean> {
|
||||
try {
|
||||
const res = await this.#storage.get("state");
|
||||
|
||||
const storageContent = RootStateService.LoadFromLocalStorageRaw();
|
||||
|
||||
if (res) {
|
||||
RootStateService.SaveToLocalStorageRaw(JSON.stringify(res));
|
||||
}
|
||||
|
||||
if (!isEqual(res, storageContent)) {
|
||||
return true;
|
||||
}
|
||||
} catch (error) {
|
||||
this._logger.log(LogLevel.ERROR, error);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async clear() {
|
||||
try {
|
||||
this._cancelSave.next();
|
||||
await this.#storage.clear("state");
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
RootStateService.RemoveFromLocalStorage();
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
this._logger.log(LogLevel.ERROR, error);
|
||||
}
|
||||
}
|
||||
|
||||
static SaveToLocalStorage(state: RootState) {
|
||||
RootStateService.SaveToLocalStorageRaw(JSON.stringify(state));
|
||||
}
|
||||
|
||||
static SaveToLocalStorageRaw(state: string) {
|
||||
localStorage.setItem(RootStateService.LOCAL_STORAGE_KEY, state);
|
||||
}
|
||||
|
||||
static LoadFromLocalStorage(): RootState {
|
||||
const raw = RootStateService.LoadFromLocalStorageRaw();
|
||||
if (raw) {
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch (error) {
|
||||
console.error("Error parsing local storage:", error);
|
||||
this.RemoveFromLocalStorage();
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
static LoadFromLocalStorageRaw(): string {
|
||||
return localStorage.getItem(RootStateService.LOCAL_STORAGE_KEY);
|
||||
}
|
||||
|
||||
static RemoveFromLocalStorage() {
|
||||
localStorage.removeItem(RootStateService.LOCAL_STORAGE_KEY);
|
||||
}
|
||||
}
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Logger, LogLevel } from '@core/logger';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { debounceTime, switchMap, takeUntil } from 'rxjs/operators';
|
||||
import { RootState } from './root.state';
|
||||
import packageInfo from 'packageJson';
|
||||
import { environment } from '../../environments/environment';
|
||||
import { Subject } from 'rxjs';
|
||||
import { AuthService } from '@core/auth';
|
||||
import { injectStorage, UserStorageProvider } from '@isa/core/storage';
|
||||
import { isEqual } from 'lodash';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class RootStateService {
|
||||
static LOCAL_STORAGE_KEY = 'ISA_APP_INITIALSTATE';
|
||||
|
||||
#storage = injectStorage(UserStorageProvider);
|
||||
|
||||
private _cancelSave = new Subject<void>();
|
||||
|
||||
constructor(
|
||||
private readonly _authService: AuthService,
|
||||
private _logger: Logger,
|
||||
private _store: Store,
|
||||
) {
|
||||
if (!environment.production) {
|
||||
console.log(
|
||||
'Die UserState kann in der Konsole mit der Funktion "clearUserState()" geleert werden.',
|
||||
);
|
||||
}
|
||||
|
||||
window['clearUserState'] = () => {
|
||||
this.clear();
|
||||
};
|
||||
}
|
||||
|
||||
async init() {
|
||||
await this.load();
|
||||
this._store.dispatch({
|
||||
type: 'HYDRATE',
|
||||
payload: RootStateService.LoadFromLocalStorage(),
|
||||
});
|
||||
this.initSave();
|
||||
}
|
||||
|
||||
initSave() {
|
||||
this._store
|
||||
.select((state) => state)
|
||||
.pipe(takeUntil(this._cancelSave), debounceTime(1000))
|
||||
.subscribe((state) => {
|
||||
const data = {
|
||||
...state,
|
||||
version: packageInfo.version,
|
||||
sub: this._authService.getClaimByKey('sub'),
|
||||
};
|
||||
RootStateService.SaveToLocalStorageRaw(JSON.stringify(data));
|
||||
return this.#storage.set('state', data);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the initial state from local storage and returns true/false if state was changed
|
||||
*/
|
||||
async load(): Promise<boolean> {
|
||||
try {
|
||||
const res = await this.#storage.get('state');
|
||||
|
||||
const storageContent = RootStateService.LoadFromLocalStorageRaw();
|
||||
|
||||
if (res) {
|
||||
RootStateService.SaveToLocalStorageRaw(JSON.stringify(res));
|
||||
}
|
||||
|
||||
if (!isEqual(res, storageContent)) {
|
||||
return true;
|
||||
}
|
||||
} catch (error) {
|
||||
this._logger.log(LogLevel.ERROR, error);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async clear() {
|
||||
try {
|
||||
this._cancelSave.next();
|
||||
await this.#storage.clear('state');
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
RootStateService.RemoveFromLocalStorage();
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
this._logger.log(LogLevel.ERROR, error);
|
||||
}
|
||||
}
|
||||
|
||||
static SaveToLocalStorage(state: RootState) {
|
||||
RootStateService.SaveToLocalStorageRaw(JSON.stringify(state));
|
||||
}
|
||||
|
||||
static SaveToLocalStorageRaw(state: string) {
|
||||
localStorage.setItem(RootStateService.LOCAL_STORAGE_KEY, state);
|
||||
}
|
||||
|
||||
static LoadFromLocalStorage(): RootState {
|
||||
const raw = RootStateService.LoadFromLocalStorageRaw();
|
||||
if (raw) {
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch (error) {
|
||||
console.error('Error parsing local storage:', error);
|
||||
this.RemoveFromLocalStorage();
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
static LoadFromLocalStorageRaw(): string {
|
||||
return localStorage.getItem(RootStateService.LOCAL_STORAGE_KEY);
|
||||
}
|
||||
|
||||
static RemoveFromLocalStorage() {
|
||||
localStorage.removeItem(RootStateService.LOCAL_STORAGE_KEY);
|
||||
}
|
||||
}
|
||||
|
||||
338
apps/isa-app/src/core/application/application.service-adapter.ts
Normal file
338
apps/isa-app/src/core/application/application.service-adapter.ts
Normal file
@@ -0,0 +1,338 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from './application.module';
|
||||
export * from './application.service';
|
||||
export * from './application.service-adapter';
|
||||
export * from './defs';
|
||||
export * from './store';
|
||||
|
||||
@@ -1,39 +1,60 @@
|
||||
<div class="shared-branch-selector-input-container" (click)="branchInput.focus(); openComplete()">
|
||||
<button (click)="onClose($event)" type="button" class="shared-branch-selector-input-icon p-2">
|
||||
<shared-icon class="text-[#AEB7C1]" icon="magnify" [size]="32"></shared-icon>
|
||||
</button>
|
||||
<input
|
||||
#branchInput
|
||||
class="shared-branch-selector-input"
|
||||
[class.shared-branch-selector-opend]="autocompleteComponent?.opend"
|
||||
uiInput
|
||||
type="text"
|
||||
[placeholder]="placeholder"
|
||||
[ngModel]="query$ | async"
|
||||
(ngModelChange)="onQueryChange($event)"
|
||||
(keyup)="onKeyup($event)"
|
||||
/>
|
||||
@if ((query$ | async)?.length > 0) {
|
||||
<button class="shared-branch-selector-clear-input-icon pr-2" type="button" (click)="clear()">
|
||||
<shared-icon class="text-[#1F466C]" icon="close" [size]="32"></shared-icon>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<ui-autocomplete class="shared-branch-selector-autocomplete z-modal w-full">
|
||||
@if (autocompleteComponent?.opend) {
|
||||
<hr class="ml-3 text-[#9CB1C6]" uiAutocompleteSeparator />
|
||||
}
|
||||
@if ((filteredBranches$ | async)?.length > 0) {
|
||||
<p class="text-p2 p-4 font-normal" uiAutocompleteLabel>Filialvorschläge</p>
|
||||
}
|
||||
@for (branch of filteredBranches$ | async; track branch) {
|
||||
<button
|
||||
class="shared-branch-selector-autocomplete-option min-h-[44px]"
|
||||
[class.shared-branch-selector-selected]="value && value.id === branch.id"
|
||||
(click)="setBranch(branch)"
|
||||
[uiAutocompleteItem]="branch"
|
||||
>
|
||||
<span class="text-lg font-semibold">{{ store.formatBranch(branch) }}</span>
|
||||
</button>
|
||||
}
|
||||
</ui-autocomplete>
|
||||
<div
|
||||
class="shared-branch-selector-input-container"
|
||||
(click)="branchInput.focus(); openComplete()"
|
||||
>
|
||||
<button
|
||||
(click)="onClose($event)"
|
||||
type="button"
|
||||
class="shared-branch-selector-input-icon p-2"
|
||||
>
|
||||
<shared-icon
|
||||
class="text-[#AEB7C1]"
|
||||
icon="magnify"
|
||||
[size]="32"
|
||||
></shared-icon>
|
||||
</button>
|
||||
<input
|
||||
#branchInput
|
||||
class="shared-branch-selector-input"
|
||||
[class.shared-branch-selector-opend]="autocompleteComponent?.opend"
|
||||
uiInput
|
||||
type="text"
|
||||
[placeholder]="placeholder"
|
||||
[ngModel]="query$ | async"
|
||||
(ngModelChange)="onQueryChange($event)"
|
||||
(keyup)="onKeyup($event)"
|
||||
/>
|
||||
@if ((query$ | async)?.length > 0) {
|
||||
<button
|
||||
class="shared-branch-selector-clear-input-icon pr-2"
|
||||
type="button"
|
||||
(click)="clear()"
|
||||
>
|
||||
<shared-icon
|
||||
class="text-[#1F466C]"
|
||||
icon="close"
|
||||
[size]="32"
|
||||
></shared-icon>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<ui-autocomplete class="shared-branch-selector-autocomplete z-modal w-full">
|
||||
@if (autocompleteComponent?.opend) {
|
||||
<hr class="ml-3 text-[#9CB1C6]" uiAutocompleteSeparator />
|
||||
}
|
||||
@if ((filteredBranches$ | async)?.length > 0) {
|
||||
<p class="text-p2 p-4 font-normal" uiAutocompleteLabel>Filialvorschläge</p>
|
||||
}
|
||||
@for (branch of filteredBranches$ | async; track branch) {
|
||||
<button
|
||||
class="shared-branch-selector-autocomplete-option min-h-[44px]"
|
||||
[class.shared-branch-selector-selected]="value && value.id === branch.id"
|
||||
(click)="setBranch(branch)"
|
||||
[uiAutocompleteItem]="branch"
|
||||
>
|
||||
<span class="text-lg font-semibold">{{
|
||||
store.formatBranch(branch)
|
||||
}}</span>
|
||||
</button>
|
||||
}
|
||||
</ui-autocomplete>
|
||||
|
||||
@@ -1,31 +1,38 @@
|
||||
<div
|
||||
class="tab-wrapper flex flex-row items-center justify-between border-b-[0.188rem] border-solid h-14"
|
||||
[class.border-surface]="!(isActive$ | async)"
|
||||
[class.border-brand]="isActive$ | async"
|
||||
>
|
||||
<a
|
||||
class="tab-link font-bold flex flex-row justify-center items-center whitespace-nowrap px-4 truncate max-w-[15rem] h-14"
|
||||
[routerLink]="routerLink$ | async"
|
||||
[queryParams]="queryParams$ | async"
|
||||
(click)="scrollIntoView()"
|
||||
>
|
||||
<span class="truncate">
|
||||
{{ process?.name }}
|
||||
</span>
|
||||
@if (process?.type !== 'cart-checkout') {
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-full px-3 h-[2.375rem] font-bold text-p1 flex flex-row items-center justify-between shopping-cart-count ml-4"
|
||||
[class.active]="isActive$ | async"
|
||||
[routerLink]="getCheckoutPath((process$ | async)?.id)"
|
||||
(click)="$event?.preventDefault(); $event?.stopPropagation()"
|
||||
>
|
||||
<shared-icon icon="shopping-cart-bold" [size]="22"></shared-icon>
|
||||
<span class="shopping-cart-count-label ml-2">{{ cartItemCount$ | async }}</span>
|
||||
</button>
|
||||
}
|
||||
</a>
|
||||
<button type="button" class="tab-close-btn -ml-4 h-12 w-12 grid justify-center items-center" (click)="close()">
|
||||
<shared-icon icon="close" [size]="28"></shared-icon>
|
||||
</button>
|
||||
</div>
|
||||
@if (process(); as p) {
|
||||
<div
|
||||
class="tab-wrapper flex flex-row items-center justify-between border-b-[0.188rem] border-solid h-14"
|
||||
[class.border-surface]="!(isActive$ | async)"
|
||||
[class.border-brand]="isActive$ | async"
|
||||
>
|
||||
<a
|
||||
class="tab-link font-bold flex flex-row justify-center items-center whitespace-nowrap px-4 truncate max-w-[15rem] h-14"
|
||||
[href]="currentLocationUrlTree()?.toString()"
|
||||
(click)="navigateByUrl($event); scrollIntoView()"
|
||||
>
|
||||
<span class="truncate">
|
||||
{{ p.name }}
|
||||
</span>
|
||||
@if (showCart() && p.type === 'cart') {
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-full px-3 h-[2.375rem] font-bold text-p1 flex flex-row items-center justify-between shopping-cart-count ml-4"
|
||||
[class.active]="isActive$ | async"
|
||||
[routerLink]="getCheckoutPath((process$ | async)?.id)"
|
||||
(click)="$event?.preventDefault(); $event?.stopPropagation()"
|
||||
>
|
||||
<shared-icon icon="shopping-cart-bold" [size]="22"></shared-icon>
|
||||
<span class="shopping-cart-count-label ml-2">{{
|
||||
cartItemCount$ | async
|
||||
}}</span>
|
||||
</button>
|
||||
}
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
class="tab-close-btn -ml-4 h-12 w-12 grid justify-center items-center"
|
||||
(click)="close()"
|
||||
>
|
||||
<shared-icon icon="close" [size]="28"></shared-icon>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -1,156 +1,208 @@
|
||||
import {
|
||||
Component,
|
||||
ChangeDetectionStrategy,
|
||||
Input,
|
||||
OnDestroy,
|
||||
OnInit,
|
||||
OnChanges,
|
||||
SimpleChanges,
|
||||
EventEmitter,
|
||||
Output,
|
||||
ElementRef,
|
||||
} from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { ApplicationProcess, ApplicationService } from '@core/application';
|
||||
import { Breadcrumb, BreadcrumbService } from '@core/breadcrumb';
|
||||
import { DomainCheckoutService } from '@domain/checkout';
|
||||
import { CheckoutNavigationService } from '@shared/services/navigation';
|
||||
import { BehaviorSubject, NEVER, Observable, combineLatest, isObservable } from 'rxjs';
|
||||
import { first, map, switchMap, tap } from 'rxjs/operators';
|
||||
|
||||
@Component({
|
||||
selector: 'shell-process-bar-item',
|
||||
templateUrl: 'process-bar-item.component.html',
|
||||
styleUrls: ['process-bar-item.component.css'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
standalone: false,
|
||||
})
|
||||
export class ShellProcessBarItemComponent implements OnInit, OnDestroy, OnChanges {
|
||||
private _process$ = new BehaviorSubject<ApplicationProcess>(undefined);
|
||||
|
||||
process$ = this._process$.asObservable();
|
||||
|
||||
@Input()
|
||||
process: ApplicationProcess;
|
||||
|
||||
@Output()
|
||||
closed = new EventEmitter();
|
||||
|
||||
activatedProcessId$ = this._app.activatedProcessId$;
|
||||
|
||||
latestBreadcrumb$: Observable<Breadcrumb> = NEVER;
|
||||
|
||||
routerLink$: Observable<string[] | any[]> = NEVER;
|
||||
|
||||
queryParams$: Observable<object> = NEVER;
|
||||
|
||||
isActive$: Observable<boolean> = NEVER;
|
||||
|
||||
showCloseButton$: Observable<boolean> = NEVER;
|
||||
|
||||
cartItemCount$: Observable<number> = NEVER;
|
||||
|
||||
constructor(
|
||||
private _breadcrumb: BreadcrumbService,
|
||||
private _app: ApplicationService,
|
||||
private _router: Router,
|
||||
private _checkout: DomainCheckoutService,
|
||||
private _checkoutNavigationService: CheckoutNavigationService,
|
||||
public _elRef: ElementRef<HTMLElement>,
|
||||
) {}
|
||||
|
||||
ngOnChanges({ process }: SimpleChanges): void {
|
||||
if (process) {
|
||||
this._process$.next(process.currentValue);
|
||||
}
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
this.initLatestBreadcrumb$();
|
||||
this.initRouterLink$();
|
||||
this.initQueryParams$();
|
||||
this.initIsActive$();
|
||||
this.initShowCloseButton$();
|
||||
this.initCartItemCount$();
|
||||
}
|
||||
|
||||
scrollIntoView() {
|
||||
setTimeout(() => this._elRef.nativeElement.scrollIntoView({ behavior: 'smooth', block: 'center' }), 0);
|
||||
}
|
||||
|
||||
getCheckoutPath(processId: number) {
|
||||
return this._checkoutNavigationService.getCheckoutReviewPath(processId).path;
|
||||
}
|
||||
|
||||
initLatestBreadcrumb$() {
|
||||
this.latestBreadcrumb$ = this.process$.pipe(
|
||||
switchMap((process) => this._breadcrumb.getLastActivatedBreadcrumbByKey$(process?.id)),
|
||||
);
|
||||
}
|
||||
|
||||
initRouterLink$() {
|
||||
this.routerLink$ = this.latestBreadcrumb$.pipe(
|
||||
map((breadcrumb) => (breadcrumb?.path instanceof Array ? breadcrumb.path : [breadcrumb?.path])),
|
||||
);
|
||||
}
|
||||
|
||||
initQueryParams$() {
|
||||
this.queryParams$ = this.latestBreadcrumb$.pipe(map((breadcrumb) => breadcrumb?.params));
|
||||
}
|
||||
|
||||
initIsActive$() {
|
||||
if (isObservable(this.activatedProcessId$) && isObservable(this.process$)) {
|
||||
this.isActive$ = combineLatest([this.activatedProcessId$, this.process$]).pipe(
|
||||
map(([activatedId, process]) => process?.id === activatedId),
|
||||
tap((isActive) => {
|
||||
if (isActive) {
|
||||
this.scrollIntoView();
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
initShowCloseButton$() {
|
||||
if (isObservable(this.isActive$) && isObservable(this.process$)) {
|
||||
this.showCloseButton$ = this.process$.pipe(map((process) => process?.closeable));
|
||||
}
|
||||
}
|
||||
|
||||
initCartItemCount$() {
|
||||
this.cartItemCount$ = this.process$.pipe(
|
||||
switchMap((process) => this._checkout?.getShoppingCart({ processId: process?.id })),
|
||||
map((cart) => cart?.items?.length ?? 0),
|
||||
);
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this._process$.complete();
|
||||
}
|
||||
|
||||
async close() {
|
||||
const breadcrumb = await this.getLatestBreadcrumbForSection();
|
||||
await this.navigate(breadcrumb);
|
||||
this._app.removeProcess(this.process.id);
|
||||
this.closed.emit();
|
||||
}
|
||||
|
||||
getLatestBreadcrumbForSection(): Promise<Breadcrumb> {
|
||||
return this._breadcrumb
|
||||
.getLatestBreadcrumbForSection('customer', (c) => c.key !== this.process?.id)
|
||||
.pipe(first())
|
||||
.toPromise();
|
||||
}
|
||||
|
||||
async navigate(breadcrumb?: Breadcrumb) {
|
||||
if (breadcrumb) {
|
||||
if (breadcrumb.path instanceof Array) {
|
||||
await this._router.navigate(breadcrumb.path, { queryParams: breadcrumb.params });
|
||||
} else {
|
||||
await this._router.navigate([breadcrumb.path], { queryParams: breadcrumb.params });
|
||||
}
|
||||
} else {
|
||||
await this._router.navigate(['/kunde/dashboard']);
|
||||
}
|
||||
}
|
||||
}
|
||||
import {
|
||||
Component,
|
||||
ChangeDetectionStrategy,
|
||||
OnDestroy,
|
||||
OnInit,
|
||||
OnChanges,
|
||||
SimpleChanges,
|
||||
EventEmitter,
|
||||
Output,
|
||||
ElementRef,
|
||||
inject,
|
||||
computed,
|
||||
input,
|
||||
effect,
|
||||
} from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { ApplicationProcess, ApplicationService } from '@core/application';
|
||||
import { Breadcrumb } from '@core/breadcrumb';
|
||||
import { DomainCheckoutService } from '@domain/checkout';
|
||||
import { CheckoutNavigationService } from '@shared/services/navigation';
|
||||
import {
|
||||
BehaviorSubject,
|
||||
NEVER,
|
||||
Observable,
|
||||
combineLatest,
|
||||
isObservable,
|
||||
} from 'rxjs';
|
||||
import { map, switchMap, tap } from 'rxjs/operators';
|
||||
import { TabService } from '@isa/core/tabs';
|
||||
|
||||
@Component({
|
||||
selector: 'shell-process-bar-item',
|
||||
templateUrl: 'process-bar-item.component.html',
|
||||
styleUrls: ['process-bar-item.component.css'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
standalone: false,
|
||||
})
|
||||
export class ShellProcessBarItemComponent
|
||||
implements OnInit, OnDestroy, OnChanges
|
||||
{
|
||||
#tabService = inject(TabService);
|
||||
|
||||
tab = computed(() => this.#tabService.entityMap()[this.process().id]);
|
||||
|
||||
private _process$ = new BehaviorSubject<ApplicationProcess>(undefined);
|
||||
|
||||
process$ = this._process$.asObservable();
|
||||
|
||||
process = input.required<ApplicationProcess>();
|
||||
|
||||
@Output()
|
||||
closed = new EventEmitter();
|
||||
|
||||
showCart = computed(() => {
|
||||
const tab = this.tab();
|
||||
|
||||
const pdata = tab.metadata?.process_data as { count?: number };
|
||||
|
||||
if (!pdata) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return 'count' in pdata;
|
||||
});
|
||||
|
||||
currentLocationUrlTree = computed(() => {
|
||||
const tab = this.tab();
|
||||
const current = tab.location.locations[tab.location.current];
|
||||
|
||||
if (current?.url) {
|
||||
return this._router.parseUrl(current.url);
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
navigateByUrl(event: MouseEvent) {
|
||||
event?.preventDefault();
|
||||
this._router.navigateByUrl(this.currentLocationUrlTree());
|
||||
}
|
||||
|
||||
activatedProcessId$ = this._app.activatedProcessId$;
|
||||
|
||||
latestBreadcrumb$: Observable<Breadcrumb> = NEVER;
|
||||
|
||||
routerLink$: Observable<string[] | any[]> = NEVER;
|
||||
|
||||
queryParams$: Observable<object> = NEVER;
|
||||
|
||||
isActive$: Observable<boolean> = NEVER;
|
||||
|
||||
showCloseButton$: Observable<boolean> = NEVER;
|
||||
|
||||
cartItemCount$: Observable<number> = NEVER;
|
||||
|
||||
constructor(
|
||||
private _app: ApplicationService,
|
||||
private _router: Router,
|
||||
private _checkout: DomainCheckoutService,
|
||||
private _checkoutNavigationService: CheckoutNavigationService,
|
||||
public _elRef: ElementRef<HTMLElement>,
|
||||
) {}
|
||||
|
||||
ngOnChanges({ process }: SimpleChanges): void {
|
||||
if (process) {
|
||||
this._process$.next(process.currentValue);
|
||||
}
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
this.initRouterLink$();
|
||||
this.initQueryParams$();
|
||||
this.initIsActive$();
|
||||
this.initShowCloseButton$();
|
||||
this.initCartItemCount$();
|
||||
}
|
||||
|
||||
scrollIntoView() {
|
||||
setTimeout(
|
||||
() =>
|
||||
this._elRef.nativeElement.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'center',
|
||||
}),
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
getCheckoutPath(processId: number) {
|
||||
return this._checkoutNavigationService.getCheckoutReviewPath(processId)
|
||||
.path;
|
||||
}
|
||||
|
||||
initRouterLink$() {
|
||||
this.routerLink$ = this.latestBreadcrumb$.pipe(
|
||||
map((breadcrumb) =>
|
||||
breadcrumb?.path instanceof Array
|
||||
? breadcrumb.path
|
||||
: [breadcrumb?.path],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
initQueryParams$() {
|
||||
this.queryParams$ = this.latestBreadcrumb$.pipe(
|
||||
map((breadcrumb) => breadcrumb?.params),
|
||||
);
|
||||
}
|
||||
|
||||
initIsActive$() {
|
||||
if (isObservable(this.activatedProcessId$) && isObservable(this.process$)) {
|
||||
this.isActive$ = combineLatest([
|
||||
this.activatedProcessId$,
|
||||
this.process$,
|
||||
]).pipe(
|
||||
map(([activatedId, process]) => process?.id === activatedId),
|
||||
tap((isActive) => {
|
||||
if (isActive) {
|
||||
this.scrollIntoView();
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
initShowCloseButton$() {
|
||||
if (isObservable(this.isActive$) && isObservable(this.process$)) {
|
||||
this.showCloseButton$ = this.process$.pipe(
|
||||
map((process) => process?.closeable),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
initCartItemCount$() {
|
||||
this.cartItemCount$ = this.process$.pipe(
|
||||
switchMap((process) =>
|
||||
this._checkout?.getShoppingCart({ processId: process?.id }),
|
||||
),
|
||||
map((cart) => cart?.items?.length ?? 0),
|
||||
);
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this._process$.complete();
|
||||
}
|
||||
|
||||
async close() {
|
||||
await this.navigate();
|
||||
this._app.removeProcess(this.process().id);
|
||||
this.closed.emit();
|
||||
}
|
||||
|
||||
async navigate(breadcrumb?: Breadcrumb) {
|
||||
if (breadcrumb) {
|
||||
if (breadcrumb.path instanceof Array) {
|
||||
await this._router.navigate(breadcrumb.path, {
|
||||
queryParams: breadcrumb.params,
|
||||
});
|
||||
} else {
|
||||
await this._router.navigate([breadcrumb.path], {
|
||||
queryParams: breadcrumb.params,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
await this._router.navigate(['/kunde/dashboard']);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,187 +1,222 @@
|
||||
import { coerceArray } from '@angular/cdk/coercion';
|
||||
import { Component, ChangeDetectionStrategy, OnInit, ViewChild, ElementRef } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { ApplicationProcess, ApplicationService } from '@core/application';
|
||||
import { BreadcrumbService } from '@core/breadcrumb';
|
||||
import { DomainCheckoutService } from '@domain/checkout';
|
||||
import { injectOpenMessageModal } from '@modal/message';
|
||||
import { CustomerOrdersNavigationService, ProductCatalogNavigationService } from '@shared/services/navigation';
|
||||
import { NEVER, Observable, of } from 'rxjs';
|
||||
import { delay, first, map, switchMap } from 'rxjs/operators';
|
||||
|
||||
@Component({
|
||||
selector: 'shell-process-bar',
|
||||
templateUrl: 'process-bar.component.html',
|
||||
styleUrls: ['process-bar.component.css'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
standalone: false,
|
||||
})
|
||||
export class ShellProcessBarComponent implements OnInit {
|
||||
@ViewChild('processContainer')
|
||||
processContainer: ElementRef;
|
||||
|
||||
section$: Observable<'customer' | 'branch'> = NEVER;
|
||||
|
||||
processes$: Observable<ApplicationProcess[]> = NEVER;
|
||||
|
||||
showStartProcessText$: Observable<boolean> = NEVER;
|
||||
|
||||
hovered: boolean;
|
||||
showScrollArrows: boolean;
|
||||
showArrowLeft: boolean;
|
||||
showArrowRight: boolean;
|
||||
|
||||
trackByFn = (_: number, process: ApplicationProcess) => process.id;
|
||||
|
||||
openMessageModal = injectOpenMessageModal();
|
||||
|
||||
constructor(
|
||||
private _app: ApplicationService,
|
||||
private _router: Router,
|
||||
private _catalogNavigationService: ProductCatalogNavigationService,
|
||||
private _customerOrderNavigationService: CustomerOrdersNavigationService,
|
||||
private _checkoutService: DomainCheckoutService,
|
||||
private _breadcrumb: BreadcrumbService,
|
||||
) {}
|
||||
|
||||
ngOnInit() {
|
||||
this.initSection$();
|
||||
this.initProcesses$();
|
||||
this.initShowStartProcessText$();
|
||||
this.checkScrollArrowVisibility();
|
||||
}
|
||||
|
||||
initSection$() {
|
||||
this.section$ = of('customer');
|
||||
}
|
||||
|
||||
initProcesses$() {
|
||||
this.processes$ = this.section$.pipe(switchMap((section) => this._app.getProcesses$(section)));
|
||||
}
|
||||
|
||||
initShowStartProcessText$() {
|
||||
this.showStartProcessText$ = this.processes$.pipe(map((processes) => processes.length === 0));
|
||||
}
|
||||
|
||||
async createProcess(target: string = 'product') {
|
||||
const process = await this.createCartProcess();
|
||||
this.navigateTo(target, process);
|
||||
|
||||
setTimeout(() => this.scrollToEnd(), 25);
|
||||
}
|
||||
|
||||
static REGEX_PROCESS_NAME = /^Vorgang \d+$/;
|
||||
|
||||
async createCartProcess() {
|
||||
return this._app.createCustomerProcess();
|
||||
}
|
||||
|
||||
async navigateTo(target: string, process: ApplicationProcess) {
|
||||
switch (target) {
|
||||
case 'product':
|
||||
await this._catalogNavigationService.getArticleSearchBasePath(process.id).navigate();
|
||||
break;
|
||||
case 'customer':
|
||||
await this._router.navigate(['/kunde', process.id, 'customer', 'search']);
|
||||
break;
|
||||
case 'goods-out':
|
||||
await this._router.navigate(['/kunde', process.id, 'goods', 'out']);
|
||||
break;
|
||||
case 'order':
|
||||
await this._customerOrderNavigationService.getCustomerOrdersBasePath(process.id).navigate();
|
||||
break;
|
||||
|
||||
default:
|
||||
await this._router.navigate(['/kunde', process.id, target]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
async closeAllProcesses() {
|
||||
const processes = await this.processes$.pipe(first()).toPromise();
|
||||
this.openMessageModal({
|
||||
title: 'Vorgänge schließen',
|
||||
message: `Sind Sie sich sicher, dass sie alle ${processes.length} Vorgänge schließen wollen?`,
|
||||
actions: [
|
||||
{ label: 'Abbrechen', value: false },
|
||||
{
|
||||
label: 'leere Warenkörbe',
|
||||
value: true,
|
||||
action: () => this.handleCloseEmptyCartProcesses(),
|
||||
},
|
||||
{
|
||||
label: 'Ja, alle',
|
||||
value: true,
|
||||
primary: true,
|
||||
action: () => this.handleCloseAllProcesses(),
|
||||
},
|
||||
],
|
||||
});
|
||||
this.checkScrollArrowVisibility();
|
||||
}
|
||||
|
||||
async handleCloseEmptyCartProcesses() {
|
||||
let processes = await this.processes$.pipe(first()).toPromise();
|
||||
for (const process of processes) {
|
||||
const cart = await this._checkoutService.getShoppingCart({ processId: process.id }).pipe(first()).toPromise();
|
||||
|
||||
if (cart?.items?.length === 0 || cart?.items === undefined) {
|
||||
this._app.removeProcess(process?.id);
|
||||
}
|
||||
|
||||
processes = await this.processes$.pipe(delay(1), first()).toPromise();
|
||||
|
||||
if (processes.length === 0) {
|
||||
this._router.navigate(['/kunde', 'dashboard']);
|
||||
} else {
|
||||
const lastest = processes.reduce(
|
||||
(prev, current) => (prev.activated > current.activated ? prev : current),
|
||||
processes[0],
|
||||
);
|
||||
const crumb = await this._breadcrumb.getLastActivatedBreadcrumbByKey$(lastest.id).pipe(first()).toPromise();
|
||||
if (crumb) {
|
||||
this._router.navigate(coerceArray(crumb.path), { queryParams: crumb.params });
|
||||
} else {
|
||||
this._router.navigate(['/kunde', lastest.id, 'product']);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async handleCloseAllProcesses() {
|
||||
const processes = await this.processes$.pipe(first()).toPromise();
|
||||
processes.forEach((process) => this._app.removeProcess(process?.id));
|
||||
this._router.navigate(['/kunde', 'dashboard']);
|
||||
}
|
||||
|
||||
onMouseWheel(event: any) {
|
||||
// Ermöglicht es, am Desktop die Prozessleiste mit dem Mausrad hoch/runter horizontal zu scrollen
|
||||
if (event.deltaY > 0) {
|
||||
this.processContainer.nativeElement.scrollLeft += 100;
|
||||
} else {
|
||||
this.processContainer.nativeElement.scrollLeft -= 100;
|
||||
}
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
scrollLeft() {
|
||||
this.processContainer.nativeElement.scrollLeft -= 100;
|
||||
}
|
||||
|
||||
scrollRight() {
|
||||
this.processContainer.nativeElement.scrollLeft += 100;
|
||||
}
|
||||
|
||||
scrollToEnd() {
|
||||
this.processContainer.nativeElement.scrollLeft =
|
||||
this.processContainer?.nativeElement?.scrollWidth + this.processContainer?.nativeElement?.scrollLeft;
|
||||
}
|
||||
|
||||
checkScrollArrowVisibility() {
|
||||
this.showScrollArrows = this.processContainer?.nativeElement?.scrollWidth > 0;
|
||||
this.showArrowRight =
|
||||
((this.processContainer?.nativeElement?.scrollWidth - this.processContainer?.nativeElement?.scrollLeft) | 0) <=
|
||||
this.processContainer?.nativeElement?.offsetWidth;
|
||||
this.showArrowLeft = this.processContainer?.nativeElement?.scrollLeft <= 0;
|
||||
}
|
||||
}
|
||||
import { coerceArray } from '@angular/cdk/coercion';
|
||||
import {
|
||||
Component,
|
||||
ChangeDetectionStrategy,
|
||||
OnInit,
|
||||
ViewChild,
|
||||
ElementRef,
|
||||
} from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { ApplicationProcess, ApplicationService } from '@core/application';
|
||||
import { BreadcrumbService } from '@core/breadcrumb';
|
||||
import { DomainCheckoutService } from '@domain/checkout';
|
||||
import { injectOpenMessageModal } from '@modal/message';
|
||||
import {
|
||||
CustomerOrdersNavigationService,
|
||||
ProductCatalogNavigationService,
|
||||
} from '@shared/services/navigation';
|
||||
import { NEVER, Observable, of } from 'rxjs';
|
||||
import { delay, first, map, switchMap } from 'rxjs/operators';
|
||||
|
||||
@Component({
|
||||
selector: 'shell-process-bar',
|
||||
templateUrl: 'process-bar.component.html',
|
||||
styleUrls: ['process-bar.component.css'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
standalone: false,
|
||||
})
|
||||
export class ShellProcessBarComponent implements OnInit {
|
||||
@ViewChild('processContainer')
|
||||
processContainer: ElementRef;
|
||||
|
||||
section$: Observable<'customer' | 'branch'> = NEVER;
|
||||
|
||||
processes$: Observable<ApplicationProcess[]> = NEVER;
|
||||
|
||||
showStartProcessText$: Observable<boolean> = NEVER;
|
||||
|
||||
hovered: boolean;
|
||||
showScrollArrows: boolean;
|
||||
showArrowLeft: boolean;
|
||||
showArrowRight: boolean;
|
||||
|
||||
trackByFn = (_: number, process: ApplicationProcess) => process.id;
|
||||
|
||||
openMessageModal = injectOpenMessageModal();
|
||||
|
||||
constructor(
|
||||
private _app: ApplicationService,
|
||||
private _router: Router,
|
||||
private _catalogNavigationService: ProductCatalogNavigationService,
|
||||
private _customerOrderNavigationService: CustomerOrdersNavigationService,
|
||||
private _checkoutService: DomainCheckoutService,
|
||||
private _breadcrumb: BreadcrumbService,
|
||||
) {}
|
||||
|
||||
ngOnInit() {
|
||||
this.initSection$();
|
||||
this.initProcesses$();
|
||||
this.initShowStartProcessText$();
|
||||
this.checkScrollArrowVisibility();
|
||||
}
|
||||
|
||||
initSection$() {
|
||||
this.section$ = of(undefined);
|
||||
}
|
||||
|
||||
initProcesses$() {
|
||||
this.processes$ = this.section$.pipe(
|
||||
switchMap((section) => this._app.getProcesses$(section)),
|
||||
);
|
||||
}
|
||||
|
||||
initShowStartProcessText$() {
|
||||
this.showStartProcessText$ = this.processes$.pipe(
|
||||
map((processes) => processes.length === 0),
|
||||
);
|
||||
}
|
||||
|
||||
async createProcess(target = 'product') {
|
||||
// const process = await this.createCartProcess();
|
||||
this.navigateTo(target, Date.now());
|
||||
|
||||
setTimeout(() => this.scrollToEnd(), 25);
|
||||
}
|
||||
|
||||
static REGEX_PROCESS_NAME = /^Vorgang \d+$/;
|
||||
|
||||
async createCartProcess() {
|
||||
return this._app.createCustomerProcess();
|
||||
}
|
||||
|
||||
async navigateTo(target: string, processId: number) {
|
||||
switch (target) {
|
||||
case 'product':
|
||||
await this._catalogNavigationService
|
||||
.getArticleSearchBasePath(processId)
|
||||
.navigate();
|
||||
break;
|
||||
case 'customer':
|
||||
await this._router.navigate([
|
||||
'/kunde',
|
||||
processId,
|
||||
'customer',
|
||||
'search',
|
||||
]);
|
||||
break;
|
||||
case 'goods-out':
|
||||
await this._router.navigate(['/kunde', processId, 'goods', 'out']);
|
||||
break;
|
||||
case 'order':
|
||||
await this._customerOrderNavigationService
|
||||
.getCustomerOrdersBasePath(processId)
|
||||
.navigate();
|
||||
break;
|
||||
|
||||
default:
|
||||
await this._router.navigate(['/kunde', processId, target]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
async closeAllProcesses() {
|
||||
const processes = await this.processes$.pipe(first()).toPromise();
|
||||
this.openMessageModal({
|
||||
title: 'Vorgänge schließen',
|
||||
message: `Sind Sie sich sicher, dass sie alle ${processes.length} Vorgänge schließen wollen?`,
|
||||
actions: [
|
||||
{ label: 'Abbrechen', value: false },
|
||||
{
|
||||
label: 'leere Warenkörbe',
|
||||
value: true,
|
||||
action: () => this.handleCloseEmptyCartProcesses(),
|
||||
},
|
||||
{
|
||||
label: 'Ja, alle',
|
||||
value: true,
|
||||
primary: true,
|
||||
action: () => this.handleCloseAllProcesses(),
|
||||
},
|
||||
],
|
||||
});
|
||||
this.checkScrollArrowVisibility();
|
||||
}
|
||||
|
||||
async handleCloseEmptyCartProcesses() {
|
||||
let processes = await this.processes$.pipe(first()).toPromise();
|
||||
for (const process of processes) {
|
||||
const cart = await this._checkoutService
|
||||
.getShoppingCart({ processId: process.id })
|
||||
.pipe(first())
|
||||
.toPromise();
|
||||
|
||||
if (cart?.items?.length === 0 || cart?.items === undefined) {
|
||||
this._app.removeProcess(process?.id);
|
||||
}
|
||||
|
||||
processes = await this.processes$.pipe(delay(1), first()).toPromise();
|
||||
|
||||
if (processes.length === 0) {
|
||||
this._router.navigate(['/kunde', 'dashboard']);
|
||||
} else {
|
||||
const lastest = processes.reduce(
|
||||
(prev, current) =>
|
||||
prev.activated > current.activated ? prev : current,
|
||||
processes[0],
|
||||
);
|
||||
const crumb = await this._breadcrumb
|
||||
.getLastActivatedBreadcrumbByKey$(lastest.id)
|
||||
.pipe(first())
|
||||
.toPromise();
|
||||
if (crumb) {
|
||||
this._router.navigate(coerceArray(crumb.path), {
|
||||
queryParams: crumb.params,
|
||||
});
|
||||
} else {
|
||||
this._router.navigate(['/kunde', lastest.id, 'product']);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async handleCloseAllProcesses() {
|
||||
const processes = await this.processes$.pipe(first()).toPromise();
|
||||
processes.forEach((process) => this._app.removeProcess(process?.id));
|
||||
this._router.navigate(['/kunde', 'dashboard']);
|
||||
}
|
||||
|
||||
onMouseWheel(event: any) {
|
||||
// Ermöglicht es, am Desktop die Prozessleiste mit dem Mausrad hoch/runter horizontal zu scrollen
|
||||
if (event.deltaY > 0) {
|
||||
this.processContainer.nativeElement.scrollLeft += 100;
|
||||
} else {
|
||||
this.processContainer.nativeElement.scrollLeft -= 100;
|
||||
}
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
scrollLeft() {
|
||||
this.processContainer.nativeElement.scrollLeft -= 100;
|
||||
}
|
||||
|
||||
scrollRight() {
|
||||
this.processContainer.nativeElement.scrollLeft += 100;
|
||||
}
|
||||
|
||||
scrollToEnd() {
|
||||
this.processContainer.nativeElement.scrollLeft =
|
||||
this.processContainer?.nativeElement?.scrollWidth +
|
||||
this.processContainer?.nativeElement?.scrollLeft;
|
||||
}
|
||||
|
||||
checkScrollArrowVisibility() {
|
||||
this.showScrollArrows =
|
||||
this.processContainer?.nativeElement?.scrollWidth > 0;
|
||||
this.showArrowRight =
|
||||
((this.processContainer?.nativeElement?.scrollWidth -
|
||||
this.processContainer?.nativeElement?.scrollLeft) |
|
||||
0) <=
|
||||
this.processContainer?.nativeElement?.offsetWidth;
|
||||
this.showArrowLeft = this.processContainer?.nativeElement?.scrollLeft <= 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,11 +111,7 @@
|
||||
*ifRole="'Store'"
|
||||
class="side-menu-group-item"
|
||||
(click)="closeSideMenu(); focusSearchBox()"
|
||||
[routerLink]="[
|
||||
'/',
|
||||
tabService.activatedTab()?.id || tabService.nextId(),
|
||||
'return',
|
||||
]"
|
||||
[routerLink]="['/', tabService.activatedTab()?.id || nextId(), 'return']"
|
||||
(isActiveChange)="focusSearchBox()"
|
||||
>
|
||||
<span class="side-menu-group-item-icon w-[2.375rem] h-12">
|
||||
@@ -307,7 +303,7 @@
|
||||
(click)="closeSideMenu(); focusSearchBox()"
|
||||
[routerLink]="[
|
||||
'/',
|
||||
tabService.activatedTab()?.id || tabService.nextId(),
|
||||
tabService.activatedTab()?.id || nextId(),
|
||||
'remission',
|
||||
]"
|
||||
(isActiveChange)="focusSearchBox(); remissionExpanded.set($event)"
|
||||
@@ -335,11 +331,7 @@
|
||||
<a
|
||||
class="side-menu-group-item"
|
||||
(click)="closeSideMenu(); focusSearchBox()"
|
||||
[routerLink]="[
|
||||
'/',
|
||||
tabService.activatedTab()?.id || tabService.nextId(),
|
||||
'remission',
|
||||
]"
|
||||
[routerLink]="['/', tabId(), 'remission']"
|
||||
(isActiveChange)="focusSearchBox()"
|
||||
sharedRegexRouterLinkActive="active"
|
||||
sharedRegexRouterLinkActiveTest="^\/\d*\/remission\/(mandatory|department)"
|
||||
@@ -350,12 +342,7 @@
|
||||
<a
|
||||
class="side-menu-group-item"
|
||||
(click)="closeSideMenu(); focusSearchBox()"
|
||||
[routerLink]="[
|
||||
'/',
|
||||
tabService.activatedTab()?.id || tabService.nextId(),
|
||||
'remission',
|
||||
'return-receipt',
|
||||
]"
|
||||
[routerLink]="['/', tabId(), 'remission', 'return-receipt']"
|
||||
(isActiveChange)="focusSearchBox()"
|
||||
sharedRegexRouterLinkActive="active"
|
||||
sharedRegexRouterLinkActiveTest="^\/\d*\/remission\/return-receipt"
|
||||
|
||||
@@ -29,11 +29,12 @@ import {
|
||||
PickUpShelfOutNavigationService,
|
||||
ProductCatalogNavigationService,
|
||||
} from '@shared/services/navigation';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import { toObservable, toSignal } from '@angular/core/rxjs-interop';
|
||||
|
||||
import { TabService } from '@isa/core/tabs';
|
||||
import { NgIconComponent, provideIcons } from '@ng-icons/core';
|
||||
import { isaNavigationRemission2, isaNavigationReturn } from '@isa/icons';
|
||||
import z from 'zod';
|
||||
|
||||
@Component({
|
||||
selector: 'shell-side-menu',
|
||||
@@ -71,6 +72,20 @@ export class ShellSideMenuComponent {
|
||||
#document = inject(DOCUMENT);
|
||||
tabService = inject(TabService);
|
||||
|
||||
staticTabIds = Object.values(
|
||||
this.#config.get('process.ids', z.record(z.coerce.number())),
|
||||
);
|
||||
|
||||
tabId = computed(() => {
|
||||
const tabId = this.tabService.activatedTab()?.id;
|
||||
if (this.staticTabIds.includes(tabId)) {
|
||||
return this.nextId();
|
||||
}
|
||||
return tabId || this.nextId();
|
||||
});
|
||||
|
||||
tabId$ = toObservable(this.tabId);
|
||||
|
||||
branchKey$ = this.#stockService.StockCurrentBranch().pipe(
|
||||
retry(3),
|
||||
map((x) => x.result.key),
|
||||
@@ -94,6 +109,10 @@ export class ShellSideMenuComponent {
|
||||
return this.#environment.matchTablet();
|
||||
}
|
||||
|
||||
nextId() {
|
||||
return Date.now();
|
||||
}
|
||||
|
||||
customerBasePath$ = this.activeProcess$.pipe(
|
||||
map((process) => {
|
||||
if (
|
||||
@@ -129,11 +148,7 @@ export class ShellSideMenuComponent {
|
||||
customerRewardRoute = computed(() => {
|
||||
const routeName = 'reward';
|
||||
const tabId = this.tabService.activatedTab()?.id;
|
||||
return this.#router.createUrlTree([
|
||||
'/',
|
||||
tabId || this.tabService.nextId(),
|
||||
routeName,
|
||||
]);
|
||||
return this.#router.createUrlTree(['/', tabId || this.nextId(), routeName]);
|
||||
});
|
||||
|
||||
pickUpShelfOutRoutePath$ = this.getLastActivatedCustomerProcessId$().pipe(
|
||||
@@ -344,23 +359,7 @@ export class ShellSideMenuComponent {
|
||||
}
|
||||
|
||||
getLastActivatedCustomerProcessId$() {
|
||||
return this.#app.getProcesses$('customer').pipe(
|
||||
map((processes) => {
|
||||
const lastCustomerProcess = processes
|
||||
.filter((process) => process.type === 'cart')
|
||||
.reduce((last, current) => {
|
||||
if (!last) return current;
|
||||
|
||||
if (last.activated > current.activated) {
|
||||
return last;
|
||||
} else {
|
||||
return current;
|
||||
}
|
||||
}, undefined);
|
||||
|
||||
return lastCustomerProcess?.id ?? Date.now();
|
||||
}),
|
||||
);
|
||||
return this.tabId$;
|
||||
}
|
||||
|
||||
closeSideMenu() {
|
||||
|
||||
@@ -1,40 +1,47 @@
|
||||
import { inject, Injectable, InjectionToken } from '@angular/core';
|
||||
import { z } from 'zod';
|
||||
import { coerceArray } from '@angular/cdk/coercion';
|
||||
|
||||
type JsonPrimitive = string | number | boolean | null | undefined;
|
||||
|
||||
export type JsonValue = Array<JsonPrimitive> | Record<string, JsonPrimitive> | JsonPrimitive;
|
||||
|
||||
export const CONFIG_DATA = new InjectionToken<JsonValue>('ConfigData');
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class Config {
|
||||
#config = inject(CONFIG_DATA);
|
||||
|
||||
/**
|
||||
* @deprecated Use `get` with a Zod schema instead
|
||||
*/
|
||||
get(path: string | string[]): any;
|
||||
get<TOut>(path: string | string[], zSchema: z.ZodSchema<TOut>): TOut;
|
||||
get<TOut>(path: string | string[], zSchema?: z.ZodSchema<TOut>): TOut | any {
|
||||
let result: JsonValue = this.#config;
|
||||
|
||||
if (typeof path === 'string') {
|
||||
path = path.split('.');
|
||||
}
|
||||
|
||||
for (const p of coerceArray(path)) {
|
||||
if (typeof result === 'object' && result !== null && !Array.isArray(result)) {
|
||||
result = (result as Record<string, JsonPrimitive>)[p];
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
if (result === null || result === undefined) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return zSchema ? zSchema.parse(result) : result;
|
||||
}
|
||||
}
|
||||
import { inject, Injectable, InjectionToken } from '@angular/core';
|
||||
import { z } from 'zod';
|
||||
import { coerceArray } from '@angular/cdk/coercion';
|
||||
|
||||
type JsonPrimitive = string | number | boolean | null | undefined;
|
||||
|
||||
export type JsonValue =
|
||||
| Array<JsonPrimitive>
|
||||
| Record<string, JsonPrimitive>
|
||||
| JsonPrimitive;
|
||||
|
||||
export const CONFIG_DATA = new InjectionToken<JsonValue>('ConfigData');
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class Config {
|
||||
#config = inject(CONFIG_DATA);
|
||||
|
||||
/**
|
||||
* @deprecated Use `get` with a Zod schema instead
|
||||
*/
|
||||
get(path: string | string[]): any;
|
||||
get<TOut>(path: string | string[], zSchema: z.ZodSchema<TOut>): TOut;
|
||||
get<TOut>(path: string | string[], zSchema?: z.ZodSchema<TOut>): TOut | any {
|
||||
let result: JsonValue = this.#config;
|
||||
|
||||
if (typeof path === 'string') {
|
||||
path = path.split('.');
|
||||
}
|
||||
|
||||
for (const p of coerceArray(path)) {
|
||||
if (
|
||||
typeof result === 'object' &&
|
||||
result !== null &&
|
||||
!Array.isArray(result)
|
||||
) {
|
||||
result = (result as Record<string, JsonPrimitive>)[p];
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
if (result === null || result === undefined) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return zSchema ? zSchema.parse(result) : result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,3 @@
|
||||
export * from './lib/idb.storage-provider';
|
||||
export * from './lib/local.storage-provider';
|
||||
export * from './lib/session.storage-provider';
|
||||
export * from './lib/signal-store-feature';
|
||||
export * from './lib/storage-provider';
|
||||
export * from './lib/storage';
|
||||
export * from './lib/user.storage-provider';
|
||||
export * from './lib/memory.storage-provider';
|
||||
export * from './lib/storage-providers';
|
||||
export * from './lib/signal-store-feature';
|
||||
export * from './lib/storage';
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { StorageProvider } from './storage-provider';
|
||||
|
||||
const DB_NAME = 'storage';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class IDBStorageProvider implements StorageProvider {
|
||||
private db!: IDBDatabase;
|
||||
|
||||
private async openDB(): Promise<IDBDatabase> {
|
||||
if (this.db) {
|
||||
return this.db; // Datenbank bereits geöffnet, bestehende Verbindung zurückgeben
|
||||
}
|
||||
|
||||
return new Promise<IDBDatabase>((resolve, reject) => {
|
||||
const request = indexedDB.open('isa-storage', 1);
|
||||
|
||||
request.onerror = (event) => {
|
||||
reject(event);
|
||||
};
|
||||
|
||||
request.onupgradeneeded = (event) => {
|
||||
this.db = (event.target as IDBOpenDBRequest).result;
|
||||
|
||||
if (!this.db.objectStoreNames.contains(DB_NAME)) {
|
||||
this.db.createObjectStore(DB_NAME, { keyPath: 'key' });
|
||||
}
|
||||
};
|
||||
|
||||
request.onsuccess = (event) => {
|
||||
this.db = (event.target as IDBOpenDBRequest).result;
|
||||
resolve(this.db);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private async getObjectStore(mode: IDBTransactionMode = 'readonly'): Promise<IDBObjectStore> {
|
||||
const db = await this.openDB();
|
||||
const transaction = db.transaction(DB_NAME, mode);
|
||||
return transaction.objectStore(DB_NAME);
|
||||
}
|
||||
|
||||
async set(key: string, value: unknown): Promise<void> {
|
||||
const store = await this.getObjectStore('readwrite');
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const request = store.put({ key, value });
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = (event) => reject(event);
|
||||
});
|
||||
}
|
||||
|
||||
async get(key: string): Promise<unknown> {
|
||||
const store = await this.getObjectStore();
|
||||
|
||||
return new Promise<unknown>((resolve, reject) => {
|
||||
const request = store.get(key);
|
||||
request.onsuccess = () => resolve(request.result?.value);
|
||||
request.onerror = (event) => reject(event);
|
||||
});
|
||||
}
|
||||
|
||||
async clear(key: string): Promise<void> {
|
||||
const store = await this.getObjectStore('readwrite');
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const request = store.delete(key);
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = (event) => reject(event);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,39 +1,32 @@
|
||||
import { Type } from '@angular/core';
|
||||
import {
|
||||
getState,
|
||||
patchState,
|
||||
signalStoreFeature,
|
||||
withHooks,
|
||||
withMethods,
|
||||
} from '@ngrx/signals';
|
||||
import { rxMethod } from '@ngrx/signals/rxjs-interop';
|
||||
import { StorageProvider } from './storage-provider';
|
||||
import { injectStorage } from './storage';
|
||||
import { debounceTime, pipe, switchMap } from 'rxjs';
|
||||
|
||||
export function withStorage(
|
||||
storageKey: string,
|
||||
storageProvider: Type<StorageProvider>,
|
||||
) {
|
||||
return signalStoreFeature(
|
||||
withMethods((store, storage = injectStorage(storageProvider)) => ({
|
||||
storeState: rxMethod<void>(
|
||||
pipe(
|
||||
debounceTime(1000),
|
||||
switchMap(() => storage.set(storageKey, getState(store))),
|
||||
),
|
||||
),
|
||||
restoreState: async () => {
|
||||
const data = await storage.get(storageKey);
|
||||
if (data && typeof data === 'object') {
|
||||
patchState(store, data);
|
||||
}
|
||||
},
|
||||
})),
|
||||
withHooks((store) => ({
|
||||
onInit() {
|
||||
store.restoreState();
|
||||
},
|
||||
})),
|
||||
);
|
||||
}
|
||||
import { Type } from '@angular/core';
|
||||
import {
|
||||
getState,
|
||||
patchState,
|
||||
signalStoreFeature,
|
||||
withHooks,
|
||||
withMethods,
|
||||
} from '@ngrx/signals';
|
||||
import { StorageProvider } from './storage-providers';
|
||||
import { injectStorage } from './storage';
|
||||
|
||||
export function withStorage(
|
||||
storageKey: string,
|
||||
storageProvider: Type<StorageProvider>,
|
||||
) {
|
||||
return signalStoreFeature(
|
||||
withMethods((store, storage = injectStorage(storageProvider)) => ({
|
||||
storeState: () => storage.set(storageKey, getState(store)),
|
||||
restoreState: async () => {
|
||||
const data = await storage.get(storageKey);
|
||||
if (data && typeof data === 'object') {
|
||||
patchState(store, data);
|
||||
}
|
||||
},
|
||||
})),
|
||||
withHooks((store) => ({
|
||||
onInit() {
|
||||
store.restoreState();
|
||||
},
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
export interface StorageProvider {
|
||||
set(key: string, value: unknown): Promise<void>;
|
||||
|
||||
get(key: string): Promise<unknown>;
|
||||
|
||||
clear(key: string): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import { Injectable, signal } from '@angular/core';
|
||||
import { StorageProvider } from './storage-provider';
|
||||
|
||||
const DB_NAME = 'storage';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class IDBStorageProvider implements StorageProvider {
|
||||
#store = signal<Record<string, unknown>>({});
|
||||
|
||||
#db!: IDBDatabase;
|
||||
|
||||
async #openDB(): Promise<IDBDatabase> {
|
||||
if (this.#db) {
|
||||
return this.#db; // Datenbank bereits geöffnet, bestehende Verbindung zurückgeben
|
||||
}
|
||||
|
||||
return new Promise<IDBDatabase>((resolve, reject) => {
|
||||
const request = indexedDB.open('isa-storage', 1);
|
||||
|
||||
request.onerror = (event) => {
|
||||
reject(event);
|
||||
};
|
||||
|
||||
request.onupgradeneeded = (event) => {
|
||||
this.#db = (event.target as IDBOpenDBRequest).result;
|
||||
|
||||
if (!this.#db.objectStoreNames.contains(DB_NAME)) {
|
||||
this.#db.createObjectStore(DB_NAME, { keyPath: 'key' });
|
||||
}
|
||||
};
|
||||
|
||||
request.onsuccess = (event) => {
|
||||
this.#db = (event.target as IDBOpenDBRequest).result;
|
||||
resolve(this.#db);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async #getObjectStore(
|
||||
mode: IDBTransactionMode = 'readonly',
|
||||
): Promise<IDBObjectStore> {
|
||||
const db = await this.#openDB();
|
||||
const transaction = db.transaction(DB_NAME, mode);
|
||||
return transaction.objectStore(DB_NAME);
|
||||
}
|
||||
|
||||
async init(): Promise<void> {
|
||||
const store = await this.#getObjectStore();
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const request = store.getAll();
|
||||
request.onsuccess = () => {
|
||||
const result = request.result as Array<{ key: string; value: unknown }>;
|
||||
const data: Record<string, unknown> = {};
|
||||
result.forEach((item) => {
|
||||
data[item.key] = item.value;
|
||||
});
|
||||
this.#store.set(data);
|
||||
resolve();
|
||||
};
|
||||
request.onerror = (event) => reject(event);
|
||||
});
|
||||
}
|
||||
|
||||
async reload(): Promise<void> {
|
||||
await this.init();
|
||||
}
|
||||
|
||||
#setState(state: Record<string, unknown>): void {
|
||||
this.#store.set(state);
|
||||
this.#writeStateToDB();
|
||||
}
|
||||
|
||||
#writeStateToDB(): Promise<void> {
|
||||
const entries = Object.entries(this.#store());
|
||||
return new Promise<void>(async (resolve, reject) => {
|
||||
try {
|
||||
const store = await this.#getObjectStore('readwrite');
|
||||
const transaction = store.transaction;
|
||||
|
||||
// Clear existing data first
|
||||
const clearRequest = store.clear();
|
||||
clearRequest.onsuccess = () => {
|
||||
// Add all current entries
|
||||
let completed = 0;
|
||||
const total = entries.length;
|
||||
|
||||
if (total === 0) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
entries.forEach(([key, value]) => {
|
||||
const addRequest = store.add({ key, value });
|
||||
|
||||
addRequest.onsuccess = () => {
|
||||
completed++;
|
||||
if (completed === total) {
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
|
||||
addRequest.onerror = () => {
|
||||
reject(addRequest.error);
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
clearRequest.onerror = () => {
|
||||
reject(clearRequest.error);
|
||||
};
|
||||
|
||||
transaction.onerror = () => {
|
||||
reject(transaction.error);
|
||||
};
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
set(key: string, value: unknown): void {
|
||||
const current = this.#store();
|
||||
const content = structuredClone(current);
|
||||
content[key] = value;
|
||||
|
||||
this.#setState(content);
|
||||
}
|
||||
|
||||
get(key: string): unknown {
|
||||
return this.#store()[key];
|
||||
}
|
||||
|
||||
clear(key: string): void {
|
||||
const current = this.#store();
|
||||
if (key in current) {
|
||||
const content = structuredClone(current);
|
||||
delete content[key];
|
||||
this.#setState(content);
|
||||
}
|
||||
}
|
||||
}
|
||||
6
libs/core/storage/src/lib/storage-providers/index.ts
Normal file
6
libs/core/storage/src/lib/storage-providers/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export * from './idb.storage-provider';
|
||||
export * from './local.storage-provider';
|
||||
export * from './memory.storage-provider';
|
||||
export * from './session.storage-provider';
|
||||
export * from './storage-provider';
|
||||
export * from './user.storage-provider';
|
||||
@@ -1,21 +1,21 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { StorageProvider } from './storage-provider';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class LocalStorageProvider implements StorageProvider {
|
||||
async set(key: string, value: unknown): Promise<void> {
|
||||
localStorage.setItem(key, JSON.stringify(value));
|
||||
}
|
||||
|
||||
async get(key: string): Promise<unknown> {
|
||||
const data = localStorage.getItem(key);
|
||||
if (data) {
|
||||
return JSON.parse(data);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
async clear(key: string): Promise<void> {
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
}
|
||||
import { Injectable } from '@angular/core';
|
||||
import { StorageProvider } from './storage-provider';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class LocalStorageProvider implements StorageProvider {
|
||||
set(key: string, value: unknown): void {
|
||||
localStorage.setItem(key, JSON.stringify(value));
|
||||
}
|
||||
|
||||
get(key: string): unknown {
|
||||
const data = localStorage.getItem(key);
|
||||
if (data) {
|
||||
return JSON.parse(data);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
clear(key: string): void {
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,19 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { StorageProvider } from './storage-provider';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class MemoryStorageProvider implements StorageProvider {
|
||||
#store = new Map<string, unknown>();
|
||||
|
||||
async set(key: string, value: unknown): Promise<void> {
|
||||
this.#store.set(key, value);
|
||||
}
|
||||
|
||||
async get(key: string): Promise<unknown> {
|
||||
return this.#store.get(key);
|
||||
}
|
||||
|
||||
async clear(key: string): Promise<void> {
|
||||
this.#store.delete(key);
|
||||
}
|
||||
}
|
||||
import { Injectable } from '@angular/core';
|
||||
import { StorageProvider } from './storage-provider';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class MemoryStorageProvider implements StorageProvider {
|
||||
#store = new Map<string, unknown>();
|
||||
|
||||
set(key: string, value: unknown): void {
|
||||
this.#store.set(key, value);
|
||||
}
|
||||
|
||||
get(key: string): unknown {
|
||||
return this.#store.get(key);
|
||||
}
|
||||
|
||||
clear(key: string): void {
|
||||
this.#store.delete(key);
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,20 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { StorageProvider } from './storage-provider';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class SessionStorageProvider implements StorageProvider {
|
||||
async set(key: string, value: unknown): Promise<void> {
|
||||
sessionStorage.setItem(key, JSON.stringify(value));
|
||||
}
|
||||
async get(key: string): Promise<unknown> {
|
||||
const data = sessionStorage.getItem(key);
|
||||
if (data) {
|
||||
return JSON.parse(data);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
async clear(key: string): Promise<void> {
|
||||
sessionStorage.removeItem(key);
|
||||
}
|
||||
}
|
||||
import { Injectable } from '@angular/core';
|
||||
import { StorageProvider } from './storage-provider';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class SessionStorageProvider implements StorageProvider {
|
||||
async set(key: string, value: unknown): Promise<void> {
|
||||
sessionStorage.setItem(key, JSON.stringify(value));
|
||||
}
|
||||
async get(key: string): Promise<unknown> {
|
||||
const data = sessionStorage.getItem(key);
|
||||
if (data) {
|
||||
return JSON.parse(data);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
async clear(key: string): Promise<void> {
|
||||
sessionStorage.removeItem(key);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export interface StorageProvider {
|
||||
init?(): Promise<void>;
|
||||
|
||||
reload?(): Promise<void>;
|
||||
|
||||
set(key: string, value: unknown): void;
|
||||
|
||||
get(key: string): unknown;
|
||||
|
||||
clear(key: string): void;
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { inject, Injectable, resource, ResourceStatus } from '@angular/core';
|
||||
import { StorageProvider } from './storage-provider';
|
||||
import { UserStateService } from '@generated/swagger/isa-api';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { USER_SUB } from '../tokens';
|
||||
|
||||
type UserState = Record<string, unknown>;
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class UserStorageProvider implements StorageProvider {
|
||||
#userStateService = inject(UserStateService);
|
||||
#userSub = inject(USER_SUB);
|
||||
|
||||
#userStateResource = resource<UserState, void>({
|
||||
params: () => this.#userSub(),
|
||||
loader: async () => {
|
||||
try {
|
||||
const res = await firstValueFrom(
|
||||
this.#userStateService.UserStateGetUserState(),
|
||||
);
|
||||
if (res?.result?.content) {
|
||||
return JSON.parse(res.result.content);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading user state:', error);
|
||||
}
|
||||
return {};
|
||||
},
|
||||
defaultValue: {},
|
||||
});
|
||||
|
||||
#setState(state: UserState) {
|
||||
this.#userStateResource.set(state);
|
||||
this.#postNewState(state);
|
||||
}
|
||||
|
||||
#postNewState(state: UserState) {
|
||||
firstValueFrom(
|
||||
this.#userStateService.UserStateSetUserState({
|
||||
content: JSON.stringify(state),
|
||||
}),
|
||||
).catch((error) => {
|
||||
console.error('Error saving user state:', error);
|
||||
});
|
||||
}
|
||||
|
||||
async init() {
|
||||
await this.#userStateInitialized();
|
||||
}
|
||||
|
||||
set(key: string, value: Record<string, unknown>): void {
|
||||
console.log('Setting user state key:', key, value);
|
||||
const current = this.#userStateResource.value();
|
||||
const content = structuredClone(current);
|
||||
content[key] = value;
|
||||
|
||||
this.#setState(content);
|
||||
}
|
||||
|
||||
get(key: string): unknown {
|
||||
console.log('Getting user state key:', key);
|
||||
return this.#userStateResource.value()[key];
|
||||
}
|
||||
|
||||
clear(key: string): void {
|
||||
const current = this.#userStateResource.value();
|
||||
if (key in current) {
|
||||
const content = structuredClone(current);
|
||||
delete content[key];
|
||||
this.#setState(content);
|
||||
}
|
||||
}
|
||||
|
||||
reload(): Promise<void> {
|
||||
this.#userStateResource.reload();
|
||||
|
||||
const reloadPromise = new Promise<void>((resolve) => {
|
||||
const check = setInterval(() => {
|
||||
if (!this.#userStateResource.isLoading()) {
|
||||
clearInterval(check);
|
||||
resolve();
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
|
||||
return reloadPromise;
|
||||
}
|
||||
|
||||
#userStateInitialized() {
|
||||
return new Promise<ResourceStatus>((resolve) => {
|
||||
const check = setInterval(() => {
|
||||
if (
|
||||
this.#userStateResource.status() === 'resolved' ||
|
||||
this.#userStateResource.status() === 'error'
|
||||
) {
|
||||
clearInterval(check);
|
||||
resolve(this.#userStateResource.status());
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,58 +1,45 @@
|
||||
import { inject, InjectionToken, Type } from '@angular/core';
|
||||
import { StorageProvider } from './storage-provider';
|
||||
import { z } from 'zod';
|
||||
import { OAuthService } from 'angular-oauth2-oidc';
|
||||
import { hash } from './hash.utils';
|
||||
|
||||
export const USER_SUB = new InjectionToken<() => string>(
|
||||
'core.storage.user-sub',
|
||||
{
|
||||
factory: () => {
|
||||
const auth = inject(OAuthService, { optional: true });
|
||||
return () => auth?.getIdentityClaims()?.['sub'] ?? 'anonymous';
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export class Storage {
|
||||
private readonly userSub = inject(USER_SUB);
|
||||
|
||||
constructor(private storageProvider: StorageProvider) {}
|
||||
|
||||
private getKey(token: string | object): string {
|
||||
const userSub = this.userSub();
|
||||
return `${userSub}:${hash(token)}`;
|
||||
}
|
||||
|
||||
set<T>(token: string | object, value: T): Promise<void> {
|
||||
return this.storageProvider.set(this.getKey(token), value);
|
||||
}
|
||||
|
||||
async get<T>(
|
||||
token: string | object,
|
||||
schema?: z.ZodType<T>,
|
||||
): Promise<T | unknown> {
|
||||
const data = await this.storageProvider.get(this.getKey(token));
|
||||
if (schema) {
|
||||
return schema.parse(data);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
async clear(token: string | object): Promise<void> {
|
||||
return this.storageProvider.clear(this.getKey(token));
|
||||
}
|
||||
}
|
||||
|
||||
const storageMap = new WeakMap<Type<StorageProvider>, Storage>();
|
||||
|
||||
export function injectStorage(storageProvider: Type<StorageProvider>): Storage {
|
||||
if (storageMap.has(storageProvider)) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
return storageMap.get(storageProvider)!;
|
||||
}
|
||||
|
||||
const storage = new Storage(inject(storageProvider));
|
||||
storageMap.set(storageProvider, storage);
|
||||
return storage;
|
||||
}
|
||||
import { inject, Type } from '@angular/core';
|
||||
import { StorageProvider } from './storage-providers';
|
||||
import { z } from 'zod';
|
||||
import { hash } from './hash.utils';
|
||||
import { USER_SUB } from './tokens';
|
||||
|
||||
export class Storage {
|
||||
#userSub = inject(USER_SUB);
|
||||
|
||||
constructor(private storageProvider: StorageProvider) {}
|
||||
|
||||
#getKey(token: string | object): string {
|
||||
const userSub = this.#userSub();
|
||||
return `${userSub}:${hash(token)}`;
|
||||
}
|
||||
|
||||
set<T>(token: string | object, value: T): void {
|
||||
this.storageProvider.set(this.#getKey(token), value);
|
||||
}
|
||||
|
||||
get<T>(token: string | object, schema?: z.ZodType<T>): T | unknown {
|
||||
const data = this.storageProvider.get(this.#getKey(token));
|
||||
if (schema) {
|
||||
return schema.parse(data);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
clear(token: string | object): void {
|
||||
this.storageProvider.clear(this.#getKey(token));
|
||||
}
|
||||
}
|
||||
|
||||
const storageMap = new WeakMap<Type<StorageProvider>, Storage>();
|
||||
|
||||
export function injectStorage(storageProvider: Type<StorageProvider>): Storage {
|
||||
if (storageMap.has(storageProvider)) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
return storageMap.get(storageProvider)!;
|
||||
}
|
||||
|
||||
const storage = new Storage(inject(storageProvider));
|
||||
storageMap.set(storageProvider, storage);
|
||||
return storage;
|
||||
}
|
||||
|
||||
12
libs/core/storage/src/lib/tokens.ts
Normal file
12
libs/core/storage/src/lib/tokens.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { inject, InjectionToken } from '@angular/core';
|
||||
import { OAuthService } from 'angular-oauth2-oidc';
|
||||
|
||||
export const USER_SUB = new InjectionToken<() => string>(
|
||||
'core.storage.user-sub',
|
||||
{
|
||||
factory: () => {
|
||||
const auth = inject(OAuthService, { optional: true });
|
||||
return () => auth?.getIdentityClaims()?.['sub'] ?? 'anonymous';
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -1,54 +0,0 @@
|
||||
import { inject, Injectable } from "@angular/core";
|
||||
import { StorageProvider } from "./storage-provider";
|
||||
import { UserStateService } from "@generated/swagger/isa-api";
|
||||
import { catchError, firstValueFrom, map, of } from "rxjs";
|
||||
import { isEmpty } from "lodash";
|
||||
|
||||
@Injectable({ providedIn: "root" })
|
||||
export class UserStorageProvider implements StorageProvider {
|
||||
#userStateService = inject(UserStateService);
|
||||
|
||||
private state$ = this.#userStateService.UserStateGetUserState().pipe(
|
||||
map((res) => {
|
||||
if (res?.result?.content) {
|
||||
return JSON.parse(res.result.content);
|
||||
}
|
||||
return {};
|
||||
}),
|
||||
catchError((err) => {
|
||||
console.warn(
|
||||
"No UserStateGetUserState found, returning empty object:",
|
||||
err,
|
||||
);
|
||||
return of({}); // Return empty state fallback
|
||||
}),
|
||||
// shareReplay(1), #5249, #5270 Würde beim Fehlerfall den fehlerhaften Zustand behalten
|
||||
// Aktuell wird nun jedes mal 2 mal der UserState aufgerufen (GET + POST)
|
||||
// Damit bei der set Funktion immer der aktuelle Zustand verwendet wird
|
||||
);
|
||||
|
||||
async set(key: string, value: Record<string, unknown>): Promise<void> {
|
||||
const current = await firstValueFrom(this.state$);
|
||||
const content =
|
||||
current && !isEmpty(current)
|
||||
? { ...current, [key]: value }
|
||||
: { [key]: value };
|
||||
|
||||
await firstValueFrom(
|
||||
this.#userStateService.UserStateSetUserState({
|
||||
content: JSON.stringify(content),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async get(key: string): Promise<unknown> {
|
||||
const userState = await firstValueFrom(this.state$);
|
||||
return userState[key];
|
||||
}
|
||||
|
||||
async clear(key: string): Promise<void> {
|
||||
const current = await firstValueFrom(this.state$);
|
||||
delete current[key];
|
||||
firstValueFrom(this.#userStateService.UserStateResetUserState());
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
export * from './lib/tab';
|
||||
export * from './lib/tab.injector';
|
||||
export * from './lib/tab.resolver-fn';
|
||||
export * from './lib/tab.schemas';
|
||||
export * from './lib/tab.service';
|
||||
export * from './lib/tab.injector';
|
||||
export * from './lib/tab.resolver-fn';
|
||||
export * from './lib/schemas';
|
||||
export * from './lib/tab';
|
||||
export * from './lib/tab-navigation.service';
|
||||
export * from './lib/tab-config';
|
||||
|
||||
281
libs/core/tabs/src/lib/schemas.ts
Normal file
281
libs/core/tabs/src/lib/schemas.ts
Normal file
@@ -0,0 +1,281 @@
|
||||
/**
|
||||
* @fileoverview Zod schemas and TypeScript types for tab system data validation and type safety.
|
||||
*
|
||||
* This module provides comprehensive type definitions and runtime validation schemas for:
|
||||
* - Tab location history management
|
||||
* - Tab metadata with history configuration
|
||||
* - Tab entity creation, updates, and persistence
|
||||
* - Schema validation for all tab-related operations
|
||||
*
|
||||
* The schemas ensure data integrity while TypeScript interfaces provide compile-time
|
||||
* type safety. Separate types are provided for different use cases (creation, updates,
|
||||
* persistence) to maintain strict typing throughout the application.
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Schema for validating tab location entries.
|
||||
*
|
||||
* Each location represents a navigation point in a tab's history,
|
||||
* capturing the URL, page title, and timestamp for later navigation.
|
||||
*/
|
||||
export const TabLocationSchema = z.object({
|
||||
/** Timestamp when this location was visited (milliseconds since epoch) */
|
||||
timestamp: z.number(),
|
||||
/** Human-readable page title */
|
||||
title: z.string(),
|
||||
/** Full URL of the location */
|
||||
url: z.string(),
|
||||
});
|
||||
|
||||
/** TypeScript type for tab location entries */
|
||||
export type TabLocation = z.infer<typeof TabLocationSchema>;
|
||||
|
||||
/**
|
||||
* Schema for tab location history management.
|
||||
*
|
||||
* Tracks navigation history with a current index pointing to the
|
||||
* active location in the history array. Supports back/forward navigation.
|
||||
*/
|
||||
export const TabLocationHistorySchema = z.object({
|
||||
/** Current index in the locations array (-1 for empty history) */
|
||||
current: z.number(),
|
||||
/** Array of location history entries */
|
||||
locations: z.array(TabLocationSchema),
|
||||
});
|
||||
|
||||
/** TypeScript type for tab location history */
|
||||
export type TabLocationHistory = z.infer<typeof TabLocationHistorySchema>;
|
||||
|
||||
/**
|
||||
* Base schema for tab metadata (arbitrary key-value pairs).
|
||||
*
|
||||
* Allows storing custom data associated with tabs. Defaults to empty object.
|
||||
*/
|
||||
export const TabMetadataSchema = z.record(z.unknown()).default({});
|
||||
|
||||
/** TypeScript type for basic tab metadata */
|
||||
export type TabMetadata = z.infer<typeof TabMetadataSchema>;
|
||||
|
||||
/**
|
||||
* Extended metadata schema supporting history configuration overrides.
|
||||
*
|
||||
* Allows individual tabs to override global history limits and behavior.
|
||||
* Uses passthrough() to preserve other metadata properties not defined here.
|
||||
*/
|
||||
export const TabMetadataWithHistorySchema = z.object({
|
||||
/** Override for maximum history size (1-1000 entries) */
|
||||
maxHistorySize: z.number().min(1).max(1000).optional(),
|
||||
/** Override for maximum forward history (0-100 entries) */
|
||||
maxForwardHistory: z.number().min(0).max(100).optional(),
|
||||
}).passthrough().default({});
|
||||
|
||||
/** TypeScript type for metadata with history configuration */
|
||||
export type TabMetadataWithHistory = z.infer<typeof TabMetadataWithHistorySchema>;
|
||||
|
||||
/**
|
||||
* Schema for tab tags (array of strings).
|
||||
*
|
||||
* Tags can be used for categorization, filtering, or custom tab organization.
|
||||
*/
|
||||
export const TabTagsSchema = z.array(z.string()).default([]);
|
||||
|
||||
/** TypeScript type for tab tags */
|
||||
export type TabTags = z.infer<typeof TabTagsSchema>;
|
||||
|
||||
/**
|
||||
* Base schema for tab validation (runtime validation only).
|
||||
*
|
||||
* This schema is primarily used for validation purposes. For NgRx entities,
|
||||
* use the separate Tab interface which ensures required properties.
|
||||
*/
|
||||
export const TabSchema = z.object({
|
||||
/** Unique identifier for the tab */
|
||||
id: z.number(),
|
||||
/** Display name for the tab (minimum 1 character) */
|
||||
name: z.string().min(1),
|
||||
/** Creation timestamp (milliseconds since epoch) */
|
||||
createdAt: z.number(),
|
||||
/** Last activation timestamp (optional) */
|
||||
activatedAt: z.number().optional(),
|
||||
/** Custom metadata for the tab */
|
||||
metadata: TabMetadataSchema,
|
||||
/** Navigation history for the tab */
|
||||
location: TabLocationHistorySchema,
|
||||
/** Array of tags for organization */
|
||||
tags: TabTagsSchema,
|
||||
});
|
||||
|
||||
/**
|
||||
* NgRx-compatible Tab interface with strict typing.
|
||||
*
|
||||
* This interface ensures all required properties are present for NgRx entity
|
||||
* operations. Separates compile-time typing from runtime validation.
|
||||
*/
|
||||
export interface Tab {
|
||||
/** Unique identifier (required for NgRx entities) */
|
||||
id: number;
|
||||
/** Display name for the tab */
|
||||
name: string;
|
||||
/** Creation timestamp */
|
||||
createdAt: number;
|
||||
/** Last activation timestamp */
|
||||
activatedAt?: number;
|
||||
/** Custom metadata object */
|
||||
metadata: Record<string, unknown>;
|
||||
/** Navigation history state */
|
||||
location: TabLocationHistory;
|
||||
/** Organization tags */
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Type for creating new tabs where id can be auto-generated.
|
||||
*
|
||||
* Used when creating tabs where the ID might be generated by the system.
|
||||
* All other properties are required for proper tab initialization.
|
||||
*/
|
||||
export interface TabCreate {
|
||||
/** Optional ID (can be generated if not provided) */
|
||||
id?: number;
|
||||
/** Display name for the tab */
|
||||
name: string;
|
||||
/** Creation timestamp */
|
||||
createdAt: number;
|
||||
/** Last activation timestamp */
|
||||
activatedAt?: number;
|
||||
/** Custom metadata object */
|
||||
metadata: Record<string, unknown>;
|
||||
/** Navigation history state */
|
||||
location: TabLocationHistory;
|
||||
/** Organization tags */
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Schema for validating persisted tabs loaded from storage.
|
||||
*
|
||||
* Ensures tabs loaded from sessionStorage/localStorage have all required
|
||||
* properties with strict validation (no extra properties allowed).
|
||||
*/
|
||||
export const PersistedTabSchema = z.object({
|
||||
/** Required unique identifier */
|
||||
id: z.number(),
|
||||
/** Tab display name */
|
||||
name: z.string().min(1),
|
||||
/** Creation timestamp */
|
||||
createdAt: z.number(),
|
||||
/** Last activation timestamp */
|
||||
activatedAt: z.number().optional(),
|
||||
/** Custom metadata */
|
||||
metadata: TabMetadataSchema,
|
||||
/** Navigation history */
|
||||
location: TabLocationHistorySchema,
|
||||
/** Organization tags */
|
||||
tags: TabTagsSchema,
|
||||
}).strict();
|
||||
|
||||
/** Input type for TabSchema (before validation) */
|
||||
export type TabInput = z.input<typeof TabSchema>;
|
||||
|
||||
/**
|
||||
* Schema for adding new tabs to the system.
|
||||
*
|
||||
* Defines the minimal required properties for tab creation.
|
||||
* ID and activatedAt are optional as they can be auto-generated.
|
||||
*/
|
||||
export const AddTabSchema = z.object({
|
||||
/** Display name for the new tab */
|
||||
name: z.string().min(1),
|
||||
/** Initial tags for the tab */
|
||||
tags: TabTagsSchema,
|
||||
/** Initial metadata for the tab */
|
||||
metadata: TabMetadataSchema,
|
||||
/** Optional ID (auto-generated if not provided) */
|
||||
id: z.number().optional(),
|
||||
/** Optional activation timestamp */
|
||||
activatedAt: z.number().optional(),
|
||||
});
|
||||
|
||||
/** TypeScript type for adding tabs */
|
||||
export type AddTab = z.infer<typeof AddTabSchema>;
|
||||
|
||||
/** Input type for AddTabSchema (before validation) */
|
||||
export type AddTabInput = z.input<typeof AddTabSchema>;
|
||||
|
||||
/**
|
||||
* Schema for tab entity updates (NgRx-compatible partial updates).
|
||||
*
|
||||
* Defines optional properties that can be updated on existing tabs.
|
||||
* All properties are optional to support partial updates.
|
||||
*/
|
||||
export const TabUpdateSchema = z.object({
|
||||
/** Updated display name */
|
||||
name: z.string().min(1).optional(),
|
||||
/** Updated activation timestamp */
|
||||
activatedAt: z.number().optional(),
|
||||
/** Updated metadata object */
|
||||
metadata: z.record(z.unknown()).optional(),
|
||||
/** Updated location history */
|
||||
location: TabLocationHistorySchema.optional(),
|
||||
/** Updated tags array */
|
||||
tags: z.array(z.string()).optional(),
|
||||
}).strict();
|
||||
|
||||
/** TypeScript type for tab updates */
|
||||
export type TabUpdate = z.infer<typeof TabUpdateSchema>;
|
||||
|
||||
/**
|
||||
* Schema for tab activation updates.
|
||||
*
|
||||
* Specifically validates activation timestamp updates when
|
||||
* switching between tabs.
|
||||
*/
|
||||
export const TabActivationUpdateSchema = z.object({
|
||||
/** New activation timestamp */
|
||||
activatedAt: z.number(),
|
||||
}).strict();
|
||||
|
||||
/** TypeScript type for activation updates */
|
||||
export type TabActivationUpdate = z.infer<typeof TabActivationUpdateSchema>;
|
||||
|
||||
/**
|
||||
* Schema for tab metadata updates.
|
||||
*
|
||||
* Validates metadata-only updates to avoid affecting other tab properties.
|
||||
*/
|
||||
export const TabMetadataUpdateSchema = z.object({
|
||||
/** Updated metadata object */
|
||||
metadata: z.record(z.unknown()),
|
||||
}).strict();
|
||||
|
||||
/** TypeScript type for metadata updates */
|
||||
export type TabMetadataUpdate = z.infer<typeof TabMetadataUpdateSchema>;
|
||||
|
||||
/**
|
||||
* Schema for tab location history updates.
|
||||
*
|
||||
* Validates navigation history updates when tabs navigate to new locations.
|
||||
*/
|
||||
export const TabLocationUpdateSchema = z.object({
|
||||
/** Updated location history */
|
||||
location: TabLocationHistorySchema,
|
||||
}).strict();
|
||||
|
||||
/** TypeScript type for location updates */
|
||||
export type TabLocationUpdate = z.infer<typeof TabLocationUpdateSchema>;
|
||||
|
||||
/**
|
||||
* Legacy patch schema for backward compatibility.
|
||||
*
|
||||
* Maintained for existing code that uses the "patch" terminology.
|
||||
* Functionally identical to TabUpdateSchema.
|
||||
*/
|
||||
export const PatchTabSchema = TabUpdateSchema;
|
||||
|
||||
/** TypeScript type for legacy patch operations */
|
||||
export type PatchTab = z.infer<typeof PatchTabSchema>;
|
||||
|
||||
/** Input type for legacy patch operations */
|
||||
export type PatchTabInput = z.input<typeof PatchTabSchema>;
|
||||
114
libs/core/tabs/src/lib/tab-config.ts
Normal file
114
libs/core/tabs/src/lib/tab-config.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* @fileoverview Configuration system for tab history management with configurable limits and pruning strategies.
|
||||
*
|
||||
* This module provides:
|
||||
* - TabConfig interface for defining history behavior
|
||||
* - DEFAULT_TAB_CONFIG with sensible defaults
|
||||
* - TAB_CONFIG injection token for dependency injection
|
||||
* - Utility functions for config management
|
||||
*
|
||||
* The configuration controls how tab navigation history is managed, including
|
||||
* size limits, pruning strategies, and validation behavior.
|
||||
*/
|
||||
|
||||
import { InjectionToken } from '@angular/core';
|
||||
|
||||
/**
|
||||
* Configuration interface for tab history management.
|
||||
*
|
||||
* Controls how navigation history is stored and managed for individual tabs,
|
||||
* including automatic pruning when limits are exceeded.
|
||||
*/
|
||||
export interface TabConfig {
|
||||
/**
|
||||
* Maximum number of history entries per tab
|
||||
* @default 50
|
||||
*/
|
||||
maxHistorySize: number;
|
||||
|
||||
/**
|
||||
* Maximum number of forward history entries to keep when navigating to a new location
|
||||
* @default 10
|
||||
*/
|
||||
maxForwardHistory: number;
|
||||
|
||||
/**
|
||||
* Strategy for pruning history when size limit is exceeded
|
||||
* - 'oldest': Remove oldest entries first
|
||||
* - 'balanced': Keep entries around current position
|
||||
* - 'smart': Intelligent pruning based on navigation patterns
|
||||
* @default 'balanced'
|
||||
*/
|
||||
pruningStrategy: 'oldest' | 'balanced' | 'smart';
|
||||
|
||||
/**
|
||||
* Enable automatic index validation after operations
|
||||
* @default true
|
||||
*/
|
||||
enableIndexValidation: boolean;
|
||||
|
||||
/**
|
||||
* Log warnings when history is pruned
|
||||
* @default false
|
||||
*/
|
||||
logPruning: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default configuration for tab history management.
|
||||
*
|
||||
* Provides balanced settings suitable for most applications:
|
||||
* - 50 history entries max (prevents memory bloat)
|
||||
* - 10 forward history entries (reasonable redo depth)
|
||||
* - Balanced pruning strategy (keeps recent + nearby entries)
|
||||
* - Index validation enabled (maintains data integrity)
|
||||
* - Pruning logs disabled (reduces console noise)
|
||||
*/
|
||||
export const DEFAULT_TAB_CONFIG: TabConfig = {
|
||||
maxHistorySize: 50,
|
||||
maxForwardHistory: 10,
|
||||
pruningStrategy: 'balanced',
|
||||
enableIndexValidation: true,
|
||||
logPruning: false,
|
||||
};
|
||||
|
||||
/**
|
||||
* Angular injection token for tab configuration.
|
||||
*
|
||||
* Use this token to inject tab configuration into services and components.
|
||||
* Defaults to DEFAULT_TAB_CONFIG when not explicitly provided.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* constructor(@Inject(TAB_CONFIG) private config: TabConfig) {}
|
||||
* ```
|
||||
*/
|
||||
export const TAB_CONFIG = new InjectionToken<TabConfig>('TAB_CONFIG', {
|
||||
providedIn: 'root',
|
||||
factory: () => DEFAULT_TAB_CONFIG,
|
||||
});
|
||||
|
||||
/**
|
||||
* Creates a tab configuration by merging partial config with defaults.
|
||||
*
|
||||
* Useful for creating custom configurations while maintaining defaults
|
||||
* for unspecified properties.
|
||||
*
|
||||
* @param partialConfig - Partial configuration to merge with defaults
|
||||
* @returns Complete TabConfig with merged values
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const customConfig = createTabConfig({
|
||||
* maxHistorySize: 100,
|
||||
* logPruning: true
|
||||
* });
|
||||
* // Results in: { maxHistorySize: 100, logPruning: true, ...other defaults }
|
||||
* ```
|
||||
*/
|
||||
export function createTabConfig(partialConfig?: Partial<TabConfig>): TabConfig {
|
||||
return {
|
||||
...DEFAULT_TAB_CONFIG,
|
||||
...partialConfig,
|
||||
};
|
||||
}
|
||||
341
libs/core/tabs/src/lib/tab-history-pruning.ts
Normal file
341
libs/core/tabs/src/lib/tab-history-pruning.ts
Normal file
@@ -0,0 +1,341 @@
|
||||
/**
|
||||
* @fileoverview Tab history pruning utilities with multiple strategies for managing navigation history size.
|
||||
*
|
||||
* This module provides sophisticated history management for tab navigation:
|
||||
* - Three pruning strategies: oldest, balanced, and smart
|
||||
* - Forward history limiting when adding new locations
|
||||
* - Index validation and correction utilities
|
||||
* - Configurable pruning behavior per tab or globally
|
||||
*
|
||||
* The pruning system prevents unlimited memory growth while preserving
|
||||
* navigation functionality and user experience. Each strategy offers
|
||||
* different trade-offs between memory usage and history preservation.
|
||||
*/
|
||||
|
||||
import { TabLocation, TabLocationHistory } from './schemas';
|
||||
import { TabConfig } from './tab-config';
|
||||
|
||||
/**
|
||||
* Result of a history pruning operation.
|
||||
*
|
||||
* Contains the pruned location array, updated current index,
|
||||
* and metadata about the pruning operation performed.
|
||||
*/
|
||||
export interface HistoryPruningResult {
|
||||
/** Array of locations after pruning */
|
||||
locations: TabLocation[];
|
||||
/** Updated current index after pruning */
|
||||
newCurrent: number;
|
||||
/** Number of entries removed during pruning */
|
||||
entriesRemoved: number;
|
||||
/** Name of the pruning strategy used */
|
||||
strategy: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Static utility class for managing tab navigation history pruning.
|
||||
*
|
||||
* Provides multiple strategies for reducing history size while maintaining
|
||||
* navigation functionality. All methods are static and stateless.
|
||||
*/
|
||||
export class TabHistoryPruner {
|
||||
|
||||
/**
|
||||
* Prunes history based on the configured strategy.
|
||||
*
|
||||
* Automatically selects and applies the appropriate pruning strategy
|
||||
* based on configuration. Supports per-tab metadata overrides.
|
||||
*
|
||||
* @param locationHistory - Current tab location history to prune
|
||||
* @param config - Global tab configuration
|
||||
* @param tabMetadata - Optional per-tab configuration overrides
|
||||
* @returns Pruning result with updated locations and metadata
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const result = TabHistoryPruner.pruneHistory(
|
||||
* tab.location,
|
||||
* globalConfig,
|
||||
* { maxHistorySize: 25 }
|
||||
* );
|
||||
* if (result.entriesRemoved > 0) {
|
||||
* console.log(`Pruned ${result.entriesRemoved} entries using ${result.strategy}`);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
static pruneHistory(
|
||||
locationHistory: TabLocationHistory,
|
||||
config: TabConfig,
|
||||
tabMetadata?: { maxHistorySize?: number; maxForwardHistory?: number }
|
||||
): HistoryPruningResult {
|
||||
const maxSize = tabMetadata?.maxHistorySize ?? config.maxHistorySize;
|
||||
const { locations, current } = locationHistory;
|
||||
|
||||
if (locations.length <= maxSize) {
|
||||
return {
|
||||
locations: [...locations],
|
||||
newCurrent: current,
|
||||
entriesRemoved: 0,
|
||||
strategy: 'no-pruning'
|
||||
};
|
||||
}
|
||||
|
||||
const strategy = config.pruningStrategy;
|
||||
|
||||
switch (strategy) {
|
||||
case 'oldest':
|
||||
return this.pruneOldestFirst(locations, current, maxSize);
|
||||
case 'balanced':
|
||||
return this.pruneBalanced(locations, current, maxSize);
|
||||
case 'smart':
|
||||
return this.pruneSmart(locations, current, maxSize, config);
|
||||
default:
|
||||
return this.pruneBalanced(locations, current, maxSize);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes oldest entries first, adjusting current index.
|
||||
*
|
||||
* Simple FIFO (First In, First Out) pruning strategy that removes
|
||||
* the oldest history entries when the size limit is exceeded.
|
||||
* Preserves recent navigation while maintaining current position.
|
||||
*
|
||||
* @param locations - Array of tab locations to prune
|
||||
* @param current - Current index in the locations array
|
||||
* @param maxSize - Maximum number of locations to keep
|
||||
* @returns Pruning result with updated locations and index
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // With locations [A, B, C, D, E] and current=2 (C), maxSize=3
|
||||
* // Result: [C, D, E] with newCurrent=0 (still pointing to C)
|
||||
* ```
|
||||
*/
|
||||
private static pruneOldestFirst(
|
||||
locations: TabLocation[],
|
||||
current: number,
|
||||
maxSize: number
|
||||
): HistoryPruningResult {
|
||||
const removeCount = locations.length - maxSize;
|
||||
const prunedLocations = locations.slice(removeCount);
|
||||
const newCurrent = Math.max(-1, current - removeCount);
|
||||
|
||||
return {
|
||||
locations: prunedLocations,
|
||||
newCurrent,
|
||||
entriesRemoved: removeCount,
|
||||
strategy: 'oldest-first'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps entries balanced around the current position.
|
||||
*
|
||||
* Maintains a balanced window around the current location, preserving
|
||||
* 70% of entries before current and 30% after. This strategy provides
|
||||
* good back/forward navigation while respecting size limits.
|
||||
*
|
||||
* @param locations - Array of tab locations to prune
|
||||
* @param current - Current index in the locations array
|
||||
* @param maxSize - Maximum number of locations to keep
|
||||
* @returns Pruning result with maintained current position
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // With current=5 in 20 locations, maxSize=10
|
||||
* // Keeps ~7 entries before current, current entry, ~2 entries after
|
||||
* // Result preserves navigation context around current position
|
||||
* ```
|
||||
*/
|
||||
private static pruneBalanced(
|
||||
locations: TabLocation[],
|
||||
current: number,
|
||||
maxSize: number
|
||||
): HistoryPruningResult {
|
||||
// Preserve 70% of entries before current, 30% after
|
||||
const backwardRatio = 0.7;
|
||||
const maxBackward = Math.floor(maxSize * backwardRatio);
|
||||
const maxForward = maxSize - maxBackward - 1; // -1 for current item
|
||||
|
||||
const keepStart = Math.max(0, current - maxBackward);
|
||||
const keepEnd = Math.min(locations.length, current + 1 + maxForward);
|
||||
|
||||
const prunedLocations = locations.slice(keepStart, keepEnd);
|
||||
const newCurrent = current - keepStart;
|
||||
const entriesRemoved = locations.length - prunedLocations.length;
|
||||
|
||||
return {
|
||||
locations: prunedLocations,
|
||||
newCurrent,
|
||||
entriesRemoved,
|
||||
strategy: 'balanced'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Intelligent pruning based on usage patterns and recency.
|
||||
*
|
||||
* Uses a scoring algorithm that considers both recency (how recently
|
||||
* a location was visited) and proximity (how close to current position).
|
||||
* Recent locations and those near the current position get higher scores
|
||||
* and are more likely to be preserved.
|
||||
*
|
||||
* Scoring factors:
|
||||
* - Recent (< 1 hour): 100 points base
|
||||
* - Medium (< 1 day): 60 points base
|
||||
* - Old (> 1 day): 20 points base
|
||||
* - Proximity: 100 - (distance_from_current * 10) points
|
||||
*
|
||||
* @param locations - Array of tab locations to prune
|
||||
* @param current - Current index in the locations array
|
||||
* @param maxSize - Maximum number of locations to keep
|
||||
* @param config - Tab configuration (unused but kept for consistency)
|
||||
* @returns Pruning result with intelligently selected locations
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Preserves recently visited pages and those near current position
|
||||
* // while removing old, distant entries first
|
||||
* ```
|
||||
*/
|
||||
private static pruneSmart(
|
||||
locations: TabLocation[],
|
||||
current: number,
|
||||
maxSize: number,
|
||||
config: TabConfig
|
||||
): HistoryPruningResult {
|
||||
const now = Date.now();
|
||||
const oneHour = 60 * 60 * 1000;
|
||||
const oneDay = 24 * oneHour;
|
||||
|
||||
// Score each location based on recency and distance from current
|
||||
const scoredLocations = locations.map((location, index) => {
|
||||
const age = now - location.timestamp;
|
||||
const distanceFromCurrent = Math.abs(index - current);
|
||||
|
||||
// Recent locations get higher scores
|
||||
let recencyScore = 100;
|
||||
if (age > oneDay) recencyScore = 20;
|
||||
else if (age > oneHour) recencyScore = 60;
|
||||
|
||||
// Locations near current position get higher scores
|
||||
const proximityScore = Math.max(0, 100 - (distanceFromCurrent * 10));
|
||||
|
||||
return {
|
||||
location,
|
||||
index,
|
||||
score: recencyScore + proximityScore,
|
||||
isCurrent: index === current
|
||||
};
|
||||
});
|
||||
|
||||
// Always keep current location and sort others by score
|
||||
const currentItem = scoredLocations.find(item => item.isCurrent);
|
||||
const otherItems = scoredLocations
|
||||
.filter(item => !item.isCurrent)
|
||||
.sort((a, b) => b.score - a.score);
|
||||
|
||||
// Take top scoring items
|
||||
const itemsToKeep = Math.min(maxSize - 1, otherItems.length); // -1 for current
|
||||
const keptItems = otherItems.slice(0, itemsToKeep);
|
||||
|
||||
if (currentItem) {
|
||||
keptItems.push(currentItem);
|
||||
}
|
||||
|
||||
// Sort by original index to maintain order
|
||||
keptItems.sort((a, b) => a.index - b.index);
|
||||
|
||||
const prunedLocations = keptItems.map(item => item.location);
|
||||
const newCurrent = keptItems.findIndex(item => item.isCurrent);
|
||||
const entriesRemoved = locations.length - prunedLocations.length;
|
||||
|
||||
return {
|
||||
locations: prunedLocations,
|
||||
newCurrent: newCurrent === -1 ? Math.max(0, prunedLocations.length - 1) : newCurrent,
|
||||
entriesRemoved,
|
||||
strategy: 'smart'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Prunes forward history when adding a new location.
|
||||
*
|
||||
* Limits the number of forward history entries that are preserved
|
||||
* when navigating to a new location. This prevents unlimited
|
||||
* forward history accumulation while maintaining reasonable redo depth.
|
||||
*
|
||||
* @param locations - Current array of tab locations
|
||||
* @param current - Current index in the locations array
|
||||
* @param maxForwardHistory - Maximum forward entries to preserve
|
||||
* @returns Object with pruned locations and unchanged current index
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // With current=2, maxForwardHistory=2
|
||||
* // [A, B, C, D, E, F] becomes [A, B, C, D, E]
|
||||
* // Preserves current position while limiting forward entries
|
||||
* ```
|
||||
*/
|
||||
static pruneForwardHistory(
|
||||
locations: TabLocation[],
|
||||
current: number,
|
||||
maxForwardHistory: number
|
||||
): { locations: TabLocation[]; newCurrent: number } {
|
||||
if (current < 0 || current >= locations.length) {
|
||||
return { locations: [...locations], newCurrent: current };
|
||||
}
|
||||
|
||||
const beforeCurrent = locations.slice(0, current + 1);
|
||||
const afterCurrent = locations.slice(current + 1);
|
||||
|
||||
const limitedAfter = afterCurrent.slice(0, maxForwardHistory);
|
||||
const prunedLocations = [...beforeCurrent, ...limitedAfter];
|
||||
|
||||
return {
|
||||
locations: prunedLocations,
|
||||
newCurrent: current // Current position unchanged
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates and corrects location history index.
|
||||
*
|
||||
* Ensures the current index is within valid bounds for the locations array.
|
||||
* Corrects invalid indices to the nearest valid value and reports whether
|
||||
* correction was needed. Essential for maintaining data integrity after
|
||||
* history modifications.
|
||||
*
|
||||
* @param locations - Array of tab locations to validate against
|
||||
* @param current - Current index to validate
|
||||
* @returns Object with corrected index and validation status
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const { index, wasInvalid } = TabHistoryPruner.validateLocationIndex(
|
||||
* locations,
|
||||
* currentIndex
|
||||
* );
|
||||
* if (wasInvalid) {
|
||||
* console.warn(`Invalid index ${currentIndex} corrected to ${index}`);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
static validateLocationIndex(
|
||||
locations: TabLocation[],
|
||||
current: number
|
||||
): { index: number; wasInvalid: boolean } {
|
||||
if (locations.length === 0) {
|
||||
return { index: -1, wasInvalid: current !== -1 };
|
||||
}
|
||||
|
||||
if (current < -1 || current >= locations.length) {
|
||||
// Invalid index, correct to last valid position
|
||||
const correctedIndex = Math.max(-1, Math.min(locations.length - 1, current));
|
||||
return { index: correctedIndex, wasInvalid: true };
|
||||
}
|
||||
|
||||
return { index: current, wasInvalid: false };
|
||||
}
|
||||
}
|
||||
10
libs/core/tabs/src/lib/tab-id.generator.ts
Normal file
10
libs/core/tabs/src/lib/tab-id.generator.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { InjectionToken } from '@angular/core';
|
||||
|
||||
const generateTabId = () => {
|
||||
return Date.now();
|
||||
};
|
||||
|
||||
export const CORE_TAB_ID_GENERATOR = new InjectionToken<() => number>(
|
||||
'CORE_TAB_ID_GENERATOR',
|
||||
{ providedIn: 'root', factory: () => generateTabId },
|
||||
);
|
||||
228
libs/core/tabs/src/lib/tab-navigation.service.ts
Normal file
228
libs/core/tabs/src/lib/tab-navigation.service.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
import { DOCUMENT } from '@angular/common';
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { NavigationEnd, Router, UrlTree } from '@angular/router';
|
||||
import { filter } from 'rxjs/operators';
|
||||
import { TabService } from './tab';
|
||||
import { TabLocation } from './schemas';
|
||||
|
||||
/**
|
||||
* Service that automatically syncs browser navigation events to tab location history.
|
||||
*
|
||||
* This service listens to Angular Router NavigationEnd events and updates the active tab's
|
||||
* location history with the current URL, page title, and timestamp. It handles both
|
||||
* tab-specific routes (:tabId) and legacy process routes (:processId).
|
||||
*
|
||||
* Key features:
|
||||
* - Automatic browser navigation synchronization
|
||||
* - History pruning awareness with fallback navigation
|
||||
* - Back/forward navigation with step-by-step movement
|
||||
* - Prevention of infinite loops during navigation
|
||||
* - Legacy route support for backward compatibility
|
||||
*
|
||||
* The service is designed to work seamlessly with the tab history pruning system,
|
||||
* providing fallback mechanisms when navigation history has been pruned.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class TabNavigationService {
|
||||
#router = inject(Router);
|
||||
#tabService = inject(TabService);
|
||||
#document = inject(DOCUMENT);
|
||||
|
||||
constructor() {
|
||||
this.#initializeNavigationSync();
|
||||
}
|
||||
|
||||
#initializeNavigationSync() {
|
||||
this.#router.events
|
||||
.pipe(filter((event) => event instanceof NavigationEnd))
|
||||
.subscribe((event: NavigationEnd) => {
|
||||
this.#syncNavigationToTab(event);
|
||||
});
|
||||
}
|
||||
|
||||
#syncNavigationToTab(event: NavigationEnd) {
|
||||
const activeTabId = this.#getActiveTabId(event.url);
|
||||
if (!activeTabId) {
|
||||
return;
|
||||
}
|
||||
const location = this.#createTabLocation(event.url);
|
||||
|
||||
// Check if this location already exists in history (browser back/forward)
|
||||
const currentTab = this.#tabService.entityMap()[activeTabId];
|
||||
if (
|
||||
currentTab &&
|
||||
this.#isLocationInHistory(currentTab.location.locations, location)
|
||||
) {
|
||||
this.#handleBrowserNavigation(activeTabId, location);
|
||||
} else {
|
||||
this.#tabService.navigateToLocation(activeTabId, location);
|
||||
}
|
||||
}
|
||||
|
||||
#getActiveTabId(url: string): number | null {
|
||||
// Extract tabId from URL pattern /:tabId/...
|
||||
const tabIdMatch = url.match(/^\/(\d+)(?:\/|$)/);
|
||||
if (tabIdMatch) {
|
||||
return parseInt(tabIdMatch[1], 10);
|
||||
}
|
||||
|
||||
// Extract processId from legacy URL pattern /kunde/:processId/...
|
||||
const processIdMatch = url.match(/^\/kunde\/(\d+)(?:\/|$)/);
|
||||
if (processIdMatch) {
|
||||
const processId = parseInt(processIdMatch[1], 10);
|
||||
// In legacy routes, processId maps to tabId
|
||||
return processId;
|
||||
}
|
||||
|
||||
// If no ID in URL, use currently activated tab
|
||||
return this.#tabService.activatedTabId();
|
||||
}
|
||||
|
||||
#createTabLocation(url: string): TabLocation {
|
||||
return {
|
||||
timestamp: Date.now(),
|
||||
title: this.#getPageTitle(),
|
||||
url: url,
|
||||
};
|
||||
}
|
||||
|
||||
#getPageTitle(): string {
|
||||
// Try document title first
|
||||
if (this.#document.title && this.#document.title !== 'ISA') {
|
||||
return this.#document.title;
|
||||
}
|
||||
|
||||
// Fallback to extracting from URL or using generic title
|
||||
const urlSegments = this.#router.url
|
||||
.split('/')
|
||||
.filter((segment) => segment);
|
||||
const lastSegment = urlSegments[urlSegments.length - 1];
|
||||
|
||||
switch (lastSegment) {
|
||||
case 'dashboard':
|
||||
return 'Dashboard';
|
||||
case 'product':
|
||||
return 'Produktkatalog';
|
||||
case 'customer':
|
||||
return 'Kundensuche';
|
||||
case 'cart':
|
||||
return 'Warenkorb';
|
||||
case 'order':
|
||||
return 'Kundenbestellungen';
|
||||
default:
|
||||
return lastSegment ? this.#capitalizeFirst(lastSegment) : 'Seite';
|
||||
}
|
||||
}
|
||||
|
||||
#capitalizeFirst(str: string): string {
|
||||
return str.charAt(0).toUpperCase() + str.slice(1);
|
||||
}
|
||||
|
||||
#isLocationInHistory(
|
||||
locations: TabLocation[],
|
||||
location: TabLocation,
|
||||
): boolean {
|
||||
return locations.some((loc) => loc.url === location.url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles browser navigation events with pruning-aware fallback logic.
|
||||
*
|
||||
* This method attempts step-by-step navigation (back/forward) to reach the target
|
||||
* location. If step-by-step navigation fails due to pruned history or other issues,
|
||||
* it falls back to direct navigation to ensure the user reaches their intended destination.
|
||||
*
|
||||
* Features:
|
||||
* - Step-by-step back/forward navigation
|
||||
* - Loop prevention with maximum attempt limits
|
||||
* - Fallback to direct navigation when step-by-step fails
|
||||
* - Handles pruned history gracefully
|
||||
*
|
||||
* @param tabId - ID of the tab to navigate
|
||||
* @param location - Target location to navigate to
|
||||
* @private
|
||||
*/
|
||||
#handleBrowserNavigation(tabId: number, location: TabLocation) {
|
||||
const currentTab = this.#tabService.entityMap()[tabId];
|
||||
if (!currentTab) return;
|
||||
|
||||
const locationIndex = currentTab.location.locations.findIndex(
|
||||
(loc) => loc.url === location.url,
|
||||
);
|
||||
|
||||
// If location not found in history (possibly pruned), navigate to new location
|
||||
if (locationIndex === -1) {
|
||||
this.#tabService.navigateToLocation(tabId, location);
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine if we need to go back or forward
|
||||
const currentIndex = currentTab.location.current;
|
||||
|
||||
if (locationIndex < currentIndex) {
|
||||
// Navigate back
|
||||
let steps = currentIndex - locationIndex;
|
||||
let attempts = 0;
|
||||
const maxAttempts = Math.abs(steps) + 5; // Prevent infinite loops
|
||||
|
||||
while (steps > 0 && attempts < maxAttempts) {
|
||||
const result = this.#tabService.navigateBack(tabId);
|
||||
if (!result) break;
|
||||
steps--;
|
||||
attempts++;
|
||||
}
|
||||
|
||||
// If we couldn't reach the target, fallback to direct navigation
|
||||
if (steps > 0) {
|
||||
this.#tabService.navigateToLocation(tabId, location);
|
||||
}
|
||||
} else if (locationIndex > currentIndex) {
|
||||
// Navigate forward
|
||||
let steps = locationIndex - currentIndex;
|
||||
let attempts = 0;
|
||||
const maxAttempts = Math.abs(steps) + 5; // Prevent infinite loops
|
||||
|
||||
while (steps > 0 && attempts < maxAttempts) {
|
||||
const result = this.#tabService.navigateForward(tabId);
|
||||
if (!result) break;
|
||||
steps--;
|
||||
attempts++;
|
||||
}
|
||||
|
||||
// If we couldn't reach the target, fallback to direct navigation
|
||||
if (steps > 0) {
|
||||
this.#tabService.navigateToLocation(tabId, location);
|
||||
}
|
||||
}
|
||||
// If locationIndex === currentIndex, we're already at the right position
|
||||
}
|
||||
|
||||
/**
|
||||
* Manually syncs the current route to the active tab's location history.
|
||||
*
|
||||
* This method is useful for:
|
||||
* - Initial page loads when navigation service starts
|
||||
* - When new tabs are created and need current location
|
||||
* - Manual synchronization after route changes outside normal navigation
|
||||
* - Recovery scenarios where tab history needs refreshing
|
||||
*
|
||||
* The method extracts the current URL and creates a location entry with
|
||||
* the appropriate tab ID, current page title, and timestamp.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Sync current route after creating a new tab
|
||||
* const tab = tabService.addTab({ name: 'New Tab' });
|
||||
* tabNavigationService.syncCurrentRoute();
|
||||
* ```
|
||||
*/
|
||||
syncCurrentRoute() {
|
||||
const url = this.#router.url;
|
||||
const activeTabId = this.#getActiveTabId(url);
|
||||
|
||||
if (activeTabId) {
|
||||
const location = this.#createTabLocation(url);
|
||||
this.#tabService.navigateToLocation(activeTabId, location);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,18 @@
|
||||
import { inject } from '@angular/core';
|
||||
import { TabService } from './tab.service';
|
||||
|
||||
/**
|
||||
* Injects the current activated tab as a signal.
|
||||
* @returns A signal that emits the current activated tab or null if no tab is activated.
|
||||
*/
|
||||
export function injectTab() {
|
||||
return inject(TabService).activatedTab;
|
||||
}
|
||||
|
||||
/**
|
||||
* Injects the current tab ID as a signal.
|
||||
* @returns A signal that emits the current tab ID or null if no tab is activated.
|
||||
*/
|
||||
export function injectTabId() {
|
||||
return inject(TabService).activatedTabId;
|
||||
}
|
||||
import { inject } from '@angular/core';
|
||||
import { TabService } from './tab';
|
||||
|
||||
/**
|
||||
* Injects the current activated tab as a signal.
|
||||
* @returns A signal that emits the current activated tab or null if no tab is activated.
|
||||
*/
|
||||
export function injectTab() {
|
||||
return inject(TabService).activatedTab;
|
||||
}
|
||||
|
||||
/**
|
||||
* Injects the current tab ID as a signal.
|
||||
* @returns A signal that emits the current tab ID or null if no tab is activated.
|
||||
*/
|
||||
export function injectTabId() {
|
||||
return inject(TabService).activatedTabId;
|
||||
}
|
||||
|
||||
@@ -1,20 +1,28 @@
|
||||
import { ResolveFn } from '@angular/router';
|
||||
import { TabService } from './tab.service';
|
||||
import { Tab } from './tab';
|
||||
import { inject } from '@angular/core';
|
||||
|
||||
export const tabResolverFn: ResolveFn<Tab> = (route) => {
|
||||
const id = parseInt(route.params['tabId']);
|
||||
const tabService = inject(TabService);
|
||||
let tab = tabService.entityMap()[id];
|
||||
|
||||
if (!tab) {
|
||||
tab = tabService.addTab({
|
||||
name: 'Neuer Vorgang',
|
||||
tags: [],
|
||||
});
|
||||
}
|
||||
|
||||
tabService.activateTab(tab.id);
|
||||
return tab;
|
||||
};
|
||||
import { ResolveFn } from '@angular/router';
|
||||
import { TabService } from './tab';
|
||||
import { Tab } from './schemas';
|
||||
import { inject } from '@angular/core';
|
||||
import { TabNavigationService } from './tab-navigation.service';
|
||||
|
||||
export const tabResolverFn: ResolveFn<Tab> = (route) => {
|
||||
const id = parseInt(route.params['tabId']);
|
||||
const tabService = inject(TabService);
|
||||
const navigationService = inject(TabNavigationService);
|
||||
|
||||
let tab = tabService.entityMap()[id];
|
||||
|
||||
if (!tab) {
|
||||
tab = tabService.addTab({
|
||||
name: 'Neuer Vorgang',
|
||||
});
|
||||
}
|
||||
|
||||
tabService.activateTab(tab.id);
|
||||
|
||||
// Sync current route to tab location history
|
||||
setTimeout(() => {
|
||||
navigationService.syncCurrentRoute();
|
||||
}, 0);
|
||||
|
||||
return tab;
|
||||
};
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const AddTabSchema = z.object({
|
||||
name: z.string().nonempty(),
|
||||
tags: z.array(z.string()),
|
||||
});
|
||||
|
||||
export const PatchTabSchema = z.object({
|
||||
name: z.string().nonempty().optional(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
});
|
||||
@@ -1,86 +0,0 @@
|
||||
import {
|
||||
patchState,
|
||||
signalStore,
|
||||
withComputed,
|
||||
withHooks,
|
||||
withMethods,
|
||||
withState,
|
||||
} from '@ngrx/signals';
|
||||
import {
|
||||
addEntities,
|
||||
addEntity,
|
||||
removeEntity,
|
||||
updateEntity,
|
||||
withEntities,
|
||||
} from '@ngrx/signals/entities';
|
||||
import { Tab } from './tab';
|
||||
import { z } from 'zod';
|
||||
import { AddTabSchema, PatchTabSchema } from './tab.schemas';
|
||||
import { computed, effect } from '@angular/core';
|
||||
|
||||
export const TabService = signalStore(
|
||||
{ providedIn: 'root' },
|
||||
withState<{ activatedTabId: number | null }>({
|
||||
activatedTabId: null,
|
||||
}),
|
||||
withEntities<Tab>(),
|
||||
withComputed((store) => ({
|
||||
nextId: computed(
|
||||
() => Math.max(0, ...store.entities().map((e) => e.id)) + 1,
|
||||
),
|
||||
activatedTab: computed<Tab | null>(() => {
|
||||
const activeTabId = store.activatedTabId();
|
||||
if (activeTabId === null) {
|
||||
return null;
|
||||
}
|
||||
return store.entities().find((e) => e.id === activeTabId) ?? null;
|
||||
}),
|
||||
})),
|
||||
withMethods((store) => ({
|
||||
addTab(add: z.infer<typeof AddTabSchema>) {
|
||||
const parsed = AddTabSchema.parse(add);
|
||||
const tab: Tab = {
|
||||
name: parsed.name,
|
||||
id: store.nextId(),
|
||||
createdAt: Date.now(),
|
||||
tags: parsed.tags,
|
||||
metadata: {},
|
||||
navigation: {
|
||||
current: 0,
|
||||
locations: [],
|
||||
},
|
||||
};
|
||||
patchState(store, addEntity(tab));
|
||||
return tab;
|
||||
},
|
||||
activateTab(id: number) {
|
||||
patchState(store, {
|
||||
...updateEntity({ id, changes: { activatedAt: Date.now() } }),
|
||||
activatedTabId: id,
|
||||
});
|
||||
},
|
||||
patchTab(id: number, changes: z.infer<typeof PatchTabSchema>) {
|
||||
patchState(
|
||||
store,
|
||||
updateEntity({ id, changes: PatchTabSchema.parse(changes) }),
|
||||
);
|
||||
},
|
||||
removeTab(id: number) {
|
||||
patchState(store, removeEntity(id));
|
||||
},
|
||||
})),
|
||||
withHooks((store) => ({
|
||||
onInit() {
|
||||
const entitiesStr = localStorage.getItem('TabEntities');
|
||||
if (entitiesStr) {
|
||||
const entities = JSON.parse(entitiesStr);
|
||||
patchState(store, addEntities(entities));
|
||||
}
|
||||
|
||||
effect(() => {
|
||||
const state = store.entities();
|
||||
localStorage.setItem('TabEntities', JSON.stringify(state));
|
||||
});
|
||||
},
|
||||
})),
|
||||
);
|
||||
@@ -1,25 +1,316 @@
|
||||
export interface Tab {
|
||||
id: number;
|
||||
name: string;
|
||||
navigation: TabNavigation;
|
||||
createdAt: number;
|
||||
activatedAt?: number;
|
||||
metadata: TabMetadata;
|
||||
/** @deprecated */
|
||||
tags: string[];
|
||||
}
|
||||
import {
|
||||
patchState,
|
||||
signalStore,
|
||||
withComputed,
|
||||
withHooks,
|
||||
withMethods,
|
||||
withProps,
|
||||
withState,
|
||||
} from '@ngrx/signals';
|
||||
import {
|
||||
addEntities,
|
||||
addEntity,
|
||||
removeEntity,
|
||||
updateEntity,
|
||||
withEntities,
|
||||
} from '@ngrx/signals/entities';
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
AddTabSchema,
|
||||
PatchTabSchema,
|
||||
TabLocationSchema,
|
||||
Tab,
|
||||
TabLocation,
|
||||
TabLocationHistory,
|
||||
PersistedTabSchema,
|
||||
} from './schemas';
|
||||
import { TAB_CONFIG } from './tab-config';
|
||||
import { TabHistoryPruner } from './tab-history-pruning';
|
||||
import { computed, effect, inject } from '@angular/core';
|
||||
import { withDevtools } from '@angular-architects/ngrx-toolkit';
|
||||
import { CORE_TAB_ID_GENERATOR } from './tab-id.generator';
|
||||
|
||||
export interface TabNavigation {
|
||||
current: number;
|
||||
locations: TabLocation[];
|
||||
}
|
||||
export const TabService = signalStore(
|
||||
{ providedIn: 'root' },
|
||||
withDevtools('TabService'),
|
||||
withState<{ activatedTabId: number | null }>({
|
||||
activatedTabId: null,
|
||||
}),
|
||||
withEntities<Tab>(),
|
||||
withProps((_, idGenerator = inject(CORE_TAB_ID_GENERATOR), config = inject(TAB_CONFIG)) => ({
|
||||
_generateId: idGenerator,
|
||||
_config: config,
|
||||
})),
|
||||
withComputed((store) => ({
|
||||
activatedTab: computed<Tab | null>(() => {
|
||||
const activeTabId = store.activatedTabId();
|
||||
if (activeTabId === null) {
|
||||
return null;
|
||||
}
|
||||
return store.entities().find((e) => e.id === activeTabId) ?? null;
|
||||
}),
|
||||
})),
|
||||
withMethods((store) => ({
|
||||
addTab(add: z.input<typeof AddTabSchema>) {
|
||||
const parsed = AddTabSchema.parse(add);
|
||||
const tab: Tab = {
|
||||
id: parsed.id ?? store._generateId(),
|
||||
name: parsed.name,
|
||||
createdAt: Date.now(),
|
||||
activatedAt: parsed.activatedAt,
|
||||
tags: parsed.tags,
|
||||
metadata: parsed.metadata,
|
||||
location: { current: -1, locations: [] },
|
||||
};
|
||||
patchState(store, addEntity(tab));
|
||||
return tab;
|
||||
},
|
||||
activateTab(id: number) {
|
||||
const changes: Partial<Tab> = { activatedAt: Date.now() };
|
||||
patchState(store, updateEntity({ id, changes }));
|
||||
patchState(store, { activatedTabId: id });
|
||||
},
|
||||
patchTab(id: number, changes: z.infer<typeof PatchTabSchema>) {
|
||||
const currentTab = store.entityMap()[id];
|
||||
|
||||
export interface TabLocation {
|
||||
timestamp: number;
|
||||
title: string;
|
||||
url: string;
|
||||
}
|
||||
const patchedMetadata = changes.metadata
|
||||
? { ...currentTab.metadata, ...changes.metadata }
|
||||
: currentTab.metadata;
|
||||
|
||||
export interface TabMetadata {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
const entityChanges: Partial<Tab> = {
|
||||
...changes,
|
||||
metadata: patchedMetadata,
|
||||
};
|
||||
|
||||
patchState(store, updateEntity({ id, changes: entityChanges }));
|
||||
},
|
||||
patchTabMetadata(id: number, metadata: Record<string, unknown>) {
|
||||
const currentTab = store.entityMap()[id];
|
||||
const changes: Partial<Tab> = {
|
||||
metadata: { ...currentTab.metadata, ...metadata },
|
||||
};
|
||||
patchState(store, updateEntity({ id, changes }));
|
||||
},
|
||||
removeTab(id: number) {
|
||||
patchState(store, removeEntity(id));
|
||||
},
|
||||
navigateToLocation(
|
||||
id: number,
|
||||
location: z.input<typeof TabLocationSchema>,
|
||||
) {
|
||||
const parsed: TabLocation = TabLocationSchema.parse(location);
|
||||
const currentTab = store.entityMap()[id];
|
||||
if (!currentTab) return null;
|
||||
|
||||
const currentLocation = currentTab.location;
|
||||
|
||||
// First, limit forward history if configured
|
||||
const maxForwardHistory =
|
||||
(currentTab.metadata as any)?.maxForwardHistory ?? store._config.maxForwardHistory;
|
||||
|
||||
const { locations: limitedLocations } = TabHistoryPruner.pruneForwardHistory(
|
||||
currentLocation.locations,
|
||||
currentLocation.current,
|
||||
maxForwardHistory
|
||||
);
|
||||
|
||||
// Add new location
|
||||
const newLocations: TabLocation[] = [
|
||||
...limitedLocations.slice(0, currentLocation.current + 1),
|
||||
parsed,
|
||||
];
|
||||
|
||||
let newLocationHistory: TabLocationHistory = {
|
||||
current: newLocations.length - 1,
|
||||
locations: newLocations,
|
||||
};
|
||||
|
||||
// Apply size-based pruning if needed
|
||||
const pruningResult = TabHistoryPruner.pruneHistory(
|
||||
newLocationHistory,
|
||||
store._config,
|
||||
currentTab.metadata as any
|
||||
);
|
||||
|
||||
if (pruningResult.entriesRemoved > 0) {
|
||||
if (store._config.logPruning) {
|
||||
console.log(`Tab ${id}: Pruned ${pruningResult.entriesRemoved} entries using ${pruningResult.strategy} strategy`);
|
||||
}
|
||||
|
||||
newLocationHistory = {
|
||||
current: pruningResult.newCurrent,
|
||||
locations: pruningResult.locations,
|
||||
};
|
||||
}
|
||||
|
||||
// Validate index integrity
|
||||
const { index: validatedCurrent, wasInvalid } = TabHistoryPruner.validateLocationIndex(
|
||||
newLocationHistory.locations,
|
||||
newLocationHistory.current
|
||||
);
|
||||
|
||||
if (wasInvalid && store._config.enableIndexValidation) {
|
||||
console.warn(`Tab ${id}: Invalid location index corrected from ${newLocationHistory.current} to ${validatedCurrent}`);
|
||||
newLocationHistory.current = validatedCurrent;
|
||||
}
|
||||
|
||||
const changes: Partial<Tab> = {
|
||||
location: newLocationHistory,
|
||||
};
|
||||
patchState(store, updateEntity({ id, changes }));
|
||||
|
||||
return parsed;
|
||||
},
|
||||
navigateBack(id: number) {
|
||||
const currentTab = store.entityMap()[id];
|
||||
if (!currentTab) return null;
|
||||
|
||||
const currentLocation = currentTab.location;
|
||||
|
||||
// Validate current index before navigation
|
||||
const { index: validatedCurrent } = TabHistoryPruner.validateLocationIndex(
|
||||
currentLocation.locations,
|
||||
currentLocation.current
|
||||
);
|
||||
|
||||
if (validatedCurrent <= 0) return null;
|
||||
|
||||
const newCurrent = validatedCurrent - 1;
|
||||
const previousLocation = currentLocation.locations[newCurrent];
|
||||
|
||||
const changes: Partial<Tab> = {
|
||||
location: {
|
||||
...currentLocation,
|
||||
current: newCurrent,
|
||||
},
|
||||
};
|
||||
patchState(store, updateEntity({ id, changes }));
|
||||
|
||||
return previousLocation;
|
||||
},
|
||||
navigateForward(id: number) {
|
||||
const currentTab = store.entityMap()[id];
|
||||
if (!currentTab) return null;
|
||||
|
||||
const currentLocation = currentTab.location;
|
||||
|
||||
// Validate current index before navigation
|
||||
const { index: validatedCurrent } = TabHistoryPruner.validateLocationIndex(
|
||||
currentLocation.locations,
|
||||
currentLocation.current
|
||||
);
|
||||
|
||||
if (validatedCurrent >= currentLocation.locations.length - 1)
|
||||
return null;
|
||||
|
||||
const newCurrent = validatedCurrent + 1;
|
||||
const nextLocation = currentLocation.locations[newCurrent];
|
||||
|
||||
const changes: Partial<Tab> = {
|
||||
location: {
|
||||
...currentLocation,
|
||||
current: newCurrent,
|
||||
},
|
||||
};
|
||||
patchState(store, updateEntity({ id, changes }));
|
||||
|
||||
return nextLocation;
|
||||
},
|
||||
clearLocationHistory(id: number) {
|
||||
const currentTab = store.entityMap()[id];
|
||||
if (!currentTab) return;
|
||||
|
||||
const changes: Partial<Tab> = {
|
||||
location: {
|
||||
current: -1,
|
||||
locations: [],
|
||||
},
|
||||
};
|
||||
patchState(store, updateEntity({ id, changes }));
|
||||
},
|
||||
getCurrentLocation(id: number) {
|
||||
const currentTab = store.entityMap()[id];
|
||||
if (!currentTab) return null;
|
||||
|
||||
const currentLocation = currentTab.location;
|
||||
|
||||
// Validate current index
|
||||
const { index: validatedCurrent, wasInvalid } = TabHistoryPruner.validateLocationIndex(
|
||||
currentLocation.locations,
|
||||
currentLocation.current
|
||||
);
|
||||
|
||||
if (wasInvalid && store._config.enableIndexValidation) {
|
||||
console.warn(`Tab ${id}: Invalid location index corrected in getCurrentLocation from ${currentLocation.current} to ${validatedCurrent}`);
|
||||
|
||||
// Correct the invalid index in store
|
||||
const changes: Partial<Tab> = {
|
||||
location: {
|
||||
...currentLocation,
|
||||
current: validatedCurrent,
|
||||
},
|
||||
};
|
||||
patchState(store, updateEntity({ id, changes }));
|
||||
}
|
||||
|
||||
if (validatedCurrent < 0 || validatedCurrent >= currentLocation.locations.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return currentLocation.locations[validatedCurrent];
|
||||
},
|
||||
updateCurrentLocation(id: number, updates: Partial<TabLocation>) {
|
||||
const currentTab = store.entityMap()[id];
|
||||
if (!currentTab) return null;
|
||||
|
||||
const currentLocation = currentTab.location;
|
||||
if (
|
||||
currentLocation.current < 0 ||
|
||||
currentLocation.current >= currentLocation.locations.length
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const updatedLocation = {
|
||||
...currentLocation.locations[currentLocation.current],
|
||||
...updates,
|
||||
};
|
||||
|
||||
const newLocations = [...currentLocation.locations];
|
||||
newLocations[currentLocation.current] = updatedLocation;
|
||||
|
||||
const changes: Partial<Tab> = {
|
||||
location: {
|
||||
...currentLocation,
|
||||
locations: newLocations,
|
||||
},
|
||||
};
|
||||
patchState(store, updateEntity({ id, changes }));
|
||||
|
||||
return updatedLocation;
|
||||
},
|
||||
})),
|
||||
withHooks((store) => ({
|
||||
onInit() {
|
||||
const entitiesStr = sessionStorage.getItem('TabEntities');
|
||||
if (entitiesStr) {
|
||||
const entities = JSON.parse(entitiesStr);
|
||||
const validatedEntities = z.array(PersistedTabSchema).parse(entities);
|
||||
const tabEntities: Tab[] = validatedEntities.map(entity => ({
|
||||
id: entity.id,
|
||||
name: entity.name,
|
||||
createdAt: entity.createdAt,
|
||||
activatedAt: entity.activatedAt,
|
||||
metadata: entity.metadata,
|
||||
location: entity.location,
|
||||
tags: entity.tags,
|
||||
}));
|
||||
patchState(store, addEntities(tabEntities));
|
||||
}
|
||||
effect(() => {
|
||||
const state = store.entities();
|
||||
sessionStorage.setItem('TabEntities', JSON.stringify(state));
|
||||
});
|
||||
},
|
||||
})),
|
||||
);
|
||||
|
||||
@@ -1 +1 @@
|
||||
export * from './lib/crm-data-access/crm-data-access.component';
|
||||
// nur facades, models + helpers dürfen exportiert werden
|
||||
|
||||
1
libs/crm/data-access/src/lib/constants.ts
Normal file
1
libs/crm/data-access/src/lib/constants.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const SELECTED_CUSTOMER_ID = 'crm-data-access.selectedCustomerId';
|
||||
@@ -1 +0,0 @@
|
||||
<p>CrmDataAccess works!</p>
|
||||
@@ -1,21 +0,0 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { CrmDataAccessComponent } from './crm-data-access.component';
|
||||
|
||||
describe('CrmDataAccessComponent', () => {
|
||||
let component: CrmDataAccessComponent;
|
||||
let fixture: ComponentFixture<CrmDataAccessComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [CrmDataAccessComponent],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(CrmDataAccessComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -1,10 +0,0 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
@Component({
|
||||
selector: 'crm-crm-data-access',
|
||||
imports: [CommonModule],
|
||||
templateUrl: './crm-data-access.component.html',
|
||||
styleUrl: './crm-data-access.component.css',
|
||||
})
|
||||
export class CrmDataAccessComponent {}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { TabService } from '@isa/core/tabs';
|
||||
import { SELECTED_CUSTOMER_ID } from '../constants';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class CrmTabMetadataService {
|
||||
#tabService = inject(TabService);
|
||||
|
||||
// TODO: Zod validation schemas schreiben
|
||||
// TODO: Facade schreiben für reine funktionsaufrufe -> Eingebunden in ISA-APP
|
||||
|
||||
// TODO: Falls notwendig reactive die daten rausgeben
|
||||
selectedCustomerId(tabId: number): number | undefined {
|
||||
return this.metadata(tabId)?.[SELECTED_CUSTOMER_ID] as number;
|
||||
}
|
||||
|
||||
setSelectedCustomerId(tabId: number, customerId: number | undefined) {
|
||||
this.#tabService.patchTabMetadata(tabId, {
|
||||
tabId,
|
||||
key: SELECTED_CUSTOMER_ID,
|
||||
value: customerId,
|
||||
});
|
||||
}
|
||||
|
||||
metadata(tabId: number) {
|
||||
return this.#tabService.entities().find((tab) => tab.id === tabId)
|
||||
?.metadata;
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,17 @@
|
||||
import { ViewportScroller } from '@angular/common';
|
||||
import { inject } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { SessionStorageProvider } from '@isa/core/storage';
|
||||
|
||||
/**
|
||||
* Stores the current scroll position in session storage.
|
||||
* Uses the current router URL as the key.
|
||||
*/
|
||||
export async function storeScrollPosition() {
|
||||
const router = inject(Router);
|
||||
const viewportScroller = inject(ViewportScroller);
|
||||
const sessionStorage = inject(SessionStorageProvider);
|
||||
|
||||
const url = router.url;
|
||||
sessionStorage.set(url, viewportScroller.getScrollPosition());
|
||||
}
|
||||
import { ViewportScroller } from '@angular/common';
|
||||
import { inject } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { injectStorage, SessionStorageProvider } from '@isa/core/storage';
|
||||
|
||||
/**
|
||||
* Stores the current scroll position in session storage.
|
||||
* Uses the current router URL as the key.
|
||||
*/
|
||||
export async function storeScrollPosition() {
|
||||
const router = inject(Router);
|
||||
const viewportScroller = inject(ViewportScroller);
|
||||
const sessionStorage = injectStorage(SessionStorageProvider);
|
||||
|
||||
const url = router.url;
|
||||
sessionStorage.set(url, viewportScroller.getScrollPosition());
|
||||
}
|
||||
|
||||
66007
package-lock.json
generated
66007
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
275
package.json
275
package.json
@@ -1,137 +1,138 @@
|
||||
{
|
||||
"name": "hima",
|
||||
"version": "0.0.0",
|
||||
"scripts": {
|
||||
"ng": "ng",
|
||||
"start": "nx serve isa-app --ssl",
|
||||
"pretest": "npx trash-cli testresults",
|
||||
"test": "npx nx run-many --tuiAutoExit true -t test --exclude isa-app",
|
||||
"ci": "npx nx run-many -t test --exclude isa-app -c ci --tuiAutoExit true",
|
||||
"build": "nx build isa-app --configuration=development",
|
||||
"build-prod": "nx build isa-app --configuration=production",
|
||||
"lint": "nx lint",
|
||||
"e2e": "nx e2e",
|
||||
"generate:swagger": "nx run-many -t generate -p tag:generated,swagger",
|
||||
"fix:files:swagger": "node ./tools/fix-files.js generated/swagger",
|
||||
"prettier": "prettier --write .",
|
||||
"pretty-quick": "pretty-quick --staged",
|
||||
"prepare": "husky",
|
||||
"storybook": "npx nx run isa-app:storybook"
|
||||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@angular/animations": "20.1.2",
|
||||
"@angular/cdk": "20.1.2",
|
||||
"@angular/common": "20.1.2",
|
||||
"@angular/compiler": "20.1.2",
|
||||
"@angular/core": "20.1.2",
|
||||
"@angular/forms": "20.1.2",
|
||||
"@angular/localize": "20.1.2",
|
||||
"@angular/platform-browser": "20.1.2",
|
||||
"@angular/platform-browser-dynamic": "20.1.2",
|
||||
"@angular/router": "20.1.2",
|
||||
"@angular/service-worker": "20.1.2",
|
||||
"@microsoft/signalr": "^8.0.7",
|
||||
"@ng-icons/core": "32.0.0",
|
||||
"@ng-icons/material-icons": "32.0.0",
|
||||
"@ngrx/component-store": "^20.0.0",
|
||||
"@ngrx/effects": "^20.0.0",
|
||||
"@ngrx/entity": "^20.0.0",
|
||||
"@ngrx/operators": "^20.0.0",
|
||||
"@ngrx/signals": "^20.0.0",
|
||||
"@ngrx/store": "^20.0.0",
|
||||
"@ngrx/store-devtools": "^20.0.0",
|
||||
"angular-oauth2-oidc": "20.0.0",
|
||||
"angular-oauth2-oidc-jwks": "20.0.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"lodash": "^4.17.21",
|
||||
"moment": "^2.30.1",
|
||||
"ng2-pdf-viewer": "^10.4.0",
|
||||
"ngx-matomo-client": "^8.0.0",
|
||||
"parse-duration": "^2.1.3",
|
||||
"rxjs": "~7.8.2",
|
||||
"scandit-web-datacapture-barcode": "^6.28.1",
|
||||
"scandit-web-datacapture-core": "^6.28.1",
|
||||
"tslib": "^2.3.0",
|
||||
"uuid": "^8.3.2",
|
||||
"zod": "^3.24.2",
|
||||
"zone.js": "~0.15.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@analogjs/vite-plugin-angular": "1.19.1",
|
||||
"@analogjs/vitest-angular": "1.19.1",
|
||||
"@angular-devkit/build-angular": "20.1.1",
|
||||
"@angular-devkit/core": "20.1.1",
|
||||
"@angular-devkit/schematics": "20.1.1",
|
||||
"@angular/build": "20.1.1",
|
||||
"@angular/cli": "~20.1.0",
|
||||
"@angular/compiler-cli": "20.1.2",
|
||||
"@angular/language-service": "20.1.2",
|
||||
"@angular/pwa": "20.1.1",
|
||||
"@eslint/js": "^9.8.0",
|
||||
"@ngneat/spectator": "19.6.2",
|
||||
"@nx/angular": "21.3.2",
|
||||
"@nx/eslint": "21.3.2",
|
||||
"@nx/eslint-plugin": "21.3.2",
|
||||
"@nx/jest": "21.3.2",
|
||||
"@nx/js": "21.3.2",
|
||||
"@nx/storybook": "21.3.2",
|
||||
"@nx/vite": "21.3.2",
|
||||
"@nx/web": "21.3.2",
|
||||
"@nx/workspace": "21.3.2",
|
||||
"@schematics/angular": "20.1.1",
|
||||
"@storybook/addon-docs": "^9.0.11",
|
||||
"@storybook/angular": "^9.0.5",
|
||||
"@swc-node/register": "1.10.10",
|
||||
"@swc/core": "1.12.1",
|
||||
"@swc/helpers": "0.5.17",
|
||||
"@types/jest": "30.0.0",
|
||||
"@types/lodash": "^4.17.16",
|
||||
"@types/node": "18.16.9",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@typescript-eslint/utils": "^8.33.1",
|
||||
"@vitest/coverage-v8": "^3.1.1",
|
||||
"@vitest/ui": "^3.1.1",
|
||||
"angular-eslint": "20.1.1",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "^9.28.0",
|
||||
"eslint-config-prettier": "^10.1.5",
|
||||
"husky": "^9.1.7",
|
||||
"jest": "^29.7.0",
|
||||
"jest-environment-jsdom": "^29.7.0",
|
||||
"jest-environment-node": "^29.7.0",
|
||||
"jest-junit": "^16.0.0",
|
||||
"jest-preset-angular": "14.6.0",
|
||||
"jiti": "2.4.2",
|
||||
"jsdom": "~22.1.0",
|
||||
"jsonc-eslint-parser": "^2.1.0",
|
||||
"ng-mocks": "14.13.5",
|
||||
"ng-packagr": "20.1.0",
|
||||
"ng-swagger-gen": "^2.3.1",
|
||||
"nx": "21.3.2",
|
||||
"postcss": "^8.5.3",
|
||||
"postcss-url": "~10.1.3",
|
||||
"prettier": "^3.5.2",
|
||||
"pretty-quick": "~4.0.0",
|
||||
"storybook": "^9.0.5",
|
||||
"tailwindcss": "^3.4.14",
|
||||
"ts-jest": "^29.1.0",
|
||||
"ts-node": "10.9.1",
|
||||
"typescript": "5.8.3",
|
||||
"typescript-eslint": "^8.33.1",
|
||||
"vite": "6.3.5",
|
||||
"vitest": "^3.1.1"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/linux-x64": "^0.25.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.00.0",
|
||||
"npm": ">=10.0.0"
|
||||
},
|
||||
"overrides": {
|
||||
"jest-environment-jsdom": {
|
||||
"jsdom": "26.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
{
|
||||
"name": "hima",
|
||||
"version": "0.0.0",
|
||||
"scripts": {
|
||||
"ng": "ng",
|
||||
"start": "nx serve isa-app --ssl",
|
||||
"pretest": "npx trash-cli testresults",
|
||||
"test": "npx nx run-many --tuiAutoExit true -t test --exclude isa-app",
|
||||
"ci": "npx nx run-many -t test --exclude isa-app -c ci --tuiAutoExit true",
|
||||
"build": "nx build isa-app --configuration=development",
|
||||
"build-prod": "nx build isa-app --configuration=production",
|
||||
"lint": "nx lint",
|
||||
"e2e": "nx e2e",
|
||||
"generate:swagger": "nx run-many -t generate -p tag:generated,swagger",
|
||||
"fix:files:swagger": "node ./tools/fix-files.js generated/swagger",
|
||||
"prettier": "prettier --write .",
|
||||
"pretty-quick": "pretty-quick --staged",
|
||||
"prepare": "husky",
|
||||
"storybook": "npx nx run isa-app:storybook"
|
||||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@angular-architects/ngrx-toolkit": "^20.4.0",
|
||||
"@angular/animations": "20.1.2",
|
||||
"@angular/cdk": "20.1.2",
|
||||
"@angular/common": "20.1.2",
|
||||
"@angular/compiler": "20.1.2",
|
||||
"@angular/core": "20.1.2",
|
||||
"@angular/forms": "20.1.2",
|
||||
"@angular/localize": "20.1.2",
|
||||
"@angular/platform-browser": "20.1.2",
|
||||
"@angular/platform-browser-dynamic": "20.1.2",
|
||||
"@angular/router": "20.1.2",
|
||||
"@angular/service-worker": "20.1.2",
|
||||
"@microsoft/signalr": "^8.0.7",
|
||||
"@ng-icons/core": "32.0.0",
|
||||
"@ng-icons/material-icons": "32.0.0",
|
||||
"@ngrx/component-store": "^20.0.0",
|
||||
"@ngrx/effects": "^20.0.0",
|
||||
"@ngrx/entity": "^20.0.0",
|
||||
"@ngrx/operators": "^20.0.0",
|
||||
"@ngrx/signals": "^20.0.0",
|
||||
"@ngrx/store": "^20.0.0",
|
||||
"@ngrx/store-devtools": "^20.0.0",
|
||||
"angular-oauth2-oidc": "20.0.0",
|
||||
"angular-oauth2-oidc-jwks": "20.0.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"lodash": "^4.17.21",
|
||||
"moment": "^2.30.1",
|
||||
"ng2-pdf-viewer": "^10.4.0",
|
||||
"ngx-matomo-client": "^8.0.0",
|
||||
"parse-duration": "^2.1.3",
|
||||
"rxjs": "~7.8.2",
|
||||
"scandit-web-datacapture-barcode": "^6.28.1",
|
||||
"scandit-web-datacapture-core": "^6.28.1",
|
||||
"tslib": "^2.3.0",
|
||||
"uuid": "^8.3.2",
|
||||
"zod": "^3.24.2",
|
||||
"zone.js": "~0.15.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@analogjs/vite-plugin-angular": "1.19.1",
|
||||
"@analogjs/vitest-angular": "1.19.1",
|
||||
"@angular-devkit/build-angular": "20.1.1",
|
||||
"@angular-devkit/core": "20.1.1",
|
||||
"@angular-devkit/schematics": "20.1.1",
|
||||
"@angular/build": "20.1.1",
|
||||
"@angular/cli": "~20.1.0",
|
||||
"@angular/compiler-cli": "20.1.2",
|
||||
"@angular/language-service": "20.1.2",
|
||||
"@angular/pwa": "20.1.1",
|
||||
"@eslint/js": "^9.8.0",
|
||||
"@ngneat/spectator": "19.6.2",
|
||||
"@nx/angular": "21.3.2",
|
||||
"@nx/eslint": "21.3.2",
|
||||
"@nx/eslint-plugin": "21.3.2",
|
||||
"@nx/jest": "21.3.2",
|
||||
"@nx/js": "21.3.2",
|
||||
"@nx/storybook": "21.3.2",
|
||||
"@nx/vite": "21.3.2",
|
||||
"@nx/web": "21.3.2",
|
||||
"@nx/workspace": "21.3.2",
|
||||
"@schematics/angular": "20.1.1",
|
||||
"@storybook/addon-docs": "^9.0.11",
|
||||
"@storybook/angular": "^9.0.5",
|
||||
"@swc-node/register": "1.10.10",
|
||||
"@swc/core": "1.12.1",
|
||||
"@swc/helpers": "0.5.17",
|
||||
"@types/jest": "30.0.0",
|
||||
"@types/lodash": "^4.17.16",
|
||||
"@types/node": "18.16.9",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@typescript-eslint/utils": "^8.33.1",
|
||||
"@vitest/coverage-v8": "^3.1.1",
|
||||
"@vitest/ui": "^3.1.1",
|
||||
"angular-eslint": "20.1.1",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "^9.28.0",
|
||||
"eslint-config-prettier": "^10.1.5",
|
||||
"husky": "^9.1.7",
|
||||
"jest": "^29.7.0",
|
||||
"jest-environment-jsdom": "^29.7.0",
|
||||
"jest-environment-node": "^29.7.0",
|
||||
"jest-junit": "^16.0.0",
|
||||
"jest-preset-angular": "14.6.0",
|
||||
"jiti": "2.4.2",
|
||||
"jsdom": "~22.1.0",
|
||||
"jsonc-eslint-parser": "^2.1.0",
|
||||
"ng-mocks": "14.13.5",
|
||||
"ng-packagr": "20.1.0",
|
||||
"ng-swagger-gen": "^2.3.1",
|
||||
"nx": "21.3.2",
|
||||
"postcss": "^8.5.3",
|
||||
"postcss-url": "~10.1.3",
|
||||
"prettier": "^3.5.2",
|
||||
"pretty-quick": "~4.0.0",
|
||||
"storybook": "^9.0.5",
|
||||
"tailwindcss": "^3.4.14",
|
||||
"ts-jest": "^29.1.0",
|
||||
"ts-node": "10.9.1",
|
||||
"typescript": "5.8.3",
|
||||
"typescript-eslint": "^8.33.1",
|
||||
"vite": "6.3.5",
|
||||
"vitest": "^3.1.1"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/linux-x64": "^0.25.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.00.0",
|
||||
"npm": ">=10.0.0"
|
||||
},
|
||||
"overrides": {
|
||||
"jest-environment-jsdom": {
|
||||
"jsdom": "26.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user