Compare commits

...

25 Commits

Author SHA1 Message Date
Nino
a9a80a192e feature(libs-remission): Improvements and Refactoring of Remission List Component
Ref: #5340
2025-12-16 17:26:28 +01:00
Nino
3d82e7f0af feature(libs-feature-data-access): Using catchResponseArgsErrorPipe across all data-acesss services now and removed old patterns. Adjusted some tests and added Logging for every case wrapped in try catch patterns
Ref: #5340
2025-12-15 17:48:43 +01:00
Nino
3101e8e8e0 feature(oms-data-access): Removed abortSignals for POST and PUT requests
Ref: #5340
2025-12-11 17:57:55 +01:00
Nino
a3415e450d feature(checkout-data-access, remission-data-access): Removed AbortSignals for POST and PUT requests
Ref: #5340
2025-12-11 17:46:46 +01:00
Nino Righi
de3edaa0f9 Merged PR 2077: fix(checkout-data-access, checkout-reward-shopping-cart, checkout-reward-sele...
fix(checkout-data-access, checkout-reward-shopping-cart, checkout-reward-selection-dialog): Show Low Stock message inside Dialog, Adjusted Item Identifyer so that mergedItems inside reward-selection-dialog service works properly, Adjusted Error Message Logic and Quantity Select Logic based on purchasing Options for Abholung

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

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

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

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

Ref: #5516
2025-12-08 16:51:49 +00:00
Nino Righi
39e56a275e Merged PR 2072: fix(reward-order-confirmation): Only display one orderDate if other ones are...
fix(reward-order-confirmation): Only display one orderDate if other ones are equal

Ref: #5517
2025-12-08 15:20:19 +00:00
Nino Righi
6c41214d69 Merged PR 2071: fix(crm-customer-bon-redemption): Added Date Pipe to Bon
fix(crm-customer-bon-redemption): Added Date Pipe to Bon

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

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

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

#5373
2025-12-05 10:10:18 +00:00
Nino
7e7721b222 Merge branch 'release/4.5' into develop 2025-12-03 16:00:41 +01:00
Nino
14be1365bd fix(crm-loyalty-cards): Show always Points of first Card 2025-12-03 15:59:08 +01:00
Nino Righi
d5324675ef Merged PR 2067: fix(ui-layout): Ipad Dropdown Scrolling Fix
fix(ui-layout): Ipad Dropdown Scrolling Fix
2025-12-03 14:16:21 +00:00
Nino
f10338a48b Merge branch 'release/4.5' into develop 2025-12-02 17:33:50 +01:00
Nino
aa57d27924 fix(oms-return-details): Label Unknown Fix
Ref: #5513
2025-12-02 17:20:56 +01:00
Nino
6cb9aea7d1 Merge branch 'release/4.5' into develop 2025-12-02 17:18:11 +01:00
Lorenz Hilpert
fdfb54a3a0 Merged PR 2065: ♻️ refactor(core-navigation): remove library and use TabService directly
♻️ refactor(core-navigation): remove library and use TabService directly

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

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

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

Refs #5502
2025-12-02 15:41:18 +00:00
Nino
5f94549539 fix(oms-return-details): Dropdown Label and Select Bullet Styling Adjustments
Ref: #5513
2025-12-02 16:32:57 +01:00
Nino Righi
aee63711e4 Merged PR 2066: fix(ui-layout, input-controls-dropdown, oms-return-details): prevent stale sc...
fix(ui-layout, input-controls-dropdown, oms-return-details): prevent stale scroll events from closing dropdown on open

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

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

Refs: #5513
2025-12-02 14:02:32 +00:00
Nino Righi
ee9f030a99 Merged PR 2062: fix(isa-app-customer): Clear Navigation State Context if Customer Area gets d...
fix(isa-app-customer): Clear Navigation State Context if Customer Area gets destroyed

Ref: #5512
2025-12-01 11:25:11 +00:00
Nino Righi
7884e1af32 Merged PR 2059: feature(ui-modal): add QR code display for URLs in dialog modals
feature(ui-modal): add QR code display for URLs in dialog modals

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

Ref: #5511
2025-11-27 16:41:51 +00:00
117 changed files with 2745 additions and 4459 deletions

3
.gitignore vendored
View File

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

View File

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

View File

@@ -1,5 +1,11 @@
import { Component, ChangeDetectionStrategy } from '@angular/core';
import {
Component,
ChangeDetectionStrategy,
OnDestroy,
inject,
} from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { TabService } from '@isa/core/tabs';
import { map } from 'rxjs/operators';
@Component({
@@ -9,8 +15,17 @@ import { map } from 'rxjs/operators';
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: false,
})
export class CustomerComponent {
export class CustomerComponent implements OnDestroy {
private _tabService = inject(TabService);
processId$ = this._activatedRoute.data.pipe(map((data) => data.processId));
constructor(private _activatedRoute: ActivatedRoute) {}
ngOnDestroy() {
const tab = this._tabService.activatedTab();
// #5512 Always clear preserved select-customer context if navigating out of customer area
this._tabService.patchTabMetadata(tab.id, {
'select-customer': null,
});
}
}

View File

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

View File

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

View File

@@ -1,16 +1,43 @@
@if (modalRef.data.subtitle; as subtitle) {
<h2 class="subtitle">{{ subtitle }}</h2>
}
@if (modalRef.data.content; as content) {
<p class="content">
{{ content }}
</p>
<!-- QR Code Display Mode -->
@if (shouldShowQrCode(); as showQr) {
@if (parsedContent(); as parsed) {
@if (parsed.textBefore) {
<p class="content">{{ parsed.textBefore }}</p>
}
<div class="qr-code-container">
<qrcode
[qrdata]="parsed.url!"
[width]="200"
[errorCorrectionLevel]="'M'"
[margin]="2"
></qrcode>
</div>
@if (parsed.textAfter) {
<p class="content">{{ parsed.textAfter }}</p>
}
}
} @else {
<!-- Default Text Display Mode -->
@if (modalRef.data.content; as content) {
<p class="content">
{{ content }}
</p>
}
}
@if (modalRef.data.actions; as actions) {
<div class="actions">
@for (action of actions; track action) {
<button [class.selected]="action.selected" (click)="handleCommand(action.command)">
<button
[class.selected]="action.selected"
(click)="handleCommand(action.command)"
>
{{ action.label }}
</button>
}

View File

@@ -15,11 +15,15 @@
@apply text-lg text-center whitespace-pre-wrap mb-8 px-16;
}
.qr-code-container {
@apply flex flex-col items-center justify-center mb-8;
}
.actions {
@apply text-center mb-8;
button {
@apply border-2 border-solid border-brand bg-white text-brand rounded-full py-3 px-6 font-bold text-lg outline-none self-end whitespace-nowrap ml-4;
@apply border-2 border-solid border-brand bg-white text-brand rounded-full py-3 px-6 font-bold text-lg outline-none self-end whitespace-nowrap;
&.selected {
@apply bg-brand text-white;

View File

@@ -1,7 +1,8 @@
import { Component, OnInit } from '@angular/core';
import { Component, computed, OnInit } from '@angular/core';
import { CommandService } from '@core/command';
import { UiModalRef } from '../defs/modal-ref';
import { DialogModel } from './dialog.model';
import { parseDialogContentForUrl } from './dialog.helper';
@Component({
selector: 'ui-dialog-modal',
@@ -10,6 +11,26 @@ import { DialogModel } from './dialog.model';
standalone: false,
})
export class UiDialogModalComponent implements OnInit {
/**
* Parsed content with URL extracted for QR code display.
* Only relevant when showUrlAsQrCode is true.
*/
readonly parsedContent = computed(() => {
const data = this.modalRef.data;
if (!data.showUrlAsQrCode) {
return null;
}
return parseDialogContentForUrl(data.content);
});
/**
* Whether to show the QR code instead of the URL text.
*/
readonly shouldShowQrCode = computed(() => {
const parsed = this.parsedContent();
return parsed !== null && parsed.url !== null;
});
constructor(
public modalRef: UiModalRef<any, DialogModel<any>>,
private _command: CommandService,

View File

@@ -0,0 +1,48 @@
import { ParsedDialogContent } from './dialog.model';
/**
* Regular expression to match URLs in text.
* Matches http:// and https:// URLs.
*/
const URL_REGEX = /https?:\/\/[^\s]+/i;
/**
* Parses the dialog content and extracts the first URL.
* Splits the content into text before the URL, the URL itself, and text after.
*
* @param content - The dialog content string to parse
* @returns ParsedDialogContent with the split content
*/
export const parseDialogContentForUrl = (
content: string | undefined,
): ParsedDialogContent => {
if (!content) {
return { textBefore: '', url: null, textAfter: '' };
}
const match = content.match(URL_REGEX);
if (!match || match.index === undefined) {
return { textBefore: content, url: null, textAfter: '' };
}
const url = match[0];
const urlIndex = match.index;
const textBefore = content.substring(0, urlIndex).trim();
const textAfter = content.substring(urlIndex + url.length).trim();
return { textBefore, url, textAfter };
};
/**
* Checks if the given content contains a URL.
*
* @param content - The content string to check
* @returns true if a URL is found, false otherwise
*/
export const contentHasUrl = (content: string | undefined): boolean => {
if (!content) {
return false;
}
return URL_REGEX.test(content);
};

View File

@@ -0,0 +1,152 @@
import { contentHasUrl, parseDialogContentForUrl } from './dialog.helper';
import { ParsedDialogContent } from './dialog.model';
describe('parseDialogContentForUrl', () => {
it('should return empty result for undefined content', () => {
const result = parseDialogContentForUrl(undefined);
expect(result).toEqual<ParsedDialogContent>({
textBefore: '',
url: null,
textAfter: '',
});
});
it('should return empty result for empty string', () => {
const result = parseDialogContentForUrl('');
expect(result).toEqual<ParsedDialogContent>({
textBefore: '',
url: null,
textAfter: '',
});
});
it('should return content as textBefore when no URL is found', () => {
const content = 'This is some text without a URL';
const result = parseDialogContentForUrl(content);
expect(result).toEqual<ParsedDialogContent>({
textBefore: content,
url: null,
textAfter: '',
});
});
it('should extract https URL from content', () => {
const content = 'Text before https://example.com text after';
const result = parseDialogContentForUrl(content);
expect(result).toEqual<ParsedDialogContent>({
textBefore: 'Text before',
url: 'https://example.com',
textAfter: 'text after',
});
});
it('should extract http URL from content', () => {
const content = 'Text before http://example.com text after';
const result = parseDialogContentForUrl(content);
expect(result).toEqual<ParsedDialogContent>({
textBefore: 'Text before',
url: 'http://example.com',
textAfter: 'text after',
});
});
it('should handle URL at the beginning of content', () => {
const content = 'https://example.com/path text after';
const result = parseDialogContentForUrl(content);
expect(result).toEqual<ParsedDialogContent>({
textBefore: '',
url: 'https://example.com/path',
textAfter: 'text after',
});
});
it('should handle URL at the end of content', () => {
const content = 'Text before https://example.com/path';
const result = parseDialogContentForUrl(content);
expect(result).toEqual<ParsedDialogContent>({
textBefore: 'Text before',
url: 'https://example.com/path',
textAfter: '',
});
});
it('should handle real-world content with newlines', () => {
const content = `Punkte: 80500
Um alle Vorteile der Kundenkarte nutzen zu können, ist eine Verknüpfung zu einem Online-Konto notwendig. Kund:innen können sich über den QR-Code selbstständig anmelden oder die Kundenkarte dem bestehendem Konto hinzufügen. Bereits gesammelte Punkte werden übernommen.
https://h-k.me/QOHNTFVA`;
const result = parseDialogContentForUrl(content);
expect(result.url).toBe('https://h-k.me/QOHNTFVA');
expect(result.textBefore).toContain('Punkte: 80500');
expect(result.textBefore).toContain(
'Bereits gesammelte Punkte werden übernommen.',
);
expect(result.textAfter).toBe('');
});
it('should extract only the first URL when multiple URLs are present', () => {
const content = 'First https://first.com then https://second.com';
const result = parseDialogContentForUrl(content);
expect(result).toEqual<ParsedDialogContent>({
textBefore: 'First',
url: 'https://first.com',
textAfter: 'then https://second.com',
});
});
it('should handle URLs with paths and query parameters', () => {
const content =
'Visit https://example.com/path?query=value&foo=bar for more';
const result = parseDialogContentForUrl(content);
expect(result.url).toBe('https://example.com/path?query=value&foo=bar');
expect(result.textBefore).toBe('Visit');
expect(result.textAfter).toBe('for more');
});
});
describe('contentHasUrl', () => {
it('should return false for undefined content', () => {
expect(contentHasUrl(undefined)).toBe(false);
});
it('should return false for empty string', () => {
expect(contentHasUrl('')).toBe(false);
});
it('should return false for content without URL', () => {
expect(contentHasUrl('This is text without a URL')).toBe(false);
});
it('should return true for content with https URL', () => {
expect(contentHasUrl('Check out https://example.com')).toBe(true);
});
it('should return true for content with http URL', () => {
expect(contentHasUrl('Check out http://example.com')).toBe(true);
});
it('should return true for real-world content', () => {
const content = `Punkte: 80500
Um alle Vorteile der Kundenkarte nutzen zu können...
https://h-k.me/QOHNTFVA`;
expect(contentHasUrl(content)).toBe(true);
});
});

View File

@@ -1,4 +1,7 @@
import { DialogSettings, KeyValueDTOOfStringAndString } from '@generated/swagger/crm-api';
import {
DialogSettings,
KeyValueDTOOfStringAndString,
} from '@generated/swagger/crm-api';
export interface DialogModel<T = any> {
actions?: Array<KeyValueDTOOfStringAndString>;
@@ -14,4 +17,21 @@ export interface DialogModel<T = any> {
* default: true
*/
handleCommand?: boolean;
/**
* If true, URLs in the content will be displayed as QR codes.
* default: false
*/
showUrlAsQrCode?: boolean;
}
/**
* Result of parsing content for URLs
*/
export interface ParsedDialogContent {
/** Text before the URL */
textBefore: string;
/** The extracted URL (if any) */
url: string | null;
/** Text after the URL */
textAfter: string;
}

View File

@@ -13,6 +13,7 @@ import { Observable, throwError } from 'rxjs';
import { catchError, tap } from 'rxjs/operators';
import { DialogModel } from './dialog.model';
import { ToasterService } from '@shared/shell';
import { contentHasUrl } from './dialog.helper';
@Injectable()
export class OpenDialogInterceptor implements HttpInterceptor {
@@ -21,7 +22,10 @@ export class OpenDialogInterceptor implements HttpInterceptor {
private _toast: ToasterService,
) {}
intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
intercept(
req: HttpRequest<any>,
next: HttpHandler,
): Observable<HttpEvent<any>> {
return next.handle(req).pipe(
tap((response) => {
if (response instanceof HttpResponse) {
@@ -59,9 +63,17 @@ export class OpenDialogInterceptor implements HttpInterceptor {
}
openDialog(model: DialogModel<any>) {
// Auto-detect URLs and enable QR code display if URL is found
// Can be overridden by explicitly setting showUrlAsQrCode in the model
const showUrlAsQrCode =
model.showUrlAsQrCode ?? contentHasUrl(model.content);
this._modal.open({
content: UiDialogModalComponent,
data: model,
data: {
...model,
showUrlAsQrCode,
},
title: model.title,
config: {
canClose: (model.settings & 1) === 1,

View File

@@ -2,7 +2,6 @@ import { OverlayModule } from '@angular/cdk/overlay';
import { CommonModule } from '@angular/common';
import { ModuleWithProviders, NgModule } from '@angular/core';
import { UiModalComponent } from './modal.component';
import { UiModalService } from './modal.service';
import { UiDebugModalComponent } from './debug-modal/debug-modal.component';
import { UiMessageModalComponent } from './message-modal.component';
import { UiIconModule } from '@ui/icon';
@@ -10,9 +9,10 @@ import { UiDialogModalComponent } from './dialog/dialog-modal.component';
import { HTTP_INTERCEPTORS } from '@angular/common/http';
import { OpenDialogInterceptor } from './dialog/open-dialog.interceptor';
import { UiPromptModalComponent } from './prompt-modal';
import { QRCodeComponent } from 'angularx-qrcode';
@NgModule({
imports: [CommonModule, OverlayModule, UiIconModule],
imports: [CommonModule, OverlayModule, UiIconModule, QRCodeComponent],
declarations: [
UiModalComponent,
UiDebugModalComponent,

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,5 @@
import { inject, Injectable } from '@angular/core';
import { AvailabilityService as GeneratedAvailabilityService } from '@generated/swagger/availability-api';
import { ResponseArgsError, takeUntilAborted } from '@isa/common/data-access';
import { LogisticianService, Logistician } from '@isa/oms/data-access';
import { logger } from '@isa/core/logging';
// TODO: [Next Sprint - Architectural] Abstract cross-domain dependency

View File

@@ -4,12 +4,13 @@ import {
} from '@isa/checkout/data-access';
/**
* Creates a unique key for an item based on EAN, destination, and orderItemType.
* Creates a unique key for an item based on EAN, targetBranchId, and orderItemType.
* Items are only considered identical if all three match.
*/
export const getItemKey = (item: ShoppingCartItem): string => {
const ean = item.product.ean ?? 'no-ean';
const destinationId = item.destination?.data?.id ?? 'no-destination';
const targetBranchId =
item.destination?.data?.targetBranch?.id ?? 'no-target-branch-id';
const orderType = getOrderTypeFeature(item.features) ?? 'no-orderType';
return `${ean}|${destinationId}|${orderType}`;
return `${ean}|${targetBranchId}|${orderType}`;
};

View File

@@ -1,6 +1,9 @@
import { inject, Injectable } from '@angular/core';
import { StoreCheckoutBranchService } from '@generated/swagger/checkout-api';
import { ResponseArgsError, takeUntilAborted } from '@isa/common/data-access';
import {
catchResponseArgsErrorPipe,
takeUntilAborted,
} from '@isa/common/data-access';
import { logger } from '@isa/core/logging';
import { firstValueFrom } from 'rxjs';
import { Cache, CacheTimeToLive, InFlight } from '@isa/common/decorators';
@@ -14,20 +17,20 @@ export class BranchService {
@Cache({ ttl: CacheTimeToLive.fiveMinutes })
@InFlight()
async fetchBranches(abortSignal?: AbortSignal): Promise<Branch[]> {
let req$ = this.#branchService.StoreCheckoutBranchGetBranches({});
let req$ = this.#branchService
.StoreCheckoutBranchGetBranches({})
.pipe(catchResponseArgsErrorPipe());
if (abortSignal) {
req$ = req$.pipe(takeUntilAborted(abortSignal));
}
const res = await firstValueFrom(req$);
if (res.error) {
const error = new ResponseArgsError(res);
try {
const res = await firstValueFrom(req$);
return res.result as Branch[];
} catch (error) {
this.#logger.error('Failed to fetch branches', error);
throw error;
}
return res.result as Branch[];
}
}

View File

@@ -4,7 +4,7 @@ import {
PrintOrderConfirmation,
PrintOrderConfirmationSchema,
} from '../schemas';
import { ResponseArgsError } from '@isa/common/data-access';
import { catchResponseArgsErrorPipe } from '@isa/common/data-access';
import { firstValueFrom } from 'rxjs';
import { logger } from '@isa/core/logging';
@@ -18,16 +18,19 @@ export class CheckoutPrintService {
): Promise<ResponseArgs> {
const parsed = PrintOrderConfirmationSchema.parse(params);
const req$ = this.#omsPrintService.OMSPrintAbholscheinById(parsed);
const req$ = this.#omsPrintService
.OMSPrintAbholscheinById(parsed)
.pipe(catchResponseArgsErrorPipe());
const res = await firstValueFrom(req$);
if (res.error) {
const err = new ResponseArgsError(res);
this.#logger.error('Failed to print order confirmation', err);
throw err;
try {
const res = await firstValueFrom(req$);
return res;
} catch (error) {
this.#logger.error('Failed to print order confirmation', error, () => ({
printer: parsed.printer,
orderIds: parsed.data,
}));
throw error;
}
return res;
}
}

View File

@@ -7,15 +7,11 @@ import {
StoreCheckoutPayerService,
StoreCheckoutPaymentService,
DestinationDTO,
BuyerDTO,
PayerDTO,
AvailabilityDTO,
} from '@generated/swagger/checkout-api';
import {
EntityContainer,
ResponseArgsError,
takeUntilAborted,
catchResponseArgsErrorPipe,
} from '@isa/common/data-access';
import { logger } from '@isa/core/logging';
import { firstValueFrom } from 'rxjs';
@@ -29,7 +25,6 @@ import {
PaymentType,
} from '../schemas';
import {
Order,
OrderOptionsAnalysis,
CustomerTypeAnalysis,
Checkout,
@@ -155,7 +150,6 @@ export class CheckoutService {
this.#logger.debug('Creating or refreshing checkout');
const initialCheckout = await this.refreshCheckout(
validated.shoppingCartId,
abortSignal,
);
const checkoutId = initialCheckout.id;
@@ -169,7 +163,6 @@ export class CheckoutService {
await this.updateDestinationsForCustomer(
validated.shoppingCartId,
validated.customerFeatures,
abortSignal,
);
}
@@ -188,7 +181,6 @@ export class CheckoutService {
validated.shoppingCartId,
shoppingCart.items,
validated.specialComment,
abortSignal,
);
}
@@ -209,26 +201,25 @@ export class CheckoutService {
// Step 9: Set buyer on checkout
this.#logger.debug('Setting buyer on checkout');
await this.setBuyerOnCheckout(checkoutId, validated.buyer, abortSignal);
await this.setBuyerOnCheckout(checkoutId, validated.buyer);
// Step 10: Set notification channels
this.#logger.debug('Setting notification channels');
await this.setNotificationChannelsOnCheckout(
checkoutId,
validated.notificationChannels ?? 0,
abortSignal,
);
// Step 11: Set payer (conditional)
if (needsPayer && validated.payer) {
this.#logger.debug('Setting payer on checkout');
await this.setPayerOnCheckout(checkoutId, validated.payer, abortSignal);
await this.setPayerOnCheckout(checkoutId, validated.payer);
}
// Step 12: Set payment type based on order types
const paymentType = this.determinePaymentType(orderOptions);
this.#logger.debug('Setting payment type');
await this.setPaymentTypeOnCheckout(checkoutId, paymentType, abortSignal);
await this.setPaymentTypeOnCheckout(checkoutId, paymentType);
// Step 13: Update destination shipping addresses (if delivery or download)
// Refresh checkout only when we need the destinations data
@@ -240,17 +231,13 @@ export class CheckoutService {
validated.shippingAddress
) {
this.#logger.debug('Refreshing checkout to get destinations');
const checkout = await this.refreshCheckout(
validated.shoppingCartId,
abortSignal,
);
const checkout = await this.refreshCheckout(validated.shoppingCartId);
this.#logger.debug('Updating destination shipping addresses');
await this.updateDestinationShippingAddresses(
checkoutId,
checkout,
validated.shippingAddress,
abortSignal,
);
}
@@ -330,25 +317,22 @@ export class CheckoutService {
private async updateDestinationsForCustomer(
shoppingCartId: number,
customerFeatures: Record<string, string>,
abortSignal?: AbortSignal,
): Promise<void> {
let req$ =
this.#shoppingCartService.StoreCheckoutShoppingCartSetLogisticianOnDestinationsByBuyer(
{
shoppingCartId,
payload: { customerFeatures },
},
const req$ = this.#shoppingCartService
.StoreCheckoutShoppingCartSetLogisticianOnDestinationsByBuyer({
shoppingCartId,
payload: { customerFeatures },
})
.pipe(catchResponseArgsErrorPipe());
try {
await firstValueFrom(req$);
} catch (error) {
this.#logger.error(
'Failed to update destinations for customer',
error,
() => ({ shoppingCartId }),
);
if (abortSignal) {
req$ = req$.pipe(takeUntilAborted(abortSignal));
}
const res = await firstValueFrom(req$);
if (res.error) {
const error = new ResponseArgsError(res);
this.#logger.error('Failed to update destinations for customer', error);
throw error;
}
}
@@ -360,31 +344,30 @@ export class CheckoutService {
shoppingCartId: number,
items: EntityContainer<ShoppingCartItem>[],
comment: string,
abortSignal?: AbortSignal,
): Promise<void> {
// Update all items in parallel
await Promise.all(
items.map(async (item) => {
if (!item.id) return;
let req$ =
this.#shoppingCartService.StoreCheckoutShoppingCartUpdateShoppingCartItem(
{
const req$ = this.#shoppingCartService
.StoreCheckoutShoppingCartUpdateShoppingCartItem({
shoppingCartId,
shoppingCartItemId: item.id,
values: { specialComment: comment },
})
.pipe(catchResponseArgsErrorPipe());
try {
await firstValueFrom(req$);
} catch (error) {
this.#logger.error(
'Failed to set special comment on item',
error,
() => ({
shoppingCartId,
shoppingCartItemId: item.id,
values: { specialComment: comment },
},
}),
);
if (abortSignal) {
req$ = req$.pipe(takeUntilAborted(abortSignal));
}
const res = await firstValueFrom(req$);
if (res.error) {
const error = new ResponseArgsError(res);
this.#logger.error('Failed to set special comment on item');
throw error;
}
}),
@@ -394,27 +377,22 @@ export class CheckoutService {
/**
* Refreshes checkout to get latest state.
*/
private async refreshCheckout(
shoppingCartId: number,
abortSignal?: AbortSignal,
): Promise<Checkout> {
let req$ = this.#storeCheckoutService.StoreCheckoutCreateOrRefreshCheckout({
shoppingCartId,
});
private async refreshCheckout(shoppingCartId: number): Promise<Checkout> {
const req$ = this.#storeCheckoutService
.StoreCheckoutCreateOrRefreshCheckout({
shoppingCartId,
})
.pipe(catchResponseArgsErrorPipe());
if (abortSignal) {
req$ = req$.pipe(takeUntilAborted(abortSignal));
}
const res = await firstValueFrom(req$);
if (res.error) {
const error = new ResponseArgsError(res);
this.#logger.error('Failed to refresh checkout', error);
try {
const res = await firstValueFrom(req$);
return res.result as Checkout;
} catch (error) {
this.#logger.error('Failed to refresh checkout', error, () => ({
shoppingCartId,
}));
throw error;
}
return res.result as Checkout;
}
/**
@@ -472,7 +450,7 @@ export class CheckoutService {
const availabilityDTO =
AvailabilityAdapter.fromAvailabilityApi(availability);
let updateReq$ =
const updateReq$ =
this.#shoppingCartService.StoreCheckoutShoppingCartUpdateShoppingCartItemAvailability(
{
shoppingCartId,
@@ -481,10 +459,6 @@ export class CheckoutService {
},
);
if (abortSignal) {
updateReq$ = updateReq$.pipe(takeUntilAborted(abortSignal));
}
const updateRes = await firstValueFrom(updateReq$);
if (updateRes.error) {
@@ -582,7 +556,7 @@ export class CheckoutService {
};
}
let req$ =
const req$ =
this.#shoppingCartService.StoreCheckoutShoppingCartUpdateShoppingCartItem(
{
shoppingCartId,
@@ -591,10 +565,6 @@ export class CheckoutService {
},
);
if (abortSignal) {
req$ = req$.pipe(takeUntilAborted(abortSignal));
}
const res = await firstValueFrom(req$);
if (res.error) {
@@ -618,26 +588,23 @@ export class CheckoutService {
private async setBuyerOnCheckout(
checkoutId: number,
buyerDTO: Buyer,
abortSignal?: AbortSignal,
): Promise<Checkout> {
let req$ = this.#buyerService.StoreCheckoutBuyerSetBuyerPOST({
checkoutId,
buyerDTO,
});
const req$ = this.#buyerService
.StoreCheckoutBuyerSetBuyerPOST({
checkoutId,
buyerDTO,
})
.pipe(catchResponseArgsErrorPipe());
if (abortSignal) {
req$ = req$.pipe(takeUntilAborted(abortSignal));
}
const res = await firstValueFrom(req$);
if (res.error) {
const error = new ResponseArgsError(res);
this.#logger.error('Failed to set buyer', error);
try {
const res = await firstValueFrom(req$);
return res.result as Checkout;
} catch (error) {
this.#logger.error('Failed to set buyer on checkout', error, () => ({
checkoutId,
}));
throw error;
}
return res.result as Checkout;
}
/**
@@ -646,26 +613,23 @@ export class CheckoutService {
private async setPayerOnCheckout(
checkoutId: number,
payer: Payer,
abortSignal?: AbortSignal,
): Promise<Checkout> {
let req$ = this.#payerService.StoreCheckoutPayerSetPayerPOST({
checkoutId,
payerDTO: payer,
});
const req$ = this.#payerService
.StoreCheckoutPayerSetPayerPOST({
checkoutId,
payerDTO: payer,
})
.pipe(catchResponseArgsErrorPipe());
if (abortSignal) {
req$ = req$.pipe(takeUntilAborted(abortSignal));
}
const res = await firstValueFrom(req$);
if (res.error) {
const error = new ResponseArgsError(res);
this.#logger.error('Failed to set payer', error);
try {
const res = await firstValueFrom(req$);
return res.result as Checkout;
} catch (error) {
this.#logger.error('Failed to set payer on checkout', error, () => ({
checkoutId,
}));
throw error;
}
return res.result as Checkout;
}
/**
@@ -674,26 +638,25 @@ export class CheckoutService {
private async setNotificationChannelsOnCheckout(
checkoutId: number,
channels: NotificationChannel,
abortSignal?: AbortSignal,
): Promise<Checkout> {
let req$ = this.#storeCheckoutService.StoreCheckoutSetNotificationChannels({
checkoutId,
notificationChannel: channels,
});
const req$ = this.#storeCheckoutService
.StoreCheckoutSetNotificationChannels({
checkoutId,
notificationChannel: channels,
})
.pipe(catchResponseArgsErrorPipe());
if (abortSignal) {
req$ = req$.pipe(takeUntilAborted(abortSignal));
}
const res = await firstValueFrom(req$);
if (res.error) {
const error = new ResponseArgsError(res);
this.#logger.error('Failed to set notification channels', error);
try {
const res = await firstValueFrom(req$);
return res.result as Checkout;
} catch (error) {
this.#logger.error(
'Failed to set notification channels on checkout',
error,
() => ({ checkoutId }),
);
throw error;
}
return res.result as Checkout;
}
/**
@@ -702,26 +665,25 @@ export class CheckoutService {
private async setPaymentTypeOnCheckout(
checkoutId: number,
paymentType: PaymentType,
abortSignal?: AbortSignal,
): Promise<Checkout> {
let req$ = this.#paymentService.StoreCheckoutPaymentSetPaymentType({
checkoutId,
paymentType: paymentType,
});
const req$ = this.#paymentService
.StoreCheckoutPaymentSetPaymentType({
checkoutId,
paymentType: paymentType,
})
.pipe(catchResponseArgsErrorPipe());
if (abortSignal) {
req$ = req$.pipe(takeUntilAborted(abortSignal));
}
const res = await firstValueFrom(req$);
if (res.error) {
const error = new ResponseArgsError(res);
this.#logger.error('Failed to set payment type', error);
try {
const res = await firstValueFrom(req$);
return res.result as Checkout;
} catch (error) {
this.#logger.error(
'Failed to set payment type on checkout',
error,
() => ({ checkoutId, paymentType }),
);
throw error;
}
return res.result as Checkout;
}
/**
@@ -731,7 +693,6 @@ export class CheckoutService {
checkoutId: number,
checkout: Checkout,
shippingAddress: ShippingAddress,
abortSignal?: AbortSignal,
): Promise<void> {
// Get shipping destinations (target 2 or 16)
const destinations = filterDeliveryDestinations(
@@ -751,21 +712,21 @@ export class CheckoutService {
shippingAddress: { ...shippingAddress },
};
let req$ = this.#storeCheckoutService.StoreCheckoutUpdateDestination({
checkoutId,
destinationId: dest.id,
destinationDTO: updatedDestination,
});
const req$ = this.#storeCheckoutService
.StoreCheckoutUpdateDestination({
checkoutId,
destinationId: dest.id,
destinationDTO: updatedDestination,
})
.pipe(catchResponseArgsErrorPipe());
if (abortSignal) {
req$ = req$.pipe(takeUntilAborted(abortSignal));
}
const res = await firstValueFrom(req$);
if (res.error) {
const error = new ResponseArgsError(res);
this.#logger.error('Failed to update destination');
try {
await firstValueFrom(req$);
} catch (error) {
this.#logger.error('Failed to update destination', error, () => ({
checkoutId,
destinationId: dest.id,
}));
throw error;
}
}),

View File

@@ -18,7 +18,7 @@ import {
} from '../schemas';
import { RewardSelectionItem, ShoppingCart, ShoppingCartItem } from '../models';
import {
ResponseArgsError,
catchResponseArgsErrorPipe,
takeUntilAborted,
ensureCurrencyDefaults,
} from '@isa/common/data-access';
@@ -36,107 +36,105 @@ export class ShoppingCartService {
#checkoutMetadataService = inject(CheckoutMetadataService);
async createShoppingCart(): Promise<ShoppingCart> {
const req$ =
this.#storeCheckoutShoppingCartService.StoreCheckoutShoppingCartCreateShoppingCart();
const req$ = this.#storeCheckoutShoppingCartService
.StoreCheckoutShoppingCartCreateShoppingCart()
.pipe(catchResponseArgsErrorPipe());
const res = await firstValueFrom(req$);
try {
const res = await firstValueFrom(req$);
if (res.error) {
const err = new ResponseArgsError(res);
this.#logger.error('Failed to create shopping cart', err);
throw err;
this.#shoppingCartStream.pub(
ShoppingCartEvent.Created,
res.result as ShoppingCart,
'ShoppingCartService',
);
return res.result as ShoppingCart;
} catch (error) {
this.#logger.error('Failed to create shopping cart', error);
throw error;
}
this.#shoppingCartStream.pub(
ShoppingCartEvent.Created,
res.result as ShoppingCart,
'ShoppingCartService',
);
return res.result as ShoppingCart;
}
async getShoppingCart(
shoppingCartId: number,
abortSignal?: AbortSignal,
): Promise<ShoppingCart | undefined> {
let req$ =
this.#storeCheckoutShoppingCartService.StoreCheckoutShoppingCartGetShoppingCart(
{
shoppingCartId,
},
);
let req$ = this.#storeCheckoutShoppingCartService
.StoreCheckoutShoppingCartGetShoppingCart({
shoppingCartId,
})
.pipe(catchResponseArgsErrorPipe());
if (abortSignal) {
req$ = req$.pipe(takeUntilAborted(abortSignal));
}
const res = await firstValueFrom(req$);
try {
const res = await firstValueFrom(req$);
if (res.error) {
const err = new ResponseArgsError(res);
this.#logger.error('Failed to fetch shopping cart', err);
throw err;
this.#shoppingCartStream.pub(
ShoppingCartEvent.ItemUpdated,
res.result as ShoppingCart,
'ShoppingCartService',
);
return res.result as ShoppingCart;
} catch (error) {
this.#logger.error('Failed to get shopping cart', error, () => ({
shoppingCartId,
}));
throw error;
}
this.#shoppingCartStream.pub(
ShoppingCartEvent.ItemUpdated,
res.result as ShoppingCart,
'ShoppingCartService',
);
return res.result as ShoppingCart;
}
async canAddItems(
params: CanAddItemsToShoppingCartParams,
): Promise<ItemsResult[]> {
const parsed = CanAddItemsToShoppingCartParamsSchema.parse(params);
const req$ =
this.#storeCheckoutShoppingCartService.StoreCheckoutShoppingCartCanAddItems(
{
shoppingCartId: parsed.shoppingCartId,
payload: parsed.payload as ItemPayload[],
},
);
const req$ = this.#storeCheckoutShoppingCartService
.StoreCheckoutShoppingCartCanAddItems({
shoppingCartId: parsed.shoppingCartId,
payload: parsed.payload as ItemPayload[],
})
.pipe(catchResponseArgsErrorPipe());
const res = await firstValueFrom(req$);
if (res.error) {
const err = new ResponseArgsError(res);
try {
const res = await firstValueFrom(req$);
return res.result as unknown as ItemsResult[];
} catch (error) {
this.#logger.error(
'Failed to check if items can be added to shopping cart',
err,
'Failed to check if items can be added',
error,
() => ({ shoppingCartId: parsed.shoppingCartId }),
);
throw err;
throw error;
}
return res.result as unknown as ItemsResult[];
}
async addItem(params: AddItemToShoppingCartParams): Promise<ShoppingCart> {
const parsed = AddItemToShoppingCartParamsSchema.parse(params);
const req$ =
this.#storeCheckoutShoppingCartService.StoreCheckoutShoppingCartAddItemToShoppingCart(
{
shoppingCartId: parsed.shoppingCartId,
items: parsed.items as AddToShoppingCartDTO[],
},
const req$ = this.#storeCheckoutShoppingCartService
.StoreCheckoutShoppingCartAddItemToShoppingCart({
shoppingCartId: parsed.shoppingCartId,
items: parsed.items as AddToShoppingCartDTO[],
})
.pipe(catchResponseArgsErrorPipe());
try {
const res = await firstValueFrom(req$);
this.#shoppingCartStream.pub(
ShoppingCartEvent.ItemAdded,
res.result as ShoppingCart,
'ShoppingCartService',
);
const res = await firstValueFrom(req$);
if (res.error) {
const err = new ResponseArgsError(res);
this.#logger.error('Failed to add item to shopping cart', err);
throw err;
return res.result as ShoppingCart;
} catch (error) {
this.#logger.error('Failed to add item to shopping cart', error, () => ({
shoppingCartId: parsed.shoppingCartId,
}));
throw error;
}
this.#shoppingCartStream.pub(
ShoppingCartEvent.ItemAdded,
res.result as ShoppingCart,
'ShoppingCartService',
);
return res.result as ShoppingCart;
}
async updateItem(
@@ -144,60 +142,62 @@ export class ShoppingCartService {
): Promise<ShoppingCart> {
const parsed = UpdateShoppingCartItemParamsSchema.parse(params);
const req$ =
this.#storeCheckoutShoppingCartService.StoreCheckoutShoppingCartUpdateShoppingCartItem(
{
shoppingCartId: parsed.shoppingCartId,
shoppingCartItemId: parsed.shoppingCartItemId,
values: parsed.values as UpdateShoppingCartItemDTO,
},
const req$ = this.#storeCheckoutShoppingCartService
.StoreCheckoutShoppingCartUpdateShoppingCartItem({
shoppingCartId: parsed.shoppingCartId,
shoppingCartItemId: parsed.shoppingCartItemId,
values: parsed.values as UpdateShoppingCartItemDTO,
})
.pipe(catchResponseArgsErrorPipe());
try {
const res = await firstValueFrom(req$);
this.#shoppingCartStream.pub(
ShoppingCartEvent.ItemUpdated,
res.result as ShoppingCart,
'ShoppingCartService',
);
const res = await firstValueFrom(req$);
if (res.error) {
const err = new ResponseArgsError(res);
this.#logger.error('Failed to update shopping cart item', err);
throw err;
return res.result as ShoppingCart;
} catch (error) {
this.#logger.error('Failed to update shopping cart item', error, () => ({
shoppingCartId: parsed.shoppingCartId,
shoppingCartItemId: parsed.shoppingCartItemId,
}));
throw error;
}
this.#shoppingCartStream.pub(
ShoppingCartEvent.ItemUpdated,
res.result as ShoppingCart,
'ShoppingCartService',
);
return res.result as ShoppingCart;
}
async removeItem(
params: RemoveShoppingCartItemParams,
): Promise<ShoppingCart> {
const parsed = RemoveShoppingCartItemParamsSchema.parse(params);
const req$ =
this.#storeCheckoutShoppingCartService.StoreCheckoutShoppingCartUpdateShoppingCartItem(
{
shoppingCartId: parsed.shoppingCartId,
shoppingCartItemId: parsed.shoppingCartItemId,
values: {
quantity: 0,
},
const req$ = this.#storeCheckoutShoppingCartService
.StoreCheckoutShoppingCartUpdateShoppingCartItem({
shoppingCartId: parsed.shoppingCartId,
shoppingCartItemId: parsed.shoppingCartItemId,
values: {
quantity: 0,
},
})
.pipe(catchResponseArgsErrorPipe());
try {
const res = await firstValueFrom(req$);
this.#shoppingCartStream.pub(
ShoppingCartEvent.ItemRemoved,
res.result as ShoppingCart,
'ShoppingCartService',
);
const res = await firstValueFrom(req$);
if (res.error) {
const err = new ResponseArgsError(res);
this.#logger.error('Failed to remove item from shopping cart', err);
throw err;
return res.result as ShoppingCart;
} catch (error) {
this.#logger.error('Failed to remove shopping cart item', error, () => ({
shoppingCartId: parsed.shoppingCartId,
shoppingCartItemId: parsed.shoppingCartItemId,
}));
throw error;
}
this.#shoppingCartStream.pub(
ShoppingCartEvent.ItemRemoved,
res.result as ShoppingCart,
'ShoppingCartService',
);
return res.result as ShoppingCart;
}
// TODO: Code Kommentieren + Beschreiben

View File

@@ -87,7 +87,8 @@ describe('SupplierService', () => {
// Arrange
const errorResponse = {
result: null,
error: { message: 'API Error', code: 500 },
error: true,
message: 'API Error',
};
mockSupplierService.StoreCheckoutSupplierGetSuppliers.mockReturnValue(

View File

@@ -1,6 +1,9 @@
import { inject, Injectable } from '@angular/core';
import { StoreCheckoutSupplierService } from '@generated/swagger/checkout-api';
import { ResponseArgsError, takeUntilAborted } from '@isa/common/data-access';
import {
catchResponseArgsErrorPipe,
takeUntilAborted,
} from '@isa/common/data-access';
import { logger } from '@isa/core/logging';
import { firstValueFrom } from 'rxjs';
import { Cache, CacheTimeToLive, InFlight } from '@isa/common/decorators';
@@ -32,37 +35,42 @@ export class SupplierService {
async getTakeAwaySupplier(abortSignal?: AbortSignal): Promise<Supplier> {
this.#logger.debug('Fetching take away supplier');
let req$ = this.#supplierService.StoreCheckoutSupplierGetSuppliers({});
let req$ = this.#supplierService
.StoreCheckoutSupplierGetSuppliers({})
.pipe(catchResponseArgsErrorPipe());
if (abortSignal) {
req$ = req$.pipe(takeUntilAborted(abortSignal));
}
const res = await firstValueFrom(req$);
try {
const res = await firstValueFrom(req$);
if (res.error) {
const error = new ResponseArgsError(res);
this.#logger.error('Failed to fetch suppliers', error);
const takeAwaySupplier = res.result?.find(
(supplier) => supplier.supplierNumber === 'F',
);
if (!takeAwaySupplier) {
const notFoundError = new Error('Take away supplier (F) not found');
this.#logger.error(
'Take away supplier not found',
notFoundError,
() => ({
availableSuppliers: res.result?.map((s) => s.supplierNumber),
}),
);
throw notFoundError;
}
this.#logger.debug('Take away supplier fetched', () => ({
supplierId: takeAwaySupplier.id,
supplierNumber: takeAwaySupplier.supplierNumber,
}));
return takeAwaySupplier;
} catch (error) {
this.#logger.error('Failed to fetch take away supplier', error);
throw error;
}
const takeAwaySupplier = res.result?.find(
(supplier) => supplier.supplierNumber === 'F',
);
if (!takeAwaySupplier) {
const notFoundError = new Error('Take away supplier (F) not found');
this.#logger.error('Take away supplier not found', notFoundError, () => ({
availableSuppliers: res.result?.map((s) => s.supplierNumber),
}));
throw notFoundError;
}
this.#logger.debug('Take away supplier fetched', () => ({
supplierId: takeAwaySupplier.id,
supplierNumber: takeAwaySupplier.supplierNumber,
}));
return takeAwaySupplier;
}
}

View File

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

View File

@@ -73,9 +73,6 @@ export class RewardCatalogComponent {
#filterService = inject(FilterService);
displayStockFilterSwitch = computed(() => {
if (this.isCallCenter) {
return [];
}
const stockInput = this.#filterService
.inputs()
?.filter((input) => input.target === 'filter')

View File

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

View File

@@ -49,7 +49,7 @@ export class OrderConfirmationHeaderComponent {
if (!orders || orders.length === 0) {
return '';
}
return orders
const formattedDates = orders
.map((order) => {
if (!order.orderDate) {
return null;
@@ -60,7 +60,8 @@ export class OrderConfirmationHeaderComponent {
);
return formatted ? `${formatted} Uhr` : null;
})
.filter(Boolean)
.join('; ');
.filter(Boolean);
return [...new Set(formattedDates)].join('; ');
});
}

View File

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

View File

@@ -66,7 +66,8 @@ export class RewardShoppingCartItemQuantityControlComponent {
if (
orderType === OrderTypeFeature.Delivery ||
orderType === OrderTypeFeature.DigitalShipping ||
orderType === OrderTypeFeature.B2BShipping
orderType === OrderTypeFeature.B2BShipping ||
orderType === OrderTypeFeature.Pickup
) {
return 999;
}

View File

@@ -37,11 +37,20 @@
</div>
</div>
@if (quantityControl.maxQuantity() < 2 && !isDownload()) {
<div
class="text-isa-accent-red isa-text-body-2-bold flex flex-row items-center gap-2"
>
<ng-icon name="isaOtherInfo" size="1.5rem"></ng-icon>
<div>Geringer Bestand - Artikel holen vor Abschluss</div>
</div>
@if (!isDownload()) {
@if (showLowStockMessage()) {
<div
class="text-isa-accent-red isa-text-body-2-bold flex flex-row items-center gap-2"
>
<ng-icon name="isaOtherInfo" size="1.5rem"></ng-icon>
<div>{{ inStock() }} Exemplare sofort lieferbar</div>
</div>
} @else if (quantityControl.maxQuantity() < 2) {
<div
class="text-isa-accent-red isa-text-body-2-bold flex flex-row items-center gap-2"
>
<ng-icon name="isaOtherInfo" size="1.5rem"></ng-icon>
<div>Geringer Bestand - Artikel holen vor Abschluss</div>
</div>
}
}

View File

@@ -72,9 +72,20 @@ export class RewardShoppingCartItemComponent {
hasOrderTypeFeature(this.item().features, ['Download']),
);
isAbholung = computed(() =>
hasOrderTypeFeature(this.item().features, ['Abholung']),
);
inStock = computed(() => this.item().availability?.inStock ?? 0);
showLowStockMessage = computed(() => {
return this.isAbholung() && this.inStock() < 2;
});
async updatePurchaseOption() {
const shoppingCartItemId = this.itemId();
const shoppingCartId = this.shoppingCartId();
const branch = this.item().destination?.data?.targetBranch?.data;
if (this.isBusy() || !shoppingCartId || !shoppingCartItemId) {
return;
@@ -90,6 +101,8 @@ export class RewardShoppingCartItemComponent {
useRedemptionPoints: true,
disabledPurchaseOptions: ['b2b-delivery'],
hideDisabledPurchaseOptions: true,
pickupBranch: branch,
inStoreBranch: branch,
});
await firstValueFrom(ref.afterClosed$);

View File

@@ -1,3 +1,3 @@
:host {
@apply text-isa-accent-red isa-text-body-2-bold flex flex-row gap-2 items-center;
@apply text-isa-accent-red isa-text-body-2-bold flex flex-col gap-2 items-start;
}

View File

@@ -1,8 +1,10 @@
@if (store.totalLoyaltyPointsNeeded() > store.customerRewardPoints()) {
<ng-icon
class="w-6 h-6 inline-flex items-center justify-center"
size="1.5rem"
name="isaOtherInfo"
></ng-icon>
<span>Lesepunkte reichen nicht für alle Artikel</span>
<div class="flex flex-row gap-2 items-center">
<ng-icon
class="w-6 h-6 inline-flex items-center justify-center"
size="1.5rem"
name="isaOtherInfo"
></ng-icon>
<span>Lesepunkte reichen nicht für alle Artikel</span>
</div>
}

View File

@@ -41,10 +41,7 @@ export class RewardSelectionInputsComponent {
hasCorrectOrderType = computed(() => {
const item = this.rewardSelectionItem().item;
return hasOrderTypeFeature(item.features, [
OrderTypeFeature.InStore,
OrderTypeFeature.Pickup,
]);
return hasOrderTypeFeature(item.features, [OrderTypeFeature.InStore]);
});
hasStock = computed(() => {

View File

@@ -27,3 +27,16 @@
<lib-reward-selection-inputs></lib-reward-selection-inputs>
</div>
@if (showLowStockMessage()) {
<div
class="flex flex-row gap-2 items-center text-isa-accent-red isa-text-body-2-bold"
>
<ng-icon
class="w-6 h-6 inline-flex items-center justify-center"
size="1.5rem"
name="isaOtherInfo"
></ng-icon>
<span>{{ inStock() }} Exemplare sofort lieferbar</span>
</div>
}

View File

@@ -1,8 +1,18 @@
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
import {
ChangeDetectionStrategy,
Component,
computed,
input,
} from '@angular/core';
import { ProductImageDirective } from '@isa/shared/product-image';
import { ProductRouterLinkDirective } from '@isa/shared/product-router-link';
import { RewardSelectionInputsComponent } from './reward-selection-inputs/reward-selection-inputs.component';
import { RewardSelectionItem } from '@isa/checkout/data-access';
import {
hasOrderTypeFeature,
RewardSelectionItem,
} from '@isa/checkout/data-access';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { isaOtherInfo } from '@isa/icons';
@Component({
selector: 'lib-reward-selection-item',
@@ -13,8 +23,24 @@ import { RewardSelectionItem } from '@isa/checkout/data-access';
ProductImageDirective,
ProductRouterLinkDirective,
RewardSelectionInputsComponent,
NgIcon,
],
providers: [provideIcons({ isaOtherInfo })],
})
export class RewardSelectionItemComponent {
rewardSelectionItem = input.required<RewardSelectionItem>();
inStock = computed(
() => this.rewardSelectionItem().item?.availability?.inStock ?? 0,
);
isAbholung = computed(() =>
hasOrderTypeFeature(this.rewardSelectionItem()?.item?.features, [
'Abholung',
]),
);
showLowStockMessage = computed(() => {
return this.isAbholung() && this.inStock() < 2;
});
}

View File

@@ -34,8 +34,15 @@ export const catchResponseArgsErrorPipe = <T>(): OperatorFunction<T, T> =>
return throwError(() => err);
}),
mergeMap((response) => {
if (isResponseArgs(response) && response.error === true) {
return throwError(() => new ResponseArgsError(response));
if (isResponseArgs(response)) {
// Treat as error if error flag is true OR if invalidProperties has entries
const hasInvalidProps =
response.invalidProperties &&
Object.keys(response.invalidProperties).length > 0;
if (response.error === true || hasInvalidProps) {
return throwError(() => new ResponseArgsError(response));
}
}
return [response];

View File

@@ -1,855 +0,0 @@
# @isa/core/navigation
A reusable Angular library providing **context preservation** for multi-step navigation flows with automatic tab-scoped storage.
## Overview
`@isa/core/navigation` solves the problem of **lost navigation state** during intermediate navigations. Unlike Angular's router state which is lost after intermediate navigations, this library persists navigation context in **tab metadata** with automatic cleanup when tabs close.
### The Problem It Solves
```typescript
// ❌ Problem: Router state is lost during intermediate navigations
await router.navigate(['/customer/search'], {
state: { returnUrl: '/reward/cart' } // Works for immediate navigation
});
// After intermediate navigations:
// /customer/search → /customer/details → /add-shipping-address
// ⚠️ The returnUrl is LOST!
```
### The Solution
```typescript
// ✅ Solution: Context preservation survives intermediate navigations
navState.preserveContext({ returnUrl: '/reward/cart' });
// Context persists in tab metadata, automatically cleaned up when tab closes
// After multiple intermediate navigations:
const context = navState.restoreAndClearContext();
// ✅ returnUrl is PRESERVED!
```
## Features
-**Survives Intermediate Navigations** - State persists across multiple navigation steps
-**Automatic Tab Scoping** - Contexts automatically isolated per tab using `TabService`
-**Automatic Cleanup** - Contexts cleared automatically when tabs close (no manual cleanup needed)
-**Hierarchical Scoping** - Combine tab ID with custom scopes (e.g., `"customer-details"`)
-**Type-Safe** - Full TypeScript generics support
-**Simple API** - No context IDs to track, scope is the identifier
-**Auto-Restore on Refresh** - Contexts survive page refresh (via TabService UserStorage persistence)
-**Map-Based Storage** - One context per scope for clarity
-**Platform-Agnostic** - Works with Angular Universal (SSR)
-**Zero URL Pollution** - No query parameters needed
## Installation
This library is part of the ISA Frontend monorepo. Import from the path alias:
```typescript
import { NavigationStateService } from '@isa/core/navigation';
```
## Quick Start
### Basic Flow
```typescript
import { Component, inject } from '@angular/core';
import { Router } from '@angular/router';
import { NavigationStateService } from '@isa/core/navigation';
@Component({
selector: 'app-cart',
template: `<button (click)="editCustomer()">Edit Customer</button>`
})
export class CartComponent {
private router = inject(Router);
private navState = inject(NavigationStateService);
async editCustomer() {
// Start flow - preserve context (auto-scoped to active tab)
this.navState.preserveContext({
returnUrl: '/reward/cart',
customerId: 123
});
await this.router.navigate(['/customer/search']);
}
}
@Component({
selector: 'app-customer-details',
template: `<button (click)="complete()">Complete</button>`
})
export class CustomerDetailsComponent {
private router = inject(Router);
private navState = inject(NavigationStateService);
async complete() {
// End flow - restore and auto-cleanup (auto-scoped to active tab)
const context = this.navState.restoreAndClearContext<{ returnUrl: string }>();
if (context?.returnUrl) {
await this.router.navigateByUrl(context.returnUrl);
}
}
}
```
### Simplified Navigation
Use `navigateWithPreservedContext()` to combine navigation + context preservation:
```typescript
async editCustomer() {
// Navigate and preserve in one call
const { success } = await this.navState.navigateWithPreservedContext(
['/customer/search'],
{ returnUrl: '/reward/cart', customerId: 123 }
);
}
```
## Core API
### Context Management
#### `preserveContext<T>(state, customScope?)`
Save navigation context that survives intermediate navigations.
```typescript
// Default tab scope
navState.preserveContext({
returnUrl: '/reward/cart',
selectedItems: [1, 2, 3]
});
// Custom scope within tab
navState.preserveContext(
{ customerId: 42 },
'customer-details' // Stored as 'customer-details' in active tab's metadata
);
```
**Parameters:**
- `state`: The data to preserve (any object)
- `customScope` (optional): Custom scope within the tab (e.g., `'customer-details'`)
**Storage Location:**
- Stored in active tab's metadata at: `tab.metadata['navigation-contexts'][scopeKey]`
- Default scope: `'default'`
- Custom scope: `customScope` value
---
#### `patchContext<T>(partialState, customScope?)`
Partially update preserved context without replacing the entire context.
This method merges partial state with the existing context, preserving properties you don't specify. Properties explicitly set to `undefined` will be removed from the context.
```typescript
// Existing context: { returnUrl: '/cart', autoTriggerContinueFn: true, customerId: 123 }
// Clear one property while preserving others
await navState.patchContext(
{ autoTriggerContinueFn: undefined },
'select-customer'
);
// Result: { returnUrl: '/cart', customerId: 123 }
// Update one property while preserving others
await navState.patchContext(
{ customerId: 456 },
'select-customer'
);
// Result: { returnUrl: '/cart', customerId: 456 }
// Add new property to existing context
await navState.patchContext(
{ selectedTab: 'details' },
'select-customer'
);
// Result: { returnUrl: '/cart', customerId: 456, selectedTab: 'details' }
```
**Parameters:**
- `partialState`: Partial state object to merge (set properties to `undefined` to remove them)
- `customScope` (optional): Custom scope within the tab
**Use Cases:**
- Clear trigger flags while preserving flow data
- Update specific properties without affecting others
- Remove properties from context
- Add properties to existing context
**Comparison with `preserveContext`:**
- `preserveContext`: Replaces entire context (overwrites all properties)
- `patchContext`: Merges with existing context (updates only specified properties)
```typescript
// ❌ preserveContext - must manually preserve existing properties
const context = await navState.restoreContext();
await navState.preserveContext({
returnUrl: context?.returnUrl, // Must specify
customerId: context?.customerId, // Must specify
autoTriggerContinueFn: undefined, // Clear this
});
// ✅ patchContext - automatically preserves unspecified properties
await navState.patchContext({
autoTriggerContinueFn: undefined, // Only specify what changes
});
```
---
#### `restoreContext<T>(customScope?)`
Retrieve preserved context **without** removing it.
```typescript
// Default tab scope
const context = navState.restoreContext<{ returnUrl: string }>();
if (context?.returnUrl) {
console.log('Return URL:', context.returnUrl);
}
// Custom scope
const context = navState.restoreContext<{ customerId: number }>('customer-details');
```
**Parameters:**
- `customScope` (optional): Custom scope to retrieve from (defaults to 'default')
**Returns:** The preserved data, or `null` if not found
---
#### `restoreAndClearContext<T>(customScope?)`
Retrieve preserved context **and automatically remove** it (recommended for cleanup).
```typescript
// Default tab scope
const context = navState.restoreAndClearContext<{ returnUrl: string }>();
if (context?.returnUrl) {
await router.navigateByUrl(context.returnUrl);
}
// Custom scope
const context = navState.restoreAndClearContext<{ customerId: number }>('customer-details');
```
**Parameters:**
- `customScope` (optional): Custom scope to retrieve from (defaults to 'default')
**Returns:** The preserved data, or `null` if not found
---
#### `clearPreservedContext(customScope?)`
Manually remove a context without retrieving its data.
```typescript
// Clear default tab scope
navState.clearPreservedContext();
// Clear custom scope
navState.clearPreservedContext('customer-details');
```
---
#### `hasPreservedContext(customScope?)`
Check if a context exists.
```typescript
// Check default tab scope
if (navState.hasPreservedContext()) {
const context = navState.restoreContext();
}
// Check custom scope
if (navState.hasPreservedContext('customer-details')) {
const context = navState.restoreContext('customer-details');
}
```
---
### Navigation Helpers
#### `navigateWithPreservedContext(commands, state, customScope?, extras?)`
Navigate and preserve context in one call.
```typescript
const { success } = await navState.navigateWithPreservedContext(
['/customer/search'],
{ returnUrl: '/reward/cart' },
'customer-flow', // optional customScope
{ queryParams: { foo: 'bar' } } // optional NavigationExtras
);
// Later...
const context = navState.restoreAndClearContext('customer-flow');
```
---
### Cleanup Methods
#### `clearScopeContexts()`
Clear all contexts for the active tab (both default and custom scopes).
```typescript
// Clear all contexts for active tab
const cleared = this.navState.clearScopeContexts();
console.log(`Cleaned up ${cleared} contexts`);
```
**Returns:** Number of contexts cleared
**Note:** This is typically not needed because contexts are **automatically cleaned up when the tab closes**. Use this only for explicit cleanup during the tab's lifecycle.
---
## Usage Patterns
### Pattern 1: Multi-Step Flow with Intermediate Navigations
**Problem:** You need to return to a page after multiple intermediate navigations.
```typescript
// Component A: Start of flow
export class RewardCartComponent {
navState = inject(NavigationStateService);
router = inject(Router);
async selectCustomer() {
// Preserve returnUrl (auto-scoped to tab)
this.navState.preserveContext({
returnUrl: '/reward/cart'
});
await this.router.navigate(['/customer/search']);
}
}
// Component B: Intermediate navigation
export class CustomerSearchComponent {
router = inject(Router);
async viewDetails(customerId: number) {
await this.router.navigate(['/customer/details', customerId]);
// Context still persists!
}
}
// Component C: Another intermediate navigation
export class CustomerDetailsComponent {
router = inject(Router);
async addShippingAddress() {
await this.router.navigate(['/add-shipping-address']);
// Context still persists!
}
}
// Component D: End of flow
export class FinalStepComponent {
navState = inject(NavigationStateService);
router = inject(Router);
async complete() {
// Restore context (auto-scoped to tab) and navigate back
const context = this.navState.restoreAndClearContext<{ returnUrl: string }>();
if (context?.returnUrl) {
await this.router.navigateByUrl(context.returnUrl);
}
}
}
```
---
### Pattern 2: Multiple Flows in Same Tab
Use custom scopes to manage different flows within the same tab.
```typescript
export class ComplexPageComponent {
navState = inject(NavigationStateService);
async startCustomerFlow() {
// Store context for customer flow
this.navState.preserveContext(
{ returnUrl: '/dashboard', step: 1 },
'customer-flow'
);
// Stored in active tab metadata under scope 'customer-flow'
}
async startProductFlow() {
// Store context for product flow
this.navState.preserveContext(
{ returnUrl: '/dashboard', selectedProducts: [1, 2] },
'product-flow'
);
// Stored in active tab metadata under scope 'product-flow'
}
async completeCustomerFlow() {
// Restore from customer flow
const context = this.navState.restoreAndClearContext('customer-flow');
}
async completeProductFlow() {
// Restore from product flow
const context = this.navState.restoreAndClearContext('product-flow');
}
}
```
---
### Pattern 3: Complex Context Data
```typescript
interface CheckoutContext {
returnUrl: string;
selectedItems: number[];
customerId: number;
shippingAddressId?: number;
metadata: {
source: 'reward' | 'checkout';
timestamp: number;
};
}
// Save
navState.preserveContext<CheckoutContext>({
returnUrl: '/reward/cart',
selectedItems: [1, 2, 3],
customerId: 456,
metadata: {
source: 'reward',
timestamp: Date.now()
}
});
// Restore with type safety
const context = navState.restoreAndClearContext<CheckoutContext>();
if (context) {
console.log('Items:', context.selectedItems);
console.log('Customer:', context.customerId);
}
```
---
### Pattern 4: No Manual Cleanup Needed
```typescript
export class TabAwareComponent {
navState = inject(NavigationStateService);
async startFlow() {
// Set context
this.navState.preserveContext({ returnUrl: '/home' });
// No need to clear in ngOnDestroy!
// Context is automatically cleaned up when tab closes
}
// ❌ NOT NEEDED:
// ngOnDestroy() {
// this.navState.clearScopeContexts();
// }
}
```
---
## Architecture
### How It Works
```mermaid
graph LR
A[NavigationStateService] --> B[NavigationContextService]
B --> C[TabService]
C --> D[Tab Metadata Storage]
D --> E[UserStorage Persistence]
style A fill:#e1f5ff
style B fill:#e1f5ff
style C fill:#fff4e1
style D fill:#e8f5e9
style E fill:#f3e5f5
```
1. **Context Storage**: Contexts are stored in **tab metadata** using `TabService`
2. **Automatic Scoping**: Active tab ID determines storage location automatically
3. **Hierarchical Keys**: Scopes are organized as `tab.metadata['navigation-contexts'][customScope]`
4. **Automatic Cleanup**: Contexts removed automatically when tabs close (via tab lifecycle)
5. **Persistent Across Refresh**: Tab metadata persists via UserStorage, so contexts survive page refresh
6. **Map-Based**: One context per scope for clarity
### Tab Metadata Structure
```typescript
// Example: Tab with ID 123
tab.metadata = {
'navigation-contexts': {
'default': {
data: { returnUrl: '/cart', selectedItems: [1, 2, 3] },
createdAt: 1234567890000
},
'customer-details': {
data: { customerId: 42, step: 2 },
createdAt: 1234567891000
},
'product-flow': {
data: { productIds: [100, 200], source: 'recommendation' },
createdAt: 1234567892000
}
},
// ... other tab metadata
}
```
### Storage Layers
```
┌─────────────────────────────────────┐
│ NavigationStateService (Public API)│
└──────────────┬──────────────────────┘
┌─────────────────────────────────────┐
│ NavigationContextService (Storage) │
└──────────────┬──────────────────────┘
┌─────────────────────────────────────┐
│ TabService.patchTabMetadata() │
└──────────────┬──────────────────────┘
┌─────────────────────────────────────┐
│ Tab Metadata Storage (In-Memory) │
└──────────────┬──────────────────────┘
┌─────────────────────────────────────┐
│ UserStorage (SessionStorage) │
│ (Automatic Persistence) │
└─────────────────────────────────────┘
```
### Integration with TabService
This library **requires** `@isa/core/tabs` for automatic tab scoping:
```typescript
import { TabService } from '@isa/core/tabs';
// NavigationContextService uses:
const tabId = this.tabService.activatedTabId(); // Returns: number | null
if (tabId !== null) {
// Store in: tab.metadata['navigation-contexts'][customScope]
this.tabService.patchTabMetadata(tabId, {
'navigation-contexts': {
[customScope]: { data, createdAt }
}
});
}
```
**When no tab is active** (tabId = null):
- Operations throw an error to prevent data loss
- This ensures contexts are always properly scoped to a tab
---
## Migration Guide
### From SessionStorage to Tab Metadata
This library previously used SessionStorage for context persistence. It has been refactored to use tab metadata for better integration with the tab lifecycle and automatic cleanup.
### What Changed
**Storage Location:**
- **Before**: SessionStorage with key `'isa:navigation:context-map'`
- **After**: Tab metadata at `tab.metadata['navigation-contexts']`
**Cleanup:**
- **Before**: Manual cleanup required + automatic expiration after 24 hours
- **After**: Automatic cleanup when tab closes (no manual cleanup needed)
**Scope Keys:**
- **Before**: `"123"` (tab ID), `"123-customer-details"` (tab ID + custom scope)
- **After**: `"default"`, `"customer-details"` (custom scope only, tab ID implicit from storage location)
**TTL Parameter:**
- **Before**: `preserveContext(data, customScope, ttl)` - TTL respected
- **After**: `preserveContext(data, customScope, ttl)` - TTL parameter ignored (kept for compatibility)
### What Stayed the Same
**Public API**: All public methods remain unchanged
**Type Safety**: Full TypeScript support with generics
**Hierarchical Scoping**: Custom scopes still work the same way
**Usage Patterns**: All existing code continues to work
**Persistence**: Contexts still survive page refresh (via TabService UserStorage)
### Benefits of Tab Metadata Approach
1. **Automatic Cleanup**: No need to manually clear contexts or worry about stale data
2. **Better Integration**: Seamless integration with tab lifecycle management
3. **Simpler Mental Model**: Contexts are "owned" by tabs, not global storage
4. **No TTL Management**: Tab lifecycle handles cleanup automatically
5. **Safer**: Impossible to leak contexts across unrelated tabs
### Migration Steps
**No action required!** The public API is unchanged. Your existing code will continue to work:
```typescript
// ✅ This code works exactly the same before and after migration
navState.preserveContext({ returnUrl: '/cart' });
const context = navState.restoreAndClearContext<{ returnUrl: string }>();
```
**Optional: Remove manual cleanup code**
If you have manual cleanup in `ngOnDestroy`, you can safely remove it:
```typescript
// Before (still works, but unnecessary):
ngOnDestroy() {
this.navState.clearScopeContexts();
}
// After (automatic cleanup):
ngOnDestroy() {
// No cleanup needed - tab lifecycle handles it!
}
```
**Note on TTL parameter**
If you were using the TTL parameter, be aware it's now ignored:
```typescript
// Before: TTL respected
navState.preserveContext({ data: 'foo' }, undefined, 60000); // Expires in 1 minute
// After: TTL ignored (context lives until tab closes)
navState.preserveContext({ data: 'foo' }, undefined, 60000); // Ignored parameter
```
---
## Testing
### Mocking NavigationStateService
```typescript
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { NavigationStateService } from '@isa/core/navigation';
import { describe, it, expect, beforeEach, vi } from 'vitest';
describe('MyComponent', () => {
let component: MyComponent;
let fixture: ComponentFixture<MyComponent>;
let navStateMock: any;
beforeEach(async () => {
navStateMock = {
preserveContext: vi.fn(),
restoreContext: vi.fn().mockReturnValue({ returnUrl: '/test' }),
restoreAndClearContext: vi.fn().mockReturnValue({ returnUrl: '/test' }),
clearPreservedContext: vi.fn().mockReturnValue(true),
hasPreservedContext: vi.fn().mockReturnValue(true),
};
await TestBed.configureTestingModule({
imports: [MyComponent],
providers: [
{ provide: NavigationStateService, useValue: navStateMock }
]
}).compileComponents();
fixture = TestBed.createComponent(MyComponent);
component = fixture.componentInstance;
});
it('should preserve context when navigating', async () => {
await component.startFlow();
expect(navStateMock.preserveContext).toHaveBeenCalledWith({
returnUrl: '/reward/cart'
});
});
it('should restore context and navigate back', async () => {
navStateMock.restoreAndClearContext.mockReturnValue({ returnUrl: '/cart' });
await component.complete();
expect(navStateMock.restoreAndClearContext).toHaveBeenCalled();
// Assert navigation occurred
});
});
```
---
## Best Practices
### ✅ Do
- **Use `restoreAndClearContext()`** for automatic cleanup when completing flows
- **Use custom scopes** for multiple concurrent flows in the same tab
- **Leverage type safety** with TypeScript generics (`<T>`)
- **Trust automatic cleanup** - no need to manually clear contexts when tabs close
- **Check for null** when restoring contexts (they may not exist)
### ❌ Don't
- **Don't store large objects** - keep contexts lean (return URLs, IDs, simple flags)
- **Don't use for persistent data** - use NgRx or services for long-lived state
- **Don't rely on TTL** - the TTL parameter is ignored in the current implementation
- **Don't manually clear in ngOnDestroy** - tab lifecycle handles it automatically
- **Don't store sensitive data** - contexts may be visible in browser dev tools
### When to Use Navigation Context
**Good Use Cases:**
- Return URLs for multi-step flows
- Wizard/multi-step form state
- Temporary search filters or selections
- Flow-specific context (customer ID during checkout)
**Bad Use Cases:**
- User preferences (use NgRx or services)
- Authentication tokens (use dedicated auth service)
- Large datasets (use data services with caching)
- Cross-tab communication (use BroadcastChannel or shared services)
---
## Configuration
### Constants
All configuration is in `navigation-context.constants.ts`:
```typescript
// Metadata key for storing contexts in tab metadata
export const NAVIGATION_CONTEXT_METADATA_KEY = 'navigation-contexts';
```
**Note:** Previous SessionStorage constants (`DEFAULT_CONTEXT_TTL`, `CLEANUP_INTERVAL`, `NAVIGATION_CONTEXT_STORAGE_KEY`) have been removed as they are no longer needed with tab metadata storage.
---
## API Reference Summary
| Method | Parameters | Returns | Purpose |
|--------|-----------|---------|---------|
| `preserveContext(state, customScope?)` | state: T, customScope?: string | void | Save context |
| `patchContext(partialState, customScope?)` | partialState: Partial<T>, customScope?: string | void | Merge partial updates |
| `restoreContext(customScope?)` | customScope?: string | T \| null | Get context (keep) |
| `restoreAndClearContext(customScope?)` | customScope?: string | T \| null | Get + remove |
| `clearPreservedContext(customScope?)` | customScope?: string | boolean | Remove context |
| `hasPreservedContext(customScope?)` | customScope?: string | boolean | Check exists |
| `navigateWithPreservedContext(...)` | commands, state, customScope?, extras? | Promise<{success}> | Navigate + preserve |
| `clearScopeContexts()` | none | number | Bulk cleanup (rarely needed) |
---
## Troubleshooting
### Context Not Found After Refresh
**Problem**: Context is `null` after page refresh.
**Solution**: Ensure `TabService` is properly initialized and the tab ID is restored from UserStorage. Contexts rely on tab metadata which persists via UserStorage.
### Context Cleared Unexpectedly
**Problem**: Context disappears before you retrieve it.
**Solution**: Check if you're using `restoreAndClearContext()` multiple times. This method removes the context after retrieval. Use `restoreContext()` if you need to access it multiple times.
### "No active tab" Error
**Problem**: Getting error "No active tab - cannot set navigation context".
**Solution**: Ensure `TabService` has an active tab before using navigation context. This typically happens during app initialization before tabs are ready.
### Context Not Isolated Between Tabs
**Problem**: Contexts from one tab appearing in another.
**Solution**: This should not happen with tab metadata storage. If you see this, it may indicate a TabService issue. Check that `TabService.activatedTabId()` returns the correct tab ID.
---
## Running Tests
```bash
# Run tests
npx nx test core-navigation
# Run tests with coverage
npx nx test core-navigation --coverage.enabled=true
# Run tests without cache (CI)
npx nx test core-navigation --skip-cache
```
**Test Results:**
- 79 tests passing
- 2 test files (navigation-state.service.spec.ts, navigation-context.service.spec.ts)
---
## CI/CD Integration
This library generates JUnit and Cobertura reports for Azure Pipelines:
- **JUnit Report**: `testresults/junit-core-navigation.xml`
- **Cobertura Report**: `coverage/libs/core/navigation/cobertura-coverage.xml`
---
## Contributing
This library follows the ISA Frontend monorepo conventions:
- **Path Alias**: `@isa/core/navigation`
- **Testing Framework**: Vitest with Angular Testing Utilities
- **Code Style**: ESLint + Prettier
- **Test Coverage**: Required for all public APIs
- **Dependencies**: Requires `@isa/core/tabs` for tab scoping
---
## License
Internal ISA Frontend monorepo library.

View File

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

View File

@@ -1,20 +0,0 @@
{
"name": "core-navigation",
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/core/navigation/src",
"prefix": "core",
"projectType": "library",
"tags": ["skip:ci"],
"targets": {
"test": {
"executor": "@nx/vite:test",
"outputs": ["{options.reportsDirectory}"],
"options": {
"reportsDirectory": "../../../coverage/libs/core/navigation"
}
},
"lint": {
"executor": "@nx/eslint:lint"
}
}
}

View File

@@ -1,5 +0,0 @@
export * from './lib/navigation-state.types';
export * from './lib/navigation-state.service';
export * from './lib/navigation-context.types';
export * from './lib/navigation-context.service';
export * from './lib/navigation-context.constants';

View File

@@ -1,22 +0,0 @@
/**
* Constants for navigation context storage in tab metadata.
* Navigation contexts are stored directly in tab metadata instead of sessionStorage,
* providing automatic cleanup when tabs are closed and better integration with the tab system.
*/
/**
* Key used to store navigation contexts in tab metadata.
* Contexts are stored as: tab.metadata[NAVIGATION_CONTEXT_METADATA_KEY][customScope]
*
* @example
* ```typescript
* // Structure in tab metadata:
* tab.metadata = {
* 'navigation-contexts': {
* 'default': { data: { returnUrl: '/cart' }, createdAt: 123 },
* 'customer-details': { data: { customerId: 42 }, createdAt: 456 }
* }
* }
* ```
*/
export const NAVIGATION_CONTEXT_METADATA_KEY = 'navigation-contexts';

View File

@@ -1,668 +0,0 @@
import { TestBed } from '@angular/core/testing';
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import { signal } from '@angular/core';
import { NavigationContextService } from './navigation-context.service';
import { TabService } from '@isa/core/tabs';
import { ReturnUrlContext } from './navigation-context.types';
import { NAVIGATION_CONTEXT_METADATA_KEY } from './navigation-context.constants';
describe('NavigationContextService', () => {
let service: NavigationContextService;
let tabServiceMock: {
activatedTabId: ReturnType<typeof signal<number | null>>;
entityMap: ReturnType<typeof vi.fn>;
patchTabMetadata: ReturnType<typeof vi.fn>;
};
beforeEach(() => {
// Create mock TabService with signals and methods
tabServiceMock = {
activatedTabId: signal<number | null>(null),
entityMap: vi.fn(),
patchTabMetadata: vi.fn(),
};
TestBed.configureTestingModule({
providers: [
NavigationContextService,
{ provide: TabService, useValue: tabServiceMock },
],
});
service = TestBed.inject(NavigationContextService);
});
afterEach(() => {
vi.clearAllMocks();
});
it('should be created', () => {
expect(service).toBeTruthy();
});
describe('setContext', () => {
it('should set context in tab metadata', async () => {
// Arrange
const tabId = 123;
tabServiceMock.activatedTabId.set(tabId);
tabServiceMock.entityMap.mockReturnValue({
[tabId]: {
id: tabId,
name: 'Test Tab',
metadata: {},
},
});
const data: ReturnUrlContext = { returnUrl: '/test-page' };
// Act
await service.setContext(data);
// Assert
expect(tabServiceMock.patchTabMetadata).toHaveBeenCalledWith(
tabId,
expect.objectContaining({
[NAVIGATION_CONTEXT_METADATA_KEY]: expect.objectContaining({
default: expect.objectContaining({
data,
createdAt: expect.any(Number),
}),
}),
}),
);
});
it('should set context with custom scope', async () => {
// Arrange
const tabId = 123;
const customScope = 'customer-details';
tabServiceMock.activatedTabId.set(tabId);
tabServiceMock.entityMap.mockReturnValue({
[tabId]: {
id: tabId,
name: 'Test Tab',
metadata: {},
},
});
const data = { customerId: 42 };
// Act
await service.setContext(data, customScope);
// Assert
expect(tabServiceMock.patchTabMetadata).toHaveBeenCalledWith(
tabId,
expect.objectContaining({
[NAVIGATION_CONTEXT_METADATA_KEY]: expect.objectContaining({
[customScope]: expect.objectContaining({
data,
createdAt: expect.any(Number),
}),
}),
}),
);
});
it('should throw error when no active tab', async () => {
// Arrange
tabServiceMock.activatedTabId.set(null);
// Act & Assert
await expect(service.setContext({ returnUrl: '/test' })).rejects.toThrow(
'No active tab - cannot set navigation context',
);
});
it('should merge with existing contexts', async () => {
// Arrange
const tabId = 123;
tabServiceMock.activatedTabId.set(tabId);
const existingContexts = {
'existing-scope': {
data: { existingData: 'value' },
createdAt: Date.now() - 1000,
},
};
tabServiceMock.entityMap.mockReturnValue({
[tabId]: {
id: tabId,
name: 'Test Tab',
metadata: {
[NAVIGATION_CONTEXT_METADATA_KEY]: existingContexts,
},
},
});
const newData = { returnUrl: '/new-page' };
// Act
await service.setContext(newData);
// Assert
expect(tabServiceMock.patchTabMetadata).toHaveBeenCalledWith(
tabId,
expect.objectContaining({
[NAVIGATION_CONTEXT_METADATA_KEY]: expect.objectContaining({
'existing-scope': existingContexts['existing-scope'],
default: expect.objectContaining({
data: newData,
}),
}),
}),
);
});
it('should accept TTL parameter for backward compatibility', async () => {
// Arrange
const tabId = 123;
tabServiceMock.activatedTabId.set(tabId);
tabServiceMock.entityMap.mockReturnValue({
[tabId]: {
id: tabId,
name: 'Test Tab',
metadata: {},
},
});
// Act
await service.setContext({ returnUrl: '/test' }, undefined, 60000);
// Assert - TTL is ignored but method should still work
expect(tabServiceMock.patchTabMetadata).toHaveBeenCalled();
});
});
describe('getContext', () => {
it('should return null when no active tab', async () => {
// Arrange
tabServiceMock.activatedTabId.set(null);
// Act
const result = await service.getContext();
// Assert
expect(result).toBeNull();
});
it('should return null when context does not exist', async () => {
// Arrange
const tabId = 123;
tabServiceMock.activatedTabId.set(tabId);
tabServiceMock.entityMap.mockReturnValue({
[tabId]: {
id: tabId,
name: 'Test Tab',
metadata: {},
},
});
// Act
const result = await service.getContext();
// Assert
expect(result).toBeNull();
});
it('should retrieve context from default scope', async () => {
// Arrange
const tabId = 123;
const data: ReturnUrlContext = { returnUrl: '/test-page' };
tabServiceMock.activatedTabId.set(tabId);
tabServiceMock.entityMap.mockReturnValue({
[tabId]: {
id: tabId,
name: 'Test Tab',
metadata: {
[NAVIGATION_CONTEXT_METADATA_KEY]: {
default: {
data,
createdAt: Date.now(),
},
},
},
},
});
// Act
const result = await service.getContext<ReturnUrlContext>();
// Assert
expect(result).toEqual(data);
});
it('should retrieve context from custom scope', async () => {
// Arrange
const tabId = 123;
const customScope = 'checkout-flow';
const data = { step: 2, productId: 456 };
tabServiceMock.activatedTabId.set(tabId);
tabServiceMock.entityMap.mockReturnValue({
[tabId]: {
id: tabId,
name: 'Test Tab',
metadata: {
[NAVIGATION_CONTEXT_METADATA_KEY]: {
[customScope]: {
data,
createdAt: Date.now(),
},
},
},
},
});
// Act
const result = await service.getContext(customScope);
// Assert
expect(result).toEqual(data);
});
it('should return null when tab not found', async () => {
// Arrange
const tabId = 123;
tabServiceMock.activatedTabId.set(tabId);
tabServiceMock.entityMap.mockReturnValue({});
// Act
const result = await service.getContext();
// Assert
expect(result).toBeNull();
});
it('should handle invalid metadata gracefully', async () => {
// Arrange
const tabId = 123;
tabServiceMock.activatedTabId.set(tabId);
tabServiceMock.entityMap.mockReturnValue({
[tabId]: {
id: tabId,
name: 'Test Tab',
metadata: {
[NAVIGATION_CONTEXT_METADATA_KEY]: 'invalid', // Invalid type
},
},
});
// Act
const result = await service.getContext();
// Assert
expect(result).toBeNull();
});
});
describe('getAndClearContext', () => {
it('should return null when no active tab', async () => {
// Arrange
tabServiceMock.activatedTabId.set(null);
// Act
const result = await service.getAndClearContext();
// Assert
expect(result).toBeNull();
});
it('should retrieve and remove context from default scope', async () => {
// Arrange
const tabId = 123;
const data: ReturnUrlContext = { returnUrl: '/test-page' };
tabServiceMock.activatedTabId.set(tabId);
tabServiceMock.entityMap.mockReturnValue({
[tabId]: {
id: tabId,
name: 'Test Tab',
metadata: {
[NAVIGATION_CONTEXT_METADATA_KEY]: {
default: {
data,
createdAt: Date.now(),
},
},
},
},
});
// Act
const result = await service.getAndClearContext<ReturnUrlContext>();
// Assert
expect(result).toEqual(data);
expect(tabServiceMock.patchTabMetadata).toHaveBeenCalledWith(tabId, {
[NAVIGATION_CONTEXT_METADATA_KEY]: {},
});
});
it('should retrieve and remove context from custom scope', async () => {
// Arrange
const tabId = 123;
const customScope = 'wizard-flow';
const data = { currentStep: 3 };
const otherScopeData = { otherData: 'value' };
tabServiceMock.activatedTabId.set(tabId);
tabServiceMock.entityMap.mockReturnValue({
[tabId]: {
id: tabId,
name: 'Test Tab',
metadata: {
[NAVIGATION_CONTEXT_METADATA_KEY]: {
[customScope]: {
data,
createdAt: Date.now(),
},
'other-scope': {
data: otherScopeData,
createdAt: Date.now(),
},
},
},
},
});
// Act
const result = await service.getAndClearContext(customScope);
// Assert
expect(result).toEqual(data);
expect(tabServiceMock.patchTabMetadata).toHaveBeenCalledWith(tabId, {
[NAVIGATION_CONTEXT_METADATA_KEY]: {
'other-scope': expect.objectContaining({
data: otherScopeData,
}),
},
});
});
it('should return null when context not found', async () => {
// Arrange
const tabId = 123;
tabServiceMock.activatedTabId.set(tabId);
tabServiceMock.entityMap.mockReturnValue({
[tabId]: {
id: tabId,
name: 'Test Tab',
metadata: {},
},
});
// Act
const result = await service.getAndClearContext();
// Assert
expect(result).toBeNull();
expect(tabServiceMock.patchTabMetadata).not.toHaveBeenCalled();
});
});
describe('clearContext', () => {
it('should return true when context exists and is cleared', async () => {
// Arrange
const tabId = 123;
const data = { returnUrl: '/test' };
tabServiceMock.activatedTabId.set(tabId);
tabServiceMock.entityMap.mockReturnValue({
[tabId]: {
id: tabId,
name: 'Test Tab',
metadata: {
[NAVIGATION_CONTEXT_METADATA_KEY]: {
default: { data, createdAt: Date.now() },
},
},
},
});
// Act
const result = await service.clearContext();
// Assert
expect(result).toBe(true);
});
it('should return false when context not found', async () => {
// Arrange
const tabId = 123;
tabServiceMock.activatedTabId.set(tabId);
tabServiceMock.entityMap.mockReturnValue({
[tabId]: {
id: tabId,
name: 'Test Tab',
metadata: {},
},
});
// Act
const result = await service.clearContext();
// Assert
expect(result).toBe(false);
});
});
describe('clearScope', () => {
it('should clear all contexts for active tab', async () => {
// Arrange
const tabId = 123;
const contexts = {
default: { data: { url: '/test' }, createdAt: Date.now() },
'scope-1': { data: { value: 1 }, createdAt: Date.now() },
'scope-2': { data: { value: 2 }, createdAt: Date.now() },
};
tabServiceMock.activatedTabId.set(tabId);
tabServiceMock.entityMap.mockReturnValue({
[tabId]: {
id: tabId,
name: 'Test Tab',
metadata: {
[NAVIGATION_CONTEXT_METADATA_KEY]: contexts,
},
},
});
// Act
const clearedCount = await service.clearScope();
// Assert
expect(clearedCount).toBe(3);
expect(tabServiceMock.patchTabMetadata).toHaveBeenCalledWith(tabId, {
[NAVIGATION_CONTEXT_METADATA_KEY]: {},
});
});
it('should return 0 when no contexts exist', async () => {
// Arrange
const tabId = 123;
tabServiceMock.activatedTabId.set(tabId);
tabServiceMock.entityMap.mockReturnValue({
[tabId]: {
id: tabId,
name: 'Test Tab',
metadata: {},
},
});
// Act
const clearedCount = await service.clearScope();
// Assert
expect(clearedCount).toBe(0);
expect(tabServiceMock.patchTabMetadata).not.toHaveBeenCalled();
});
it('should return 0 when no active tab', async () => {
// Arrange
tabServiceMock.activatedTabId.set(null);
// Act
const clearedCount = await service.clearScope();
// Assert
expect(clearedCount).toBe(0);
});
});
describe('clearAll', () => {
it('should clear all contexts for active tab', async () => {
// Arrange
const tabId = 123;
const contexts = {
default: { data: { url: '/test' }, createdAt: Date.now() },
};
tabServiceMock.activatedTabId.set(tabId);
tabServiceMock.entityMap.mockReturnValue({
[tabId]: {
id: tabId,
name: 'Test Tab',
metadata: {
[NAVIGATION_CONTEXT_METADATA_KEY]: contexts,
},
},
});
// Act
await service.clearAll();
// Assert
expect(tabServiceMock.patchTabMetadata).toHaveBeenCalledWith(tabId, {
[NAVIGATION_CONTEXT_METADATA_KEY]: {},
});
});
});
describe('hasContext', () => {
it('should return true when context exists', async () => {
// Arrange
const tabId = 123;
const data = { returnUrl: '/test' };
tabServiceMock.activatedTabId.set(tabId);
tabServiceMock.entityMap.mockReturnValue({
[tabId]: {
id: tabId,
name: 'Test Tab',
metadata: {
[NAVIGATION_CONTEXT_METADATA_KEY]: {
default: { data, createdAt: Date.now() },
},
},
},
});
// Act
const result = await service.hasContext();
// Assert
expect(result).toBe(true);
});
it('should return false when context does not exist', async () => {
// Arrange
const tabId = 123;
tabServiceMock.activatedTabId.set(tabId);
tabServiceMock.entityMap.mockReturnValue({
[tabId]: {
id: tabId,
name: 'Test Tab',
metadata: {},
},
});
// Act
const result = await service.hasContext();
// Assert
expect(result).toBe(false);
});
it('should check custom scope', async () => {
// Arrange
const tabId = 123;
const customScope = 'wizard';
const data = { step: 1 };
tabServiceMock.activatedTabId.set(tabId);
tabServiceMock.entityMap.mockReturnValue({
[tabId]: {
id: tabId,
name: 'Test Tab',
metadata: {
[NAVIGATION_CONTEXT_METADATA_KEY]: {
[customScope]: { data, createdAt: Date.now() },
},
},
},
});
// Act
const result = await service.hasContext(customScope);
// Assert
expect(result).toBe(true);
});
});
describe('getContextCount', () => {
it('should return total number of contexts for active tab', async () => {
// Arrange
const tabId = 123;
const contexts = {
default: { data: { url: '/test' }, createdAt: Date.now() },
'scope-1': { data: { value: 1 }, createdAt: Date.now() },
'scope-2': { data: { value: 2 }, createdAt: Date.now() },
};
tabServiceMock.activatedTabId.set(tabId);
tabServiceMock.entityMap.mockReturnValue({
[tabId]: {
id: tabId,
name: 'Test Tab',
metadata: {
[NAVIGATION_CONTEXT_METADATA_KEY]: contexts,
},
},
});
// Act
const count = await service.getContextCount();
// Assert
expect(count).toBe(3);
});
it('should return 0 when no contexts exist', async () => {
// Arrange
const tabId = 123;
tabServiceMock.activatedTabId.set(tabId);
tabServiceMock.entityMap.mockReturnValue({
[tabId]: {
id: tabId,
name: 'Test Tab',
metadata: {},
},
});
// Act
const count = await service.getContextCount();
// Assert
expect(count).toBe(0);
});
it('should return 0 when no active tab', async () => {
// Arrange
tabServiceMock.activatedTabId.set(null);
// Act
const count = await service.getContextCount();
// Assert
expect(count).toBe(0);
});
});
});

View File

@@ -1,442 +0,0 @@
import { Injectable, inject } from '@angular/core';
import { TabService } from '@isa/core/tabs';
import { logger } from '@isa/core/logging';
import {
NavigationContext,
NavigationContextData,
NavigationContextsMetadataSchema,
} from './navigation-context.types';
import { NAVIGATION_CONTEXT_METADATA_KEY } from './navigation-context.constants';
/**
* Service for managing navigation context using tab metadata storage.
*
* This service provides a type-safe approach to preserving navigation state
* across intermediate navigations, solving the problem of lost router state
* in multi-step flows.
*
* Key Features:
* - Stores contexts in tab metadata (automatic cleanup when tab closes)
* - Type-safe with Zod validation
* - Scoped to individual tabs (no cross-tab pollution)
* - Simple API with hierarchical scoping support
*
* Storage Architecture:
* - Contexts stored at: `tab.metadata[NAVIGATION_CONTEXT_METADATA_KEY]`
* - Structure: `{ [customScope]: { data, createdAt } }`
* - No manual cleanup needed (handled by tab lifecycle)
*
* @example
* ```typescript
* // Start of flow - preserve context (auto-scoped to active tab)
* contextService.setContext({
* returnUrl: '/original-page',
* customerId: 123
* });
*
* // ... intermediate navigations happen ...
*
* // End of flow - restore and cleanup
* const context = contextService.getAndClearContext<{ returnUrl: string }>();
* if (context?.returnUrl) {
* await router.navigateByUrl(context.returnUrl);
* }
* ```
*/
@Injectable({ providedIn: 'root' })
export class NavigationContextService {
readonly #tabService = inject(TabService);
readonly #log = logger(() => ({ module: 'navigation-context' }));
/**
* Get the navigation contexts map from tab metadata.
*
* @param tabId The tab ID to get contexts for
* @returns Record of scope keys to contexts, or empty object if not found
*/
#getContextsMap(tabId: number): Record<string, NavigationContext> {
const tab = this.#tabService.entityMap()[tabId];
if (!tab) {
this.#log.debug('Tab not found', () => ({ tabId }));
return {};
}
const contextsMap = tab.metadata[NAVIGATION_CONTEXT_METADATA_KEY];
if (!contextsMap) {
return {};
}
// Validate with Zod schema
const result = NavigationContextsMetadataSchema.safeParse(contextsMap);
if (!result.success) {
this.#log.warn('Invalid contexts map in tab metadata', () => ({
tabId,
validationErrors: result.error.errors,
}));
return {};
}
return result.data as Record<string, NavigationContext>;
}
/**
* Save the navigation contexts map to tab metadata.
*
* @param tabId The tab ID to save contexts to
* @param contextsMap The contexts map to save
*/
#saveContextsMap(
tabId: number,
contextsMap: Record<string, NavigationContext>,
): void {
this.#tabService.patchTabMetadata(tabId, {
[NAVIGATION_CONTEXT_METADATA_KEY]: contextsMap,
});
}
/**
* Set a context in the active tab's metadata.
*
* Creates or overwrites a navigation context and persists it to tab metadata.
* The context will automatically be cleaned up when the tab is closed.
*
* @template T The type of data being stored in the context
* @param data The navigation data to preserve
* @param customScope Optional custom scope (defaults to 'default')
* @param _ttl Optional TTL parameter (kept for API compatibility but ignored)
*
* @example
* ```typescript
* // Set context for default scope
* contextService.setContext({ returnUrl: '/products', selectedIds: [1, 2, 3] });
*
* // Set context for custom scope
* contextService.setContext({ customerId: 42 }, 'customer-details');
* ```
*/
async setContext<T extends NavigationContextData>(
data: T,
customScope?: string,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_ttl?: number, // Kept for API compatibility but ignored
): Promise<void> {
const tabId = this.#tabService.activatedTabId();
if (tabId === null) {
throw new Error('No active tab - cannot set navigation context');
}
const scopeKey = customScope || 'default';
const context: NavigationContext = {
data,
createdAt: Date.now(),
};
const contextsMap = this.#getContextsMap(tabId);
contextsMap[scopeKey] = context;
this.#saveContextsMap(tabId, contextsMap);
this.#log.debug('Context set in tab metadata', () => ({
tabId,
scopeKey,
dataKeys: Object.keys(data),
totalContexts: Object.keys(contextsMap).length,
}));
}
/**
* Patch a context in the active tab's metadata.
*
* Merges partial data with the existing context, preserving unspecified properties.
* Properties explicitly set to `undefined` will be removed from the context.
* If the context doesn't exist, creates a new one (behaves like setContext).
*
* @template T The type of data being patched
* @param partialData The partial navigation data to merge
* @param customScope Optional custom scope (defaults to 'default')
*
* @example
* ```typescript
* // Clear one property while preserving others
* contextService.patchContext({ autoTriggerContinueFn: undefined }, 'select-customer');
*
* // Update one property while preserving others
* contextService.patchContext({ selectedTab: 'details' }, 'customer-flow');
* ```
*/
async patchContext<T extends NavigationContextData>(
partialData: Partial<T>,
customScope?: string,
): Promise<void> {
const tabId = this.#tabService.activatedTabId();
if (tabId === null) {
throw new Error('No active tab - cannot patch navigation context');
}
const scopeKey = customScope || 'default';
const existingContext = await this.getContext<T>(customScope);
const mergedData = {
...(existingContext ?? {}),
...partialData,
};
// Remove properties explicitly set to undefined
const removedKeys: string[] = [];
Object.keys(mergedData).forEach((key) => {
if (mergedData[key] === undefined) {
removedKeys.push(key);
delete mergedData[key];
}
});
const contextsMap = this.#getContextsMap(tabId);
const context: NavigationContext = {
data: mergedData,
createdAt:
existingContext && contextsMap[scopeKey]
? contextsMap[scopeKey].createdAt
: Date.now(),
};
contextsMap[scopeKey] = context;
this.#saveContextsMap(tabId, contextsMap);
this.#log.debug('Context patched in tab metadata', () => ({
tabId,
scopeKey,
patchedKeys: Object.keys(partialData),
removedKeys,
totalDataKeys: Object.keys(mergedData),
wasUpdate: existingContext !== null,
totalContexts: Object.keys(contextsMap).length,
}));
}
/**
* Get a context from the active tab's metadata without removing it.
*
* Retrieves a preserved navigation context by scope.
*
* @template T The expected type of the context data
* @param customScope Optional custom scope (defaults to 'default')
* @returns The context data, or null if not found
*
* @example
* ```typescript
* // Get context for default scope
* const context = contextService.getContext<{ returnUrl: string }>();
*
* // Get context for custom scope
* const context = contextService.getContext<{ customerId: number }>('customer-details');
* ```
*/
async getContext<T extends NavigationContextData = NavigationContextData>(
customScope?: string,
): Promise<T | null> {
const tabId = this.#tabService.activatedTabId();
if (tabId === null) {
this.#log.debug('No active tab - cannot get context');
return null;
}
const scopeKey = customScope || 'default';
const contextsMap = this.#getContextsMap(tabId);
const context = contextsMap[scopeKey];
if (!context) {
this.#log.debug('Context not found', () => ({ tabId, scopeKey }));
return null;
}
this.#log.debug('Context retrieved', () => ({
tabId,
scopeKey,
dataKeys: Object.keys(context.data),
}));
return context.data as T;
}
/**
* Get a context from the active tab's metadata and remove it.
*
* Retrieves a preserved navigation context and removes it from the metadata.
* Use this when completing a flow to clean up automatically.
*
* @template T The expected type of the context data
* @param customScope Optional custom scope (defaults to 'default')
* @returns The context data, or null if not found
*
* @example
* ```typescript
* // Get and clear context for default scope
* const context = contextService.getAndClearContext<{ returnUrl: string }>();
* if (context?.returnUrl) {
* await router.navigateByUrl(context.returnUrl);
* }
*
* // Get and clear context for custom scope
* const context = contextService.getAndClearContext<{ customerId: number }>('customer-details');
* ```
*/
async getAndClearContext<
T extends NavigationContextData = NavigationContextData,
>(customScope?: string): Promise<T | null> {
const tabId = this.#tabService.activatedTabId();
if (tabId === null) {
this.#log.debug('No active tab - cannot get and clear context');
return null;
}
const scopeKey = customScope || 'default';
const contextsMap = this.#getContextsMap(tabId);
const context = contextsMap[scopeKey];
if (!context) {
this.#log.debug('Context not found for clearing', () => ({
tabId,
scopeKey,
}));
return null;
}
// Remove from map
delete contextsMap[scopeKey];
this.#saveContextsMap(tabId, contextsMap);
this.#log.debug('Context retrieved and cleared', () => ({
tabId,
scopeKey,
dataKeys: Object.keys(context.data),
remainingContexts: Object.keys(contextsMap).length,
}));
return context.data as T;
}
/**
* Clear a specific context from the active tab's metadata.
*
* Removes a context without returning its data.
* Useful for explicit cleanup without needing the data.
*
* @param customScope Optional custom scope (defaults to 'default')
* @returns true if context was found and cleared, false otherwise
*
* @example
* ```typescript
* // Clear context for default scope
* contextService.clearContext();
*
* // Clear context for custom scope
* contextService.clearContext('customer-details');
* ```
*/
async clearContext(customScope?: string): Promise<boolean> {
const result = await this.getAndClearContext(customScope);
return result !== null;
}
/**
* Clear all contexts for the active tab.
*
* Removes all contexts from the active tab's metadata.
* Useful for cleanup when a workflow is cancelled or completed.
*
* @returns The number of contexts cleared
*
* @example
* ```typescript
* // Clear all contexts for active tab
* const cleared = contextService.clearScope();
* console.log(`Cleared ${cleared} contexts`);
* ```
*/
async clearScope(): Promise<number> {
const tabId = this.#tabService.activatedTabId();
if (tabId === null) {
this.#log.warn('Cannot clear scope: no active tab');
return 0;
}
const contextsMap = this.#getContextsMap(tabId);
const contextCount = Object.keys(contextsMap).length;
if (contextCount === 0) {
return 0;
}
// Clear entire metadata key
this.#tabService.patchTabMetadata(tabId, {
[NAVIGATION_CONTEXT_METADATA_KEY]: {},
});
this.#log.debug('Tab scope cleared', () => ({
tabId,
clearedCount: contextCount,
}));
return contextCount;
}
/**
* Clear all contexts from the active tab (alias for clearScope).
*
* This method is kept for backward compatibility with the previous API.
* It clears all contexts for the active tab only, not globally.
*
* @example
* ```typescript
* contextService.clearAll();
* ```
*/
async clearAll(): Promise<void> {
await this.clearScope();
this.#log.debug('All contexts cleared for active tab');
}
/**
* Check if a context exists for the active tab.
*
* @param customScope Optional custom scope (defaults to 'default')
* @returns true if context exists, false otherwise
*
* @example
* ```typescript
* // Check default scope
* if (contextService.hasContext()) {
* const context = contextService.getContext();
* }
*
* // Check custom scope
* if (contextService.hasContext('customer-details')) {
* const context = contextService.getContext('customer-details');
* }
* ```
*/
async hasContext(customScope?: string): Promise<boolean> {
const context = await this.getContext(customScope);
return context !== null;
}
/**
* Get the current context count for the active tab (for debugging/monitoring).
*
* @returns The total number of contexts in the active tab's metadata
*
* @example
* ```typescript
* const count = await contextService.getContextCount();
* console.log(`Active tab has ${count} contexts`);
* ```
*/
async getContextCount(): Promise<number> {
const tabId = this.#tabService.activatedTabId();
if (tabId === null) {
return 0;
}
const contextsMap = this.#getContextsMap(tabId);
return Object.keys(contextsMap).length;
}
}

View File

@@ -1,102 +0,0 @@
import { z } from 'zod';
/**
* Base interface for navigation context data.
* Extend this interface for type-safe context preservation.
*
* @example
* ```typescript
* interface MyFlowContext extends NavigationContextData {
* returnUrl: string;
* selectedItems: number[];
* }
* ```
*/
export interface NavigationContextData {
[key: string]: unknown;
}
/**
* Navigation context stored in tab metadata.
* Represents a single preserved navigation state with metadata.
* Stored at: tab.metadata[NAVIGATION_CONTEXT_METADATA_KEY][customScope]
*/
export interface NavigationContext {
/** The preserved navigation state/data */
data: NavigationContextData;
/** Timestamp when context was created (for debugging and monitoring) */
createdAt: number;
/**
* Optional expiration timestamp (reserved for future TTL implementation)
* @deprecated Currently unused - contexts are cleaned up automatically when tabs close
*/
expiresAt?: number;
}
/**
* Zod schema for navigation context data validation.
*/
export const NavigationContextDataSchema = z.record(z.string(), z.unknown());
/**
* Zod schema for navigation context validation.
*/
export const NavigationContextSchema = z.object({
data: NavigationContextDataSchema,
createdAt: z.number().positive(),
expiresAt: z.number().positive().optional(),
});
/**
* Zod schema for navigation contexts stored in tab metadata.
* Structure: { [customScope: string]: NavigationContext }
*
* @example
* ```typescript
* {
* "default": { data: { returnUrl: '/cart' }, createdAt: 123, expiresAt: 456 },
* "customer-details": { data: { customerId: 42 }, createdAt: 123 }
* }
* ```
*/
export const NavigationContextsMetadataSchema = z.record(
z.string(),
NavigationContextSchema
);
/**
* Common navigation context for "return URL" pattern.
* Used when navigating through a flow and needing to return to the original location.
*
* @example
* ```typescript
* navContextService.preserveContext({
* returnUrl: '/original-page'
* });
* ```
*/
export interface ReturnUrlContext extends NavigationContextData {
returnUrl: string;
}
/**
* Extended context with additional flow metadata.
* Useful for complex multi-step flows that need to preserve additional state.
*
* @example
* ```typescript
* interface CheckoutFlowContext extends FlowContext {
* returnUrl: string;
* selectedProductIds: number[];
* shippingAddressId?: number;
* }
* ```
*/
export interface FlowContext extends NavigationContextData {
/** Step identifier for multi-step flows */
currentStep?: string;
/** Total number of steps (if known) */
totalSteps?: number;
/** Flow-specific metadata */
metadata?: Record<string, unknown>;
}

View File

@@ -1,227 +0,0 @@
import { TestBed } from '@angular/core/testing';
import { Location } from '@angular/common';
import { Router } from '@angular/router';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { NavigationStateService } from './navigation-state.service';
import { NavigationContextService } from './navigation-context.service';
import { ReturnUrlContext } from './navigation-context.types';
describe('NavigationStateService', () => {
let service: NavigationStateService;
let locationMock: { getState: ReturnType<typeof vi.fn> };
let routerMock: { navigate: ReturnType<typeof vi.fn> };
let contextServiceMock: {
setContext: ReturnType<typeof vi.fn>;
getContext: ReturnType<typeof vi.fn>;
getAndClearContext: ReturnType<typeof vi.fn>;
clearContext: ReturnType<typeof vi.fn>;
hasContext: ReturnType<typeof vi.fn>;
clearScope: ReturnType<typeof vi.fn>;
};
beforeEach(() => {
locationMock = {
getState: vi.fn(),
};
routerMock = {
navigate: vi.fn(),
};
contextServiceMock = {
setContext: vi.fn().mockResolvedValue(undefined),
getContext: vi.fn().mockResolvedValue(null),
getAndClearContext: vi.fn().mockResolvedValue(null),
clearContext: vi.fn().mockResolvedValue(false),
hasContext: vi.fn().mockResolvedValue(false),
clearScope: vi.fn().mockResolvedValue(0),
};
TestBed.configureTestingModule({
providers: [
NavigationStateService,
{ provide: Location, useValue: locationMock },
{ provide: Router, useValue: routerMock },
{ provide: NavigationContextService, useValue: contextServiceMock },
],
});
service = TestBed.inject(NavigationStateService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
// Context Preservation Methods Tests
describe('preserveContext', () => {
it('should call contextService.setContext with correct parameters', async () => {
const data: ReturnUrlContext = { returnUrl: '/test-page' };
const scopeKey = 'process-123';
await service.preserveContext(data, scopeKey);
expect(contextServiceMock.setContext).toHaveBeenCalledWith(data, scopeKey);
});
it('should work without scope key', async () => {
const data: ReturnUrlContext = { returnUrl: '/test-page' };
await service.preserveContext(data);
expect(contextServiceMock.setContext).toHaveBeenCalledWith(data, undefined);
});
});
describe('restoreContext', () => {
it('should call contextService.getContext with correct parameters', async () => {
const expectedData: ReturnUrlContext = { returnUrl: '/test-page' };
contextServiceMock.getContext.mockResolvedValue(expectedData);
const result = await service.restoreContext<ReturnUrlContext>('scope-123');
expect(contextServiceMock.getContext).toHaveBeenCalledWith('scope-123');
expect(result).toEqual(expectedData);
});
it('should return null when context not found', async () => {
contextServiceMock.getContext.mockResolvedValue(null);
const result = await service.restoreContext();
expect(result).toBeNull();
});
it('should work without parameters', async () => {
const expectedData: ReturnUrlContext = { returnUrl: '/test-page' };
contextServiceMock.getContext.mockResolvedValue(expectedData);
const result = await service.restoreContext<ReturnUrlContext>();
expect(contextServiceMock.getContext).toHaveBeenCalledWith(undefined);
expect(result).toEqual(expectedData);
});
});
describe('restoreAndClearContext', () => {
it('should call contextService.getAndClearContext with correct parameters', async () => {
const expectedData: ReturnUrlContext = { returnUrl: '/test-page' };
contextServiceMock.getAndClearContext.mockResolvedValue(expectedData);
const result = await service.restoreAndClearContext<ReturnUrlContext>('scope-123');
expect(contextServiceMock.getAndClearContext).toHaveBeenCalledWith('scope-123');
expect(result).toEqual(expectedData);
});
it('should return null when context not found', async () => {
contextServiceMock.getAndClearContext.mockResolvedValue(null);
const result = await service.restoreAndClearContext();
expect(result).toBeNull();
});
});
describe('clearPreservedContext', () => {
it('should call contextService.clearContext and return result', async () => {
contextServiceMock.clearContext.mockResolvedValue(true);
const result = await service.clearPreservedContext('scope-123');
expect(contextServiceMock.clearContext).toHaveBeenCalledWith('scope-123');
expect(result).toBe(true);
});
it('should return false when context not found', async () => {
contextServiceMock.clearContext.mockResolvedValue(false);
const result = await service.clearPreservedContext();
expect(result).toBe(false);
});
});
describe('hasPreservedContext', () => {
it('should call contextService.hasContext and return result', async () => {
contextServiceMock.hasContext.mockResolvedValue(true);
const result = await service.hasPreservedContext('scope-123');
expect(contextServiceMock.hasContext).toHaveBeenCalledWith('scope-123');
expect(result).toBe(true);
});
it('should return false when context not found', async () => {
contextServiceMock.hasContext.mockResolvedValue(false);
const result = await service.hasPreservedContext();
expect(result).toBe(false);
});
});
describe('navigateWithPreservedContext', () => {
it('should preserve context and navigate', async () => {
const data: ReturnUrlContext = { returnUrl: '/reward/cart', customerId: 123 };
const commands = ['/customer/search'];
const scopeKey = 'process-123';
routerMock.navigate.mockResolvedValue(true);
const result = await service.navigateWithPreservedContext(commands, data, scopeKey);
expect(contextServiceMock.setContext).toHaveBeenCalledWith(data, scopeKey);
expect(routerMock.navigate).toHaveBeenCalledWith(commands, {
state: data,
});
expect(result).toEqual({ success: true });
});
it('should merge navigation extras', async () => {
const data: ReturnUrlContext = { returnUrl: '/test' };
const commands = ['/page'];
const extras = { queryParams: { foo: 'bar' } };
routerMock.navigate.mockResolvedValue(true);
await service.navigateWithPreservedContext(commands, data, undefined, extras);
expect(routerMock.navigate).toHaveBeenCalledWith(commands, {
queryParams: { foo: 'bar' },
state: data,
});
});
it('should return false when navigation fails', async () => {
const data: ReturnUrlContext = { returnUrl: '/test' };
const commands = ['/page'];
routerMock.navigate.mockResolvedValue(false);
const result = await service.navigateWithPreservedContext(commands, data);
expect(result).toEqual({ success: false });
});
});
describe('clearScopeContexts', () => {
it('should call contextService.clearScope and return count', async () => {
contextServiceMock.clearScope.mockResolvedValue(3);
const result = await service.clearScopeContexts();
expect(contextServiceMock.clearScope).toHaveBeenCalled();
expect(result).toBe(3);
});
it('should return 0 when no contexts cleared', async () => {
contextServiceMock.clearScope.mockResolvedValue(0);
const result = await service.clearScopeContexts();
expect(result).toBe(0);
});
});
});

View File

@@ -1,331 +0,0 @@
import { Injectable, inject } from '@angular/core';
import { Router, NavigationExtras } from '@angular/router';
import { NavigationContextService } from './navigation-context.service';
import { NavigationContextData } from './navigation-context.types';
/**
* Service for managing navigation context preservation across multi-step flows.
*
* This service provides automatic context preservation using tab metadata,
* allowing navigation state to survive intermediate navigations. Contexts are
* automatically scoped to the active tab and cleaned up when the tab closes.
*
* ## Context Preservation for Multi-Step Flows
*
* @example
* ```typescript
* // Start of flow - preserve context (automatically scoped to active tab)
* await navigationStateService.preserveContext({
* returnUrl: '/reward/cart',
* customerId: 123
* });
*
* // ... multiple intermediate navigations happen ...
* await router.navigate(['/customer/details']);
* await router.navigate(['/add-shipping-address']);
*
* // End of flow - restore and cleanup (auto-scoped to active tab)
* const context = await navigationStateService.restoreAndClearContext<{ returnUrl: string }>();
* if (context?.returnUrl) {
* await router.navigateByUrl(context.returnUrl);
* }
* ```
*
* ## Simplified Navigation with Context
*
* @example
* ```typescript
* // Navigate and preserve context in one call
* const { success } = await navigationStateService.navigateWithPreservedContext(
* ['/customer/search'],
* { returnUrl: '/reward/cart' }
* );
*
* // Later, restore and navigate back
* const context = await navigationStateService.restoreAndClearContext<{ returnUrl: string }>();
* if (context?.returnUrl) {
* await router.navigateByUrl(context.returnUrl);
* }
* ```
*
* ## Automatic Tab Cleanup
*
* @example
* ```typescript
* ngOnDestroy() {
* // Clean up all contexts when tab closes (auto-uses active tab ID)
* this.navigationStateService.clearScopeContexts();
* }
* ```
*
* Key Features:
* - ✅ Automatic tab scoping using TabService
* - ✅ Stored in tab metadata (automatic cleanup when tab closes)
* - ✅ Type-safe with TypeScript generics and Zod validation
* - ✅ Automatic cleanup with restoreAndClearContext()
* - ✅ Support for multiple custom scopes per tab
* - ✅ No manual expiration management needed
* - ✅ Platform-agnostic (works with SSR)
*/
@Injectable({ providedIn: 'root' })
export class NavigationStateService {
readonly #router = inject(Router);
readonly #contextService = inject(NavigationContextService);
// Context Preservation Methods
/**
* Preserve navigation state for multi-step flows.
*
* This method stores navigation context in tab metadata, allowing it to
* persist across intermediate navigations within a flow. Contexts are automatically
* scoped to the active tab, with optional custom scope for different flows.
*
* Use this when starting a flow that will have intermediate navigations
* before returning to the original location.
*
* @template T The type of state data being preserved
* @param state The navigation state to preserve
* @param customScope Optional custom scope within the tab (e.g., 'customer-details')
*
* @example
* ```typescript
* // Preserve context for default tab scope
* await navigationStateService.preserveContext({ returnUrl: '/products' });
*
* // Preserve context for custom scope within tab
* await navigationStateService.preserveContext({ customerId: 42 }, 'customer-details');
*
* // ... multiple intermediate navigations ...
*
* // Restore at end of flow
* const context = await navigationStateService.restoreContext<{ returnUrl: string }>();
* if (context?.returnUrl) {
* await router.navigateByUrl(context.returnUrl);
* }
* ```
*/
async preserveContext<T extends NavigationContextData>(
state: T,
customScope?: string,
): Promise<void> {
await this.#contextService.setContext(state, customScope);
}
/**
* Patch preserved navigation state.
*
* Merges partial state with existing preserved context, keeping unspecified properties intact.
* This is useful when you need to update or clear specific properties without replacing
* the entire context. Properties set to `undefined` will be removed.
*
* Use cases:
* - Clear a trigger flag while preserving return URL
* - Update one property in a multi-property context
* - Remove specific properties from context
*
* @template T The type of state data being patched
* @param partialState The partial state to merge (properties set to undefined will be removed)
* @param customScope Optional custom scope within the tab
*
* @example
* ```typescript
* // Clear the autoTriggerContinueFn flag while preserving returnUrl
* await navigationStateService.patchContext(
* { autoTriggerContinueFn: undefined },
* 'select-customer'
* );
*
* // Update selectedTab while keeping other properties
* await navigationStateService.patchContext(
* { selectedTab: 'rewards' },
* 'customer-flow'
* );
*
* // Add a new property to existing context
* await navigationStateService.patchContext(
* { shippingAddressId: 123 },
* 'checkout-flow'
* );
* ```
*/
async patchContext<T extends NavigationContextData>(
partialState: Partial<T>,
customScope?: string,
): Promise<void> {
await this.#contextService.patchContext(partialState, customScope);
}
/**
* Restore preserved navigation state.
*
* Retrieves a previously preserved navigation context for the active tab scope,
* or a custom scope if specified.
*
* This method does NOT remove the context - use clearPreservedContext() or
* restoreAndClearContext() for automatic cleanup.
*
* @template T The expected type of the preserved state
* @param customScope Optional custom scope (defaults to active tab scope)
* @returns The preserved state, or null if not found
*
* @example
* ```typescript
* // Restore from default tab scope
* const context = navigationStateService.restoreContext<{ returnUrl: string }>();
* if (context?.returnUrl) {
* console.log('Returning to:', context.returnUrl);
* }
*
* // Restore from custom scope
* const context = navigationStateService.restoreContext<{ customerId: number }>('customer-details');
* ```
*/
async restoreContext<T extends NavigationContextData = NavigationContextData>(
customScope?: string,
): Promise<T | null> {
return await this.#contextService.getContext<T>(customScope);
}
/**
* Restore and automatically clear preserved navigation state.
*
* Retrieves a preserved navigation context and removes it from tab metadata in one operation.
* Use this when completing a flow to clean up automatically.
*
* @template T The expected type of the preserved state
* @param customScope Optional custom scope (defaults to active tab scope)
* @returns The preserved state, or null if not found
*
* @example
* ```typescript
* // Restore and clear from default tab scope
* const context = await navigationStateService.restoreAndClearContext<{ returnUrl: string }>();
* if (context?.returnUrl) {
* await router.navigateByUrl(context.returnUrl);
* }
*
* // Restore and clear from custom scope
* const context = await navigationStateService.restoreAndClearContext<{ customerId: number }>('customer-details');
* ```
*/
async restoreAndClearContext<
T extends NavigationContextData = NavigationContextData,
>(customScope?: string): Promise<T | null> {
return await this.#contextService.getAndClearContext<T>(customScope);
}
/**
* Clear a preserved navigation context.
*
* Removes a context from tab metadata without returning its data.
* Use this for explicit cleanup when you no longer need the preserved state.
*
* @param customScope Optional custom scope (defaults to active tab scope)
* @returns true if context was found and cleared, false otherwise
*
* @example
* ```typescript
* // Clear default tab scope context
* await navigationStateService.clearPreservedContext();
*
* // Clear custom scope context
* await navigationStateService.clearPreservedContext('customer-details');
* ```
*/
async clearPreservedContext(customScope?: string): Promise<boolean> {
return await this.#contextService.clearContext(customScope);
}
/**
* Check if a preserved context exists.
*
* @param customScope Optional custom scope (defaults to active tab scope)
* @returns true if context exists, false otherwise
*
* @example
* ```typescript
* // Check default tab scope
* if (navigationStateService.hasPreservedContext()) {
* const context = navigationStateService.restoreContext();
* }
*
* // Check custom scope
* if (navigationStateService.hasPreservedContext('customer-details')) {
* const context = navigationStateService.restoreContext('customer-details');
* }
* ```
*/
async hasPreservedContext(customScope?: string): Promise<boolean> {
return await this.#contextService.hasContext(customScope);
}
/**
* Navigate while preserving context state.
*
* Convenience method that combines navigation with context preservation.
* The context will be stored in tab metadata and available throughout the
* navigation flow and any intermediate navigations. Context is automatically
* scoped to the active tab.
*
* @param commands Navigation commands (same as Router.navigate)
* @param state The state to preserve
* @param customScope Optional custom scope within the tab
* @param extras Optional navigation extras
* @returns Promise resolving to navigation success status
*
* @example
* ```typescript
* // Navigate and preserve context
* const { success } = await navigationStateService.navigateWithPreservedContext(
* ['/customer/search'],
* { returnUrl: '/reward/cart', customerId: 123 }
* );
*
* // Later, retrieve and navigate back
* const context = await navigationStateService.restoreAndClearContext<{ returnUrl: string }>();
* if (context?.returnUrl) {
* await router.navigateByUrl(context.returnUrl);
* }
* ```
*/
async navigateWithPreservedContext<T extends NavigationContextData>(
commands: unknown[],
state: T,
customScope?: string,
extras?: NavigationExtras,
): Promise<{ success: boolean }> {
await this.preserveContext(state, customScope);
// Also pass state via router for immediate access
const navigationExtras: NavigationExtras = {
...extras,
state,
};
const success = await this.#router.navigate(commands, navigationExtras);
return { success };
}
/**
* Clear all preserved contexts for the active tab.
*
* Removes all contexts for the active tab (both default and custom scopes).
* Useful for cleanup when a tab is closed.
*
* @returns The number of contexts cleared
*
* @example
* ```typescript
* // Clear all contexts for active tab
* ngOnDestroy() {
* const cleared = this.navigationStateService.clearScopeContexts();
* console.log(`Cleared ${cleared} contexts`);
* }
* ```
*/
async clearScopeContexts(): Promise<number> {
return await this.#contextService.clearScope();
}
}

View File

@@ -1,23 +0,0 @@
/**
* Type definition for navigation state that can be passed through Angular Router.
* Use generic type parameter to ensure type safety for your specific state shape.
*
* @example
* ```typescript
* interface MyNavigationState extends NavigationState {
* returnUrl: string;
* customerId: number;
* }
* ```
*/
export interface NavigationState {
[key: string]: unknown;
}
/**
* Common navigation state for "return URL" pattern.
* Used when navigating to a page and needing to return to the previous location.
*/
export interface ReturnUrlNavigationState extends NavigationState {
returnUrl: string;
}

View File

@@ -1,13 +0,0 @@
import '@angular/compiler';
import '@analogjs/vitest-angular/setup-zone';
import {
BrowserTestingModule,
platformBrowserTesting,
} from '@angular/platform-browser/testing';
import { getTestBed } from '@angular/core/testing';
getTestBed().initTestEnvironment(
BrowserTestingModule,
platformBrowserTesting(),
);

View File

@@ -1,30 +0,0 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"importHelpers": true,
"moduleResolution": "bundler",
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"module": "preserve"
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"typeCheckHostBindings": true,
"strictTemplates": true
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}

View File

@@ -1,27 +0,0 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../dist/out-tsc",
"declaration": true,
"declarationMap": true,
"inlineSources": true,
"types": []
},
"exclude": [
"src/**/*.spec.ts",
"src/test-setup.ts",
"jest.config.ts",
"src/**/*.test.ts",
"vite.config.ts",
"vite.config.mts",
"vitest.config.ts",
"vitest.config.mts",
"src/**/*.test.tsx",
"src/**/*.spec.tsx",
"src/**/*.test.js",
"src/**/*.spec.js",
"src/**/*.test.jsx",
"src/**/*.spec.jsx"
],
"include": ["src/**/*.ts"]
}

View File

@@ -1,29 +0,0 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../dist/out-tsc",
"types": [
"vitest/globals",
"vitest/importMeta",
"vite/client",
"node",
"vitest"
]
},
"include": [
"vite.config.ts",
"vite.config.mts",
"vitest.config.ts",
"vitest.config.mts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.test.tsx",
"src/**/*.spec.tsx",
"src/**/*.test.js",
"src/**/*.spec.js",
"src/**/*.test.jsx",
"src/**/*.spec.jsx",
"src/**/*.d.ts"
],
"files": ["src/test-setup.ts"]
}

View File

@@ -1,33 +0,0 @@
/// <reference types='vitest' />
import { defineConfig } from 'vite';
import angular from '@analogjs/vite-plugin-angular';
import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin';
import { nxCopyAssetsPlugin } from '@nx/vite/plugins/nx-copy-assets.plugin';
export default
// @ts-expect-error - Vitest reporter tuple types have complex inference issues, but config works correctly at runtime
defineConfig(() => ({
root: __dirname,
cacheDir: '../../../node_modules/.vite/libs/core/navigation',
plugins: [angular(), nxViteTsPaths(), nxCopyAssetsPlugin(['*.md'])],
// Uncomment this if you are using workers.
// worker: {
// plugins: [ nxViteTsPaths() ],
// },
test: {
watch: false,
globals: true,
environment: 'jsdom',
include: ['{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
setupFiles: ['src/test-setup.ts'],
reporters: [
'default',
['junit', { outputFile: '../../../testresults/junit-core-navigation.xml' }],
],
coverage: {
reportsDirectory: '../../../coverage/libs/core/navigation',
provider: 'v8' as const,
reporter: ['text', 'cobertura'],
},
},
}));

View File

@@ -2,6 +2,7 @@ import { Injectable, inject, resource, signal, computed } from '@angular/core';
import { logger } from '@isa/core/logging';
import { CustomerBonRedemptionFacade } from '../facades/customer-bon-redemption.facade';
import { LoyaltyBonResponse } from '@generated/swagger/crm-api';
import { ResponseArgsError } from '@isa/common/data-access';
/**
* Resource for checking/validating Bon numbers.
@@ -47,8 +48,24 @@ export class CustomerBonCheckResource {
this.#logger.debug('Bon checked', () => ({
bonNr,
found: !!response?.result,
hasInvalidProperties:
!!response?.invalidProperties &&
Object.keys(response.invalidProperties).length > 0,
}));
// Check for invalidProperties even when error is false
// Backend may return { error: false, invalidProperties: {...} } for validation issues
if (
response?.invalidProperties &&
Object.keys(response.invalidProperties).length > 0
) {
this.#logger.warn('Bon check has invalid properties', () => ({
bonNr,
invalidProperties: response.invalidProperties,
}));
throw new ResponseArgsError(response);
}
return response?.result;
},
defaultValue: undefined,

View File

@@ -1,17 +1,18 @@
import { CountryService as ApiCountryService } from '@generated/swagger/crm-api';
import { Cache, CacheTimeToLive } from '@isa/common/decorators';
import { Cache } from '@isa/common/decorators';
import { inject, Injectable } from '@angular/core';
import { Country } from '../models';
import {
catchResponseArgsErrorPipe,
ResponseArgs,
takeUntilAborted,
} from '@isa/common/data-access';
import { firstValueFrom } from 'rxjs';
import { logger } from '@isa/core/logging';
@Injectable({ providedIn: 'root' })
export class CountryService {
#apiCountryService = inject(ApiCountryService);
#logger = logger(() => ({ service: 'CountryService' }));
@Cache()
async getCountries(abortSignal?: AbortSignal): Promise<Country[]> {
@@ -23,8 +24,12 @@ export class CountryService {
req$ = req$.pipe(catchResponseArgsErrorPipe());
const res = await firstValueFrom(req$);
return res.result as Country[];
try {
const res = await firstValueFrom(req$);
return res.result as Country[];
} catch (error) {
this.#logger.error('Failed to get countries', error);
throw error;
}
}
}

View File

@@ -30,7 +30,6 @@ import {
import {
catchResponseArgsErrorPipe,
ResponseArgs,
ResponseArgsError,
takeUntilAborted,
} from '@isa/common/data-access';
import { Cache, CacheTimeToLive } from '@isa/common/decorators';
@@ -161,8 +160,15 @@ export class CrmSearchService {
})
.pipe(catchResponseArgsErrorPipe());
const res = await firstValueFrom(req$);
return res?.result;
try {
const res = await firstValueFrom(req$);
return res?.result;
} catch (error) {
this.#logger.error('Error adding customer card', error, () => ({
customerId: parsed.customerId,
}));
throw error;
}
}
async lockCard(params: LockCardInput): Promise<boolean | undefined> {
@@ -176,15 +182,15 @@ export class CrmSearchService {
})
.pipe(catchResponseArgsErrorPipe());
const res = await firstValueFrom(req$);
if (res.error) {
const err = new ResponseArgsError(res);
this.#logger.error('Lock card Failed', err);
throw err;
try {
const res = await firstValueFrom(req$);
return res?.result;
} catch (error) {
this.#logger.error('Error locking customer card', error, () => ({
cardCode: parsed.cardCode,
}));
throw error;
}
return res?.result;
}
async unlockCard(params: UnlockCardInput): Promise<boolean | undefined> {
@@ -199,15 +205,16 @@ export class CrmSearchService {
})
.pipe(catchResponseArgsErrorPipe());
const res = await firstValueFrom(req$);
if (res.error) {
const err = new ResponseArgsError(res);
this.#logger.error('Unlock card Failed', err);
throw err;
try {
const res = await firstValueFrom(req$);
return res?.result;
} catch (error) {
this.#logger.error('Error unlocking customer card', error, () => ({
customerId: parsed.customerId,
cardCode: parsed.cardCode,
}));
throw error;
}
return res?.result;
}
@Cache({ ttl: CacheTimeToLive.oneHour })
@@ -227,11 +234,10 @@ export class CrmSearchService {
try {
const res = await firstValueFrom(req$);
this.#logger.debug('Successfully fetched current booking partner store');
return res?.result;
} catch (error) {
this.#logger.error('Error fetching current booking partner store', error);
return undefined;
throw error;
}
}
@@ -250,8 +256,16 @@ export class CrmSearchService {
},
})
.pipe(catchResponseArgsErrorPipe());
const res = await firstValueFrom(req$);
return res?.result;
try {
const res = await firstValueFrom(req$);
return res?.result;
} catch (error) {
this.#logger.error('Error adding booking', error, () => ({
cardCode: parsed.cardCode,
}));
throw error;
}
}
/**
@@ -278,16 +292,12 @@ export class CrmSearchService {
try {
const res = await firstValueFrom(req$);
this.#logger.debug('Successfully checked Bon');
if (res.error) {
const err = new ResponseArgsError(res);
this.#logger.error('Bon check failed', err);
throw err;
}
return res as ResponseArgs<LoyaltyBonResponse>;
} catch (error) {
this.#logger.error('Error checking Bon', error);
this.#logger.error('Error checking Bon', error, () => ({
cardCode,
bonNr,
}));
throw error;
}
}
@@ -306,8 +316,16 @@ export class CrmSearchService {
})
.pipe(catchResponseArgsErrorPipe());
const res = await firstValueFrom(req$);
this.#logger.debug('Successfully redeemed Bon');
return res?.result ?? false;
try {
const res = await firstValueFrom(req$);
this.#logger.debug('Successfully redeemed Bon');
return res?.result ?? false;
} catch (error) {
this.#logger.error('Error redeeming Bon', error, () => ({
cardCode,
bonNr,
}));
throw error;
}
}
}

View File

@@ -8,13 +8,15 @@
aria-label="Bon Details"
>
<div class="flex justify-between items-center py-1">
<span class="isa-text-body-2-regular text-isa-neutral-600">Bon Datum</span>
<span class="isa-text-body-2-regular text-isa-neutral-600"
>Bon Datum</span
>
<span
class="isa-text-body-2-bold text-isa-black"
data-what="bon-date"
[attr.data-which]="bon.bonNumber"
>
{{ bon.date }}
{{ bon.date | date: 'dd.MM.yyyy' }}
</span>
</div>

View File

@@ -114,12 +114,7 @@ export class CrmFeatureCustomerBonRedemptionComponent {
}
// Handle API errors
else if (error) {
let errorMsg = 'Bon-Validierung fehlgeschlagen';
if (error instanceof ResponseArgsError) {
errorMsg = error.message || errorMsg;
} else if (error instanceof Error) {
errorMsg = error.message;
}
const errorMsg = this.#extractErrorMessage(error);
this.store.setError(errorMsg);
}
});
@@ -224,4 +219,23 @@ export class CrmFeatureCustomerBonRedemptionComponent {
this.store.reset();
this.#bonCheckResource.reset();
}
/**
* Extract error message from various error types.
* ResponseArgsError already formats invalidProperties into a readable message.
*/
#extractErrorMessage(error: unknown): string {
const defaultMsg = 'Bon-Validierung fehlgeschlagen';
const actualError = (error as { cause?: unknown })?.cause ?? error;
if (actualError instanceof ResponseArgsError) {
return actualError.message || defaultMsg;
}
if (actualError instanceof Error) {
return actualError.message || defaultMsg;
}
return defaultMsg;
}
}

View File

@@ -51,7 +51,7 @@ describe('CustomerCardPointsSummaryComponent', () => {
});
describe('formattedPoints computed signal', () => {
it('should display points from primary card', () => {
it('should display points from first card', () => {
fixture.componentRef.setInput('cards', mockCards);
fixture.detectChanges();
@@ -77,7 +77,7 @@ describe('CustomerCardPointsSummaryComponent', () => {
expect(component.formattedPoints()).toBe('123.456');
});
it('should display 0 when no primary card exists', () => {
it('should display points from first card regardless of isPrimary flag', () => {
const cardsWithoutPrimary: BonusCardInfo[] = [
{
code: 'CARD-1',
@@ -93,7 +93,7 @@ describe('CustomerCardPointsSummaryComponent', () => {
fixture.componentRef.setInput('cards', cardsWithoutPrimary);
fixture.detectChanges();
expect(component.formattedPoints()).toBe('0');
expect(component.formattedPoints()).toBe('1.500');
});
it('should display 0 when cards array is empty', () => {
@@ -122,14 +122,14 @@ describe('CustomerCardPointsSummaryComponent', () => {
expect(component.formattedPoints()).toBe('0');
});
it('should only use primary card points, not sum of all cards', () => {
it('should only use first card points, not sum of all cards', () => {
const multipleCards: BonusCardInfo[] = [
{
code: 'CARD-1',
firstName: 'John',
lastName: 'Doe',
isActive: true,
isPrimary: true,
isPrimary: false,
totalPoints: 1000,
cardNumber: '1234-5678-9012-3456',
} as BonusCardInfo,
@@ -138,7 +138,7 @@ describe('CustomerCardPointsSummaryComponent', () => {
firstName: 'John',
lastName: 'Doe',
isActive: true,
isPrimary: false,
isPrimary: true,
totalPoints: 500,
cardNumber: '9876-5432-1098-7654',
} as BonusCardInfo,
@@ -147,7 +147,7 @@ describe('CustomerCardPointsSummaryComponent', () => {
fixture.componentRef.setInput('cards', multipleCards);
fixture.detectChanges();
// Should be 1000, not 1500
// Should be 1000 (first card), not 500 (primary) or 1500 (sum)
expect(component.formattedPoints()).toBe('1.000');
});
});

View File

@@ -36,12 +36,11 @@ export class CustomerCardPointsSummaryComponent {
readonly navigateToPraemienshop = output<void>();
/**
* Total points from primary card, formatted with thousands separator.
* Total points from first card, formatted with thousands separator.
*/
readonly formattedPoints = computed(() => {
const cards = this.cards();
const primaryCard = cards.find((c) => c.isPrimary);
const points = primaryCard?.totalPoints ?? 0;
const points = cards?.[0]?.totalPoints ?? 0;
// Format with German thousands separator (dot)
return points.toLocaleString('de-DE');

View File

@@ -10,6 +10,7 @@ export * from './get-receipt-item-quantity.helper';
export * from './get-return-info.helper';
export * from './get-return-process-questions.helper';
export * from './get-tolino-questions.helper';
export * from './is-task-type.helper';
export * from './receipt-item-has-category.helper';
export * from './return-details-mapping.helper';
export * from './return-receipt-values-mapping.helper';

View File

@@ -0,0 +1,88 @@
import { TaskActionTypes } from '../../models';
import { isTaskType } from './is-task-type.helper';
describe('isTaskType', () => {
describe('OK type matching', () => {
it('should return true when comparing OK with OK', () => {
expect(isTaskType('OK', TaskActionTypes.OK)).toBe(true);
});
it('should return true when comparing RETOURE_OK with OK', () => {
expect(isTaskType('RETOURE_OK', TaskActionTypes.OK)).toBe(true);
});
it('should return true when comparing OK with RETOURE_OK', () => {
expect(isTaskType('OK', TaskActionTypes.RETOURE_OK)).toBe(true);
});
it('should return true when comparing RETOURE_OK with RETOURE_OK', () => {
expect(isTaskType('RETOURE_OK', TaskActionTypes.RETOURE_OK)).toBe(true);
});
});
describe('NOK type matching', () => {
it('should return true when comparing NOK with NOK', () => {
expect(isTaskType('NOK', TaskActionTypes.NOK)).toBe(true);
});
it('should return true when comparing RETOURE_NOK with NOK', () => {
expect(isTaskType('RETOURE_NOK', TaskActionTypes.NOK)).toBe(true);
});
it('should return true when comparing NOK with RETOURE_NOK', () => {
expect(isTaskType('NOK', TaskActionTypes.RETOURE_NOK)).toBe(true);
});
it('should return true when comparing RETOURE_NOK with RETOURE_NOK', () => {
expect(isTaskType('RETOURE_NOK', TaskActionTypes.RETOURE_NOK)).toBe(true);
});
});
describe('UNKNOWN type matching', () => {
it('should return true when comparing UNKNOWN with UNKNOWN', () => {
expect(isTaskType('UNKNOWN', TaskActionTypes.UNKNOWN)).toBe(true);
});
it('should return true when comparing RETOURE_UNKNOWN with UNKNOWN', () => {
expect(isTaskType('RETOURE_UNKNOWN', TaskActionTypes.UNKNOWN)).toBe(true);
});
it('should return true when comparing UNKNOWN with RETOURE_UNKNOWN', () => {
expect(isTaskType('UNKNOWN', TaskActionTypes.RETOURE_UNKNOWN)).toBe(true);
});
it('should return true when comparing RETOURE_UNKNOWN with RETOURE_UNKNOWN', () => {
expect(
isTaskType('RETOURE_UNKNOWN', TaskActionTypes.RETOURE_UNKNOWN),
).toBe(true);
});
});
describe('non-matching types', () => {
it('should return false when comparing OK with NOK', () => {
expect(isTaskType('OK', TaskActionTypes.NOK)).toBe(false);
});
it('should return false when comparing RETOURE_OK with UNKNOWN', () => {
expect(isTaskType('RETOURE_OK', TaskActionTypes.UNKNOWN)).toBe(false);
});
it('should return false when comparing NOK with OK', () => {
expect(isTaskType('NOK', TaskActionTypes.OK)).toBe(false);
});
});
describe('falsy values', () => {
it('should return false when value is undefined', () => {
expect(isTaskType(undefined, TaskActionTypes.OK)).toBe(false);
});
it('should return false when value is null', () => {
expect(isTaskType(null, TaskActionTypes.OK)).toBe(false);
});
it('should return false when value is empty string', () => {
expect(isTaskType('', TaskActionTypes.OK)).toBe(false);
});
});
});

View File

@@ -0,0 +1,34 @@
import { TaskActionTypeType } from '../../models';
/**
* Checks if a task action type value matches a given type, including its RETOURE_ variant.
*
* This helper normalizes both values by stripping the 'RETOURE_' prefix before comparison,
* allowing flexible matching between base types and their return variants.
*
* @param value - The task action type value to check (can be a base type or RETOURE_ variant)
* @param type - The task action type to compare against (can be a base type or RETOURE_ variant)
* @returns `true` if the normalized values match, `false` otherwise
*
* @example
* // All of these return true:
* isTaskType('OK', TaskActionTypes.OK) // 'OK' === 'OK'
* isTaskType('RETOURE_OK', TaskActionTypes.OK) // 'OK' === 'OK'
* isTaskType('OK', TaskActionTypes.RETOURE_OK) // 'OK' === 'OK'
* isTaskType('RETOURE_OK', TaskActionTypes.RETOURE_OK) // 'OK' === 'OK'
*
* @example
* // Returns false:
* isTaskType('OK', TaskActionTypes.NOK) // 'OK' !== 'NOK'
* isTaskType('RETOURE_OK', TaskActionTypes.UNKNOWN) // 'OK' !== 'UNKNOWN'
* isTaskType(undefined, TaskActionTypes.OK) // value is falsy
*/
export const isTaskType = (
value: TaskActionTypeType | string | undefined | null,
type: TaskActionTypeType,
): boolean => {
if (!value) return false;
const normalizedValue = value.replace('RETOURE_', '');
const normalizedType = type.replace('RETOURE_', '');
return normalizedValue === normalizedType;
};

View File

@@ -2,8 +2,11 @@ import { KeyValueDTOOfStringAndString } from '@generated/swagger/oms-api';
export const TaskActionTypes = {
OK: 'OK',
RETOURE_OK: 'RETOURE_OK',
NOK: 'NOK',
RETOURE_NOK: 'RETOURE_NOK',
UNKNOWN: 'UNKNOWN',
RETOURE_UNKNOWN: 'RETOURE_UNKNOWN',
} as const;
export type TaskActionTypeType =
@@ -13,6 +16,6 @@ export interface TaskActionType {
type: TaskActionTypeType;
taskId: number;
receiptItemId?: number;
updateTo?: Exclude<TaskActionTypeType, 'UNKNOWN'>;
updateTo?: Exclude<TaskActionTypeType, 'UNKNOWN' | 'RETOURE_UNKNOWN'>;
actions?: Array<KeyValueDTOOfStringAndString>;
}

View File

@@ -9,7 +9,10 @@ import {
import { logger } from '@isa/core/logging';
import { ReceiptService } from '@generated/swagger/oms-api';
import { firstValueFrom } from 'rxjs';
import { ResponseArgsError, takeUntilAborted } from '@isa/common/data-access';
import {
catchResponseArgsErrorPipe,
takeUntilAborted,
} from '@isa/common/data-access';
import { Receipt } from '../models';
@Injectable()
@@ -74,29 +77,31 @@ export class HandleCommandService {
parsed,
}));
let req$ = this.#receiptService.ReceiptGetReceiptsByOrderItemSubset({
payload: parsed, // Payload Default from old Implementation, eagerLoading: 1 and receiptType: (1 + 64 + 128) set as Schema default
});
let req$ = this.#receiptService
.ReceiptGetReceiptsByOrderItemSubset({
payload: parsed, // Payload Default from old Implementation, eagerLoading: 1 and receiptType: (1 + 64 + 128) set as Schema default
})
.pipe(catchResponseArgsErrorPipe());
if (abortSignal) {
req$ = req$.pipe(takeUntilAborted(abortSignal));
}
const res = await firstValueFrom(req$);
try {
const res = await firstValueFrom(req$);
if (res.error) {
const err = new ResponseArgsError(res);
// Mapping Logic from old implementation
const mappedReceipts =
res?.result?.map((r) => r.item3?.data).filter((f) => !!f) ?? [];
return mappedReceipts as Receipt[];
} catch (error) {
this.#logger.error(
'Failed to fetch receipts by order item subset IDs',
err,
error,
() => ({ ids: parsed.ids }),
);
throw err;
throw error;
}
// Mapping Logic from old implementation
const mappedReceipts =
res?.result?.map((r) => r.item3?.data).filter((f) => !!f) ?? [];
return mappedReceipts as Receipt[];
}
}

View File

@@ -1,6 +1,9 @@
import { inject, Injectable } from '@angular/core';
import { LogisticianService as GeneratedLogisticianService } from '@generated/swagger/oms-api';
import { ResponseArgsError, takeUntilAborted } from '@isa/common/data-access';
import {
catchResponseArgsErrorPipe,
takeUntilAborted,
} from '@isa/common/data-access';
import { logger } from '@isa/core/logging';
import { firstValueFrom } from 'rxjs';
import { Cache, CacheTimeToLive, InFlight } from '@isa/common/decorators';
@@ -32,37 +35,38 @@ export class LogisticianService {
async getLogistician2470(abortSignal?: AbortSignal): Promise<Logistician> {
this.#logger.debug('Fetching logistician 2470');
let req$ = this.#logisticianService.LogisticianGetLogisticians({});
let req$ = this.#logisticianService
.LogisticianGetLogisticians({})
.pipe(catchResponseArgsErrorPipe());
if (abortSignal) {
req$ = req$.pipe(takeUntilAborted(abortSignal));
}
const res = await firstValueFrom(req$);
try {
const res = await firstValueFrom(req$);
if (res.error || !res.result) {
const error = new ResponseArgsError(res);
this.#logger.error('Failed to fetch logisticians', error);
const logistician = res.result?.find(
(l) => l.logisticianNumber === '2470',
);
if (!logistician) {
const notFoundError = new Error('Logistician 2470 not found');
this.#logger.error('Logistician 2470 not found', notFoundError, () => ({
availableLogisticians: res.result?.map((l) => l.logisticianNumber),
}));
throw notFoundError;
}
this.#logger.debug('Logistician 2470 fetched', () => ({
logisticianId: logistician.id,
logisticianNumber: logistician.logisticianNumber,
}));
return logistician;
} catch (error) {
this.#logger.error('Failed to fetch logistician 2470', error);
throw error;
}
const logistician = res.result.find(
(l) => l.logisticianNumber === '2470',
);
if (!logistician) {
const notFoundError = new Error('Logistician 2470 not found');
this.#logger.error('Logistician 2470 not found', notFoundError, () => ({
availableLogisticians: res.result?.map((l) => l.logisticianNumber),
}));
throw notFoundError;
}
this.#logger.debug('Logistician 2470 fetched', () => ({
logisticianId: logistician.id,
logisticianNumber: logistician.logisticianNumber,
}));
return logistician;
}
}

View File

@@ -4,7 +4,10 @@ import {
DBHOrderItemListItemDTO,
QueryTokenDTO,
} from '@generated/swagger/oms-api';
import { ResponseArgsError, takeUntilAborted } from '@isa/common/data-access';
import {
catchResponseArgsErrorPipe,
takeUntilAborted,
} from '@isa/common/data-access';
import { logger } from '@isa/core/logging';
import { firstValueFrom } from 'rxjs';
@@ -49,26 +52,26 @@ export class OpenRewardTasksService {
orderBy: [],
};
let req$ = this.#abholfachService.AbholfachWarenausgabe(payload);
let req$ = this.#abholfachService
.AbholfachWarenausgabe(payload)
.pipe(catchResponseArgsErrorPipe());
if (abortSignal) {
req$ = req$.pipe(takeUntilAborted(abortSignal));
}
const res = await firstValueFrom(req$);
try {
const res = await firstValueFrom(req$);
const tasks = res.result ?? [];
if (res.error) {
const error = new ResponseArgsError(res);
this.#logger.debug('Open reward tasks fetched', () => ({
taskCount: tasks.length,
}));
return tasks;
} catch (error) {
this.#logger.error('Failed to fetch open reward tasks', error);
throw error;
}
const tasks = res.result ?? [];
this.#logger.debug('Open reward tasks fetched', () => ({
taskCount: tasks.length,
}));
return tasks;
}
}

View File

@@ -4,7 +4,10 @@ import {
LogisticianService,
LogisticianDTO,
} from '@generated/swagger/oms-api';
import { ResponseArgsError, takeUntilAborted } from '@isa/common/data-access';
import {
catchResponseArgsErrorPipe,
takeUntilAborted,
} from '@isa/common/data-access';
import { logger } from '@isa/core/logging';
import { firstValueFrom } from 'rxjs';
import { DisplayOrder } from '../models';
@@ -36,19 +39,23 @@ export class OrderCreationService {
throw new Error(`Invalid checkoutId: ${checkoutId}`);
}
const req$ = this.#orderCheckoutService.OrderCheckoutCreateOrderPOST({
checkoutId,
});
const req$ = this.#orderCheckoutService
.OrderCheckoutCreateOrderPOST({
checkoutId,
})
.pipe(catchResponseArgsErrorPipe());
const res = await firstValueFrom(req$);
if (res.error) {
const error = new ResponseArgsError(res);
this.#logger.error('Failed to create orders', error);
try {
const res = await firstValueFrom(req$);
return res.result as DisplayOrder[];
} catch (error) {
this.#logger.error(
'Failed to create orders from checkout',
error,
() => ({ checkoutId }),
);
throw error;
}
return res.result as DisplayOrder[];
}
/**
@@ -63,25 +70,29 @@ export class OrderCreationService {
logisticianNumber = '2470',
abortSignal?: AbortSignal,
): Promise<LogisticianDTO> {
let req$ = this.#logisticianService.LogisticianGetLogisticians({});
let req$ = this.#logisticianService
.LogisticianGetLogisticians({})
.pipe(catchResponseArgsErrorPipe());
if (abortSignal) req$ = req$.pipe(takeUntilAborted(abortSignal));
const res = await firstValueFrom(req$);
try {
const res = await firstValueFrom(req$);
if (res.error) {
const error = new ResponseArgsError(res);
this.#logger.error('Failed to get logistician', error);
const logistician = res.result?.find(
(l) => l.logisticianNumber === logisticianNumber,
);
if (!logistician) {
throw new Error(`Logistician ${logisticianNumber} not found`);
}
return logistician;
} catch (error) {
this.#logger.error('Failed to get logistician', error, () => ({
logisticianNumber,
}));
throw error;
}
const logistician = res.result?.find(
(l) => l.logisticianNumber === logisticianNumber,
);
if (!logistician) {
throw new Error(`Logistician ${logisticianNumber} not found`);
}
return logistician;
}
}

View File

@@ -1,6 +1,9 @@
import { inject, Injectable } from '@angular/core';
import { OrderService } from '@generated/swagger/oms-api';
import { ResponseArgsError, takeUntilAborted } from '@isa/common/data-access';
import {
catchResponseArgsErrorPipe,
takeUntilAborted,
} from '@isa/common/data-access';
import { logger } from '@isa/core/logging';
import { firstValueFrom } from 'rxjs';
import {
@@ -25,25 +28,28 @@ export class OrderRewardCollectService {
throw error;
}
const req$ = this.#orderService.OrderLoyaltyCollect({
orderId: params.orderId,
orderItemId: params.orderItemId,
orderItemSubsetId: params.orderItemSubsetId,
data: {
collectType: params.collectType,
quantity: params.quantity,
},
});
const req$ = this.#orderService
.OrderLoyaltyCollect({
orderId: params.orderId,
orderItemId: params.orderItemId,
orderItemSubsetId: params.orderItemSubsetId,
data: {
collectType: params.collectType,
quantity: params.quantity,
},
})
.pipe(catchResponseArgsErrorPipe());
const res = await firstValueFrom(req$);
if (res.error) {
const error = new ResponseArgsError(res);
this.#logger.error('Failed to collect reward item', error);
try {
const res = await firstValueFrom(req$);
return res.result as DBHOrderItemListItem[];
} catch (error) {
this.#logger.error('Failed to collect order reward', error, () => ({
orderId: params.orderId,
orderItemSubsetId: params.orderItemSubsetId,
}));
throw error;
}
return res.result as DBHOrderItemListItem[];
}
async fetchOrderItemSubset(
@@ -57,22 +63,22 @@ export class OrderRewardCollectService {
throw error;
}
let req$ = this.#orderService.OrderGetOrderItemSubset(
params.orderItemSubsetId,
);
let req$ = this.#orderService
.OrderGetOrderItemSubset(params.orderItemSubsetId)
.pipe(catchResponseArgsErrorPipe());
if (abortSignal) {
req$ = req$.pipe(takeUntilAborted(abortSignal));
}
const res = await firstValueFrom(req$);
if (res.error) {
const error = new ResponseArgsError(res);
this.#logger.error('Failed to fetch order item subset', error);
try {
const res = await firstValueFrom(req$);
return res.result as DisplayOrderItemSubset;
} catch (error) {
this.#logger.error('Failed to fetch order item subset', error, () => ({
orderItemSubsetId: params.orderItemSubsetId,
}));
throw error;
}
return res.result as DisplayOrderItemSubset;
}
}

View File

@@ -4,7 +4,10 @@ import {
OrderDTO,
DisplayOrderDTO,
} from '@generated/swagger/oms-api';
import { ResponseArgsError, takeUntilAborted } from '@isa/common/data-access';
import {
catchResponseArgsErrorPipe,
takeUntilAborted,
} from '@isa/common/data-access';
import { logger } from '@isa/core/logging';
import { firstValueFrom } from 'rxjs';
@@ -20,21 +23,23 @@ export class OrdersService {
orderId: number,
abortSignal?: AbortSignal,
): Promise<OrderDTO | null> {
let req$ = this.#orderService.OrderGetOrder(orderId);
let req$ = this.#orderService
.OrderGetOrder(orderId)
.pipe(catchResponseArgsErrorPipe());
if (abortSignal) {
req$ = req$.pipe(takeUntilAborted(abortSignal));
}
const res = await firstValueFrom(req$);
if (res.error) {
const error = new ResponseArgsError(res);
this.#logger.error('Failed to fetch order', { orderId, error });
try {
const res = await firstValueFrom(req$);
return res.result ?? null;
} catch (error) {
this.#logger.error('Failed to get order', error, () => ({
orderId,
}));
throw error;
}
return res.result ?? null;
}
/**
@@ -66,21 +71,23 @@ export class OrdersService {
orderId: number,
abortSignal?: AbortSignal,
): Promise<DisplayOrderDTO | null> {
let req$ = this.#orderService.OrderGetDisplayOrder(orderId);
let req$ = this.#orderService
.OrderGetDisplayOrder(orderId)
.pipe(catchResponseArgsErrorPipe());
if (abortSignal) {
req$ = req$.pipe(takeUntilAborted(abortSignal));
}
const res = await firstValueFrom(req$);
if (res.error) {
const error = new ResponseArgsError(res);
this.#logger.error('Failed to fetch display order', { orderId, error });
try {
const res = await firstValueFrom(req$);
return res.result ?? null;
} catch (error) {
this.#logger.error('Failed to get display order', error, () => ({
orderId,
}));
throw error;
}
return res.result ?? null;
}
/**

View File

@@ -12,7 +12,6 @@ import {
returnReceiptValuesMapping,
} from '../helpers/return-process';
import { isReturnProcessTypeGuard } from '../guards';
import { takeUntilAborted } from '@isa/common/data-access';
/**
* Service for determining if a return process can proceed based on
@@ -36,20 +35,14 @@ export class ReturnCanReturnService {
* @param returnProcess - The return process object to evaluate.
* @returns A promise resolving to a CanReturn result or undefined if the process should continue.
*/
async canReturn(
returnProcess: ReturnProcess,
abortSignal?: AbortSignal,
): Promise<CanReturn | undefined>;
async canReturn(returnProcess: ReturnProcess): Promise<CanReturn | undefined>;
/**
* Determines if a return can proceed based on mapped receipt values.
*
* @param returnValues - The mapped return receipt values.
* @returns A promise resolving to a CanReturn result.
*/
async canReturn(
returnValues: ReturnReceiptValues,
abortSignal?: AbortSignal,
): Promise<CanReturn>;
async canReturn(returnValues: ReturnReceiptValues): Promise<CanReturn>;
/**
* Determines if a return can proceed, accepting either a ReturnProcess or ReturnReceiptValues.
@@ -60,7 +53,6 @@ export class ReturnCanReturnService {
*/
async canReturn(
input: ReturnProcess | ReturnReceiptValues,
abortSignal?: AbortSignal,
): Promise<CanReturn | undefined> {
let data: ReturnReceiptValues | undefined = undefined;
@@ -74,14 +66,10 @@ export class ReturnCanReturnService {
return undefined; // Prozess soll weitergehen, daher kein Error
}
let req$ = this.#receiptService.ReceiptCanReturn(
const req$ = this.#receiptService.ReceiptCanReturn(
data as ReturnReceiptValuesDTO,
);
if (abortSignal) {
req$ = req$.pipe(takeUntilAborted(abortSignal));
}
try {
return await firstValueFrom(
req$.pipe(

View File

@@ -32,19 +32,19 @@ export class ReturnDetailsService {
* @param params - The parameters for the return check.
* @param params.item - The receipt item to check.
* @param params.category - The product category to check against.
* @param abortSignal - Optional AbortSignal to cancel the request.
* @returns A promise resolving to the result of the canReturn check, containing
* eligibility status and any relevant constraints or messages.
* @throws Will throw an error if the return check fails or is aborted.
* @throws Will throw an error if the return check fails.
*/
async canReturn(
{
receiptItemId,
quantity,
category,
}: { receiptItemId: number; quantity: number; category: ProductCategory },
abortSignal?: AbortSignal,
): Promise<CanReturn> {
async canReturn({
receiptItemId,
quantity,
category,
}: {
receiptItemId: number;
quantity: number;
category: ProductCategory;
}): Promise<CanReturn> {
const returnReceiptValues: ReturnReceiptValues = {
quantity,
receiptItem: {
@@ -53,10 +53,7 @@ export class ReturnDetailsService {
category,
};
return this.#returnCanReturnService.canReturn(
returnReceiptValues,
abortSignal,
);
return this.#returnCanReturnService.canReturn(returnReceiptValues);
}
/**
* Gets all available product categories that have defined question sets.

View File

@@ -1,6 +1,11 @@
import { inject, Injectable } from '@angular/core';
import { map, Observable, throwError } from 'rxjs';
import { ReceiptItemTaskListItem, TaskActionTypeType } from '../models';
import {
ReceiptItemTaskListItem,
TaskActionTypes,
TaskActionTypeType,
} from '../models';
import { isTaskType } from '../helpers';
import { QueryTokenInput, QueryTokenSchema } from '../schemas';
import { ZodError } from 'zod';
import { ReturnParseQueryTokenError } from '../errors';
@@ -98,7 +103,7 @@ export class ReturnTaskListService {
* @throws Error when the update operation fails or returns an error
*/
updateTaskType(updateTask: {
type: Exclude<TaskActionTypeType, 'UNKNOWN'>;
type: Exclude<TaskActionTypeType, 'UNKNOWN' | 'RETOURE_UNKNOWN'>;
taskId: number;
}) {
try {
@@ -127,18 +132,18 @@ export class ReturnTaskListService {
* @private
*/
private _updateTaskRequestHelper(updateTask: {
type: Exclude<TaskActionTypeType, 'UNKNOWN'>;
type: Exclude<TaskActionTypeType, 'UNKNOWN' | 'RETOURE_UNKNOWN'>;
taskId: number;
}): Observable<ResponseArgsOfReceiptItemTaskListItemDTO> {
if (!updateTask?.taskId) {
return throwError(() => new Error('Task ID missing'));
}
if (updateTask.type === 'OK') {
if (isTaskType(updateTask.type, TaskActionTypes.OK)) {
return this.#receiptService.ReceiptSetReceiptItemTaskToOK(
updateTask.taskId,
);
} else if (updateTask.type === 'NOK') {
} else if (isTaskType(updateTask.type, TaskActionTypes.NOK)) {
return this.#receiptService.ReceiptSetReceiptItemTaskToNOK(
updateTask.taskId,
);

View File

@@ -176,7 +176,7 @@ export const ReturnDetailsStore = signalStore(
category,
};
},
loader: async ({ params, abortSignal }) => {
loader: async ({ params }) => {
if (params === undefined) {
return undefined;
}
@@ -186,10 +186,7 @@ export const ReturnDetailsStore = signalStore(
return store.canReturn()[key];
}
const res = await store._returnDetailsService.canReturn(
params,
abortSignal,
);
const res = await store._returnDetailsService.canReturn(params);
patchState(store, {
canReturn: { ...store.canReturn(), [key]: res },
});

View File

@@ -5,8 +5,8 @@
<ui-dropdown
class="quantity-dropdown"
[disabled]="!canReturnReceiptItem()"
[value]="selectedQuantity()"
(valueChange)="setQuantity($event)"
[ngModel]="selectedQuantity()"
(ngModelChange)="setQuantity($event)"
>
@for (quantity of quantityDropdownValues(); track quantity) {
<ui-dropdown-option [value]="quantity">{{
@@ -17,11 +17,11 @@
}
<ui-dropdown
label="Produktart"
[label]="dropdownLabel()"
class="product-dropdown"
[disabled]="!canReturnReceiptItem()"
[value]="productCategory()"
(valueChange)="setProductCategory($event)"
[ngModel]="productCategory()"
(ngModelChange)="setProductCategory($event)"
>
@for (kv of availableCategories; track kv.key) {
<ui-dropdown-option [value]="kv.key">{{ kv.value }}</ui-dropdown-option>
@@ -31,7 +31,7 @@
@if (canReturnReceiptItem()) {
@if (!canReturnResource.isLoading() && selectable()) {
<ui-checkbox appearance="bullet">
<ui-checkbox class="min-w-12" appearance="bullet">
<input
type="checkbox"
[ngModel]="selected()"

View File

@@ -5,27 +5,27 @@ import {
inject,
input,
signal,
} from "@angular/core";
import { provideLoggerContext } from "@isa/core/logging";
} from '@angular/core';
import { provideLoggerContext } from '@isa/core/logging';
import {
canReturnReceiptItem,
ProductCategory,
ReceiptItem,
ReturnDetailsService,
ReturnDetailsStore,
} from "@isa/oms/data-access";
import { IconButtonComponent } from "@isa/ui/buttons";
} from '@isa/oms/data-access';
import { IconButtonComponent } from '@isa/ui/buttons';
import {
CheckboxComponent,
DropdownButtonComponent,
DropdownOptionComponent,
} from "@isa/ui/input-controls";
import { FormsModule } from "@angular/forms";
} from '@isa/ui/input-controls';
import { FormsModule } from '@angular/forms';
@Component({
selector: "oms-feature-return-details-order-group-item-controls",
templateUrl: "./return-details-order-group-item-controls.component.html",
styleUrls: ["./return-details-order-group-item-controls.component.scss"],
selector: 'oms-feature-return-details-order-group-item-controls',
templateUrl: './return-details-order-group-item-controls.component.html',
styleUrls: ['./return-details-order-group-item-controls.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [
@@ -37,7 +37,7 @@ import { FormsModule } from "@angular/forms";
],
providers: [
provideLoggerContext({
component: "ReturnDetailsOrderGroupItemControlsComponent",
component: 'ReturnDetailsOrderGroupItemControlsComponent',
}),
],
})
@@ -89,6 +89,13 @@ export class ReturnDetailsOrderGroupItemControlsComponent {
canReturnReceiptItem = computed(() => canReturnReceiptItem(this.item()));
dropdownLabel = computed(() => {
const category = this.productCategory();
return !!category && category !== ProductCategory.Unknown
? category
: 'Produktart';
});
setProductCategory(category: ProductCategory | undefined) {
if (!category) {
category = ProductCategory.Unknown;

View File

@@ -57,21 +57,25 @@
</div>
<div class="text-isa-neutral-900 flex flex-col gap-2" uiItemRowProdcutInfo>
<div class="flex flex-row gap-2 items-center justify-start">
<ng-icon [name]="i.product.format | lowercase"></ng-icon>
@if (!!i?.product?.format) {
<ng-icon [name]="i.product.format | lowercase"></ng-icon>
}
<span class="isa-text-body-2-bold truncate">
{{ i.product.formatDetail }}
{{ i?.product?.formatDetail }}
</span>
</div>
<div
class="text-isa-neutral-600 isa-text-body-2-regular"
data-what="product-info"
[attr.data-which]="i.product.ean"
[attr.data-which]="i?.product?.ean"
>
{{ i.product.manufacturer }} | {{ i.product.ean }}
</div>
<div class="text-isa-neutral-600 isa-text-body-2-regular">
{{ i.product.publicationDate | date: 'dd. MMM yyyy' }}
{{ i?.product?.manufacturer }} | {{ i?.product?.ean }}
</div>
@if (!!i?.product?.publicationDate) {
<div class="text-isa-neutral-600 isa-text-body-2-regular">
{{ i.product.publicationDate | date: 'dd. MMM yyyy' }}
</div>
}
</div>
<oms-feature-return-details-order-group-item-controls [item]="i">
</oms-feature-return-details-order-group-item-controls>

View File

@@ -8,7 +8,7 @@
@let taskItem = item();
@let taskActionType = type();
@if (taskActionType === 'UNKNOWN') {
@if (isTaskType(taskActionType, TaskActionTypes.UNKNOWN)) {
<div
data-what="task-list"
data-which="processing-comment"
@@ -51,14 +51,21 @@
</div>
}
@if (taskActionType === 'UNKNOWN' && !taskItem?.completed) {
@if (
isTaskType(taskActionType, TaskActionTypes.UNKNOWN) && !taskItem?.completed
) {
<div class="task-unknown-actions">
<button
class="flex items-center"
type="button"
uiButton
color="secondary"
(click)="onActionClick({ type: taskActionType, updateTo: 'OK' })"
(click)="
onActionClick({
type: taskActionType,
updateTo: TaskActionTypes.RETOURE_OK,
})
"
data-what="button"
data-which="resellable"
>
@@ -69,7 +76,12 @@
type="button"
uiButton
color="secondary"
(click)="onActionClick({ type: taskActionType, updateTo: 'NOK' })"
(click)="
onActionClick({
type: taskActionType,
updateTo: TaskActionTypes.RETOURE_NOK,
})
"
data-what="button"
data-which="damaged"
>

View File

@@ -9,9 +9,11 @@ import {
} from '@angular/core';
import { isaActionCheck, isaActionPrinter } from '@isa/icons';
import {
isTaskType,
Product,
ReceiptItemTaskListItem,
TaskActionType,
TaskActionTypes,
TaskActionTypeType,
} from '@isa/oms/data-access';
import { ReturnProductInfoComponent } from '@isa/oms/shared/product-info';
@@ -37,6 +39,8 @@ import { NgIconComponent, provideIcons } from '@ng-icons/core';
encapsulation: ViewEncapsulation.None,
})
export class ReturnTaskListItemComponent {
readonly TaskActionTypes = TaskActionTypes;
readonly isTaskType = isTaskType;
appearance = input<'main' | 'review'>('main');
item = input.required<ReceiptItemTaskListItem>();
action = output<TaskActionType>();

View File

@@ -10,12 +10,13 @@ import {
} from '@angular/core';
import { ReturnTaskListItemComponent } from './return-task-list-item/return-task-list-item.component';
import {
isTaskType,
PrintTolinoReturnReceiptService,
QueryTokenInput,
ReceiptItemTaskListItem,
ReturnTaskListService,
ReturnTaskListStore,
TaskActionType,
TaskActionTypes,
TaskActionTypeType,
} from '@isa/oms/data-access';
import { IconButtonComponent } from '@isa/ui/buttons';
@@ -86,7 +87,9 @@ export class ReturnTaskListComponent {
const appearance = this.appearance();
if (processId) {
const filter: Record<string, unknown> =
appearance === 'review' ? { eob: true } : { completed: false };
appearance === 'review'
? { eob: true, tasktype: '!retoure_loyalty' }
: { completed: false, tasktype: '!retoure_loyalty' };
const queryToken: QueryTokenInput = {
filter,
};
@@ -113,7 +116,7 @@ export class ReturnTaskListComponent {
);
}
if (action.type === 'UNKNOWN' && !!action.updateTo) {
if (isTaskType(action.type, TaskActionTypes.UNKNOWN) && !!action.updateTo) {
return await this.updateTask({
taskId: action.taskId,
updateTo: action.updateTo,
@@ -149,7 +152,7 @@ export class ReturnTaskListComponent {
updateTo,
}: {
taskId: number;
updateTo: Exclude<TaskActionTypeType, 'UNKNOWN'>;
updateTo: Exclude<TaskActionTypeType, 'UNKNOWN' | 'RETOURE_UNKNOWN'>;
}) {
try {
const processId = this.processId();

View File

@@ -1,6 +1,9 @@
import { inject, Injectable } from '@angular/core';
import { StockService, BranchDTO } from '@generated/swagger/inventory-api';
import { ResponseArgsError, takeUntilAborted } from '@isa/common/data-access';
import {
catchResponseArgsErrorPipe,
takeUntilAborted,
} from '@isa/common/data-access';
import { logger } from '@isa/core/logging';
import { firstValueFrom } from 'rxjs';
@@ -27,41 +30,42 @@ export class BranchService {
* @throws {Error} If branch retrieval fails
*/
async getDefaultBranch(abortSignal?: AbortSignal): Promise<BranchDTO> {
let req$ = this.#stockService.StockCurrentBranch();
let req$ = this.#stockService
.StockCurrentBranch()
.pipe(catchResponseArgsErrorPipe());
if (abortSignal) req$ = req$.pipe(takeUntilAborted(abortSignal));
const res = await firstValueFrom(req$);
try {
const res = await firstValueFrom(req$);
const branch = res.result;
if (res.error) {
const error = new ResponseArgsError(res);
if (!branch) {
const error = new Error('No branch data returned');
this.#logger.error('Failed to get default branch', error);
throw error;
}
return {
id: branch.id,
name: branch.name,
address: branch.address,
branchType: branch.branchType,
branchNumber: branch.branchNumber,
changed: branch.changed,
created: branch.created,
isDefault: branch.isDefault,
isOnline: branch.isOnline,
key: branch.key,
label: branch.label,
pId: branch.pId,
shortName: branch.shortName,
status: branch.status,
version: branch.version,
};
} catch (error) {
this.#logger.error('Failed to get default branch', error);
throw error;
}
const branch = res.result;
if (!branch) {
const error = new Error('No branch data returned');
this.#logger.error('Failed to get default branch', error);
throw error;
}
return {
id: branch.id,
name: branch.name,
address: branch.address,
branchType: branch.branchType,
branchNumber: branch.branchNumber,
changed: branch.changed,
created: branch.created,
isDefault: branch.isDefault,
isOnline: branch.isOnline,
key: branch.key,
label: branch.label,
pId: branch.pId,
shortName: branch.shortName,
status: branch.status,
version: branch.version,
};
}
}

View File

@@ -4,7 +4,10 @@ import { KeyValueStringAndString } from '../models';
import { firstValueFrom } from 'rxjs';
import { logger } from '@isa/core/logging';
import { RemissionStockService } from './remission-stock.service';
import { ResponseArgsError, takeUntilAborted } from '@isa/common/data-access';
import {
catchResponseArgsErrorPipe,
takeUntilAborted,
} from '@isa/common/data-access';
import { InFlight, Cache, CacheTimeToLive } from '@isa/common/decorators';
/**
@@ -65,26 +68,29 @@ export class RemissionProductGroupService {
stockId: assignedStock.id,
}));
let req$ = this.#remiService.RemiProductgroups({
stockId: assignedStock.id,
});
let req$ = this.#remiService
.RemiProductgroups({
stockId: assignedStock.id,
})
.pipe(catchResponseArgsErrorPipe());
if (abortSignal) {
req$ = req$.pipe(takeUntilAborted(abortSignal));
}
const res = await firstValueFrom(req$);
try {
const res = await firstValueFrom(req$);
if (res.error || !res.result) {
const error = new ResponseArgsError(res);
this.#logger.error('Failed to fetch product groups', error);
this.#logger.debug('Successfully fetched product groups', () => ({
groupCount: res.result?.length || 0,
}));
return res.result as KeyValueStringAndString[];
} catch (error) {
this.#logger.error('Failed to fetch product groups', error, () => ({
stockId: assignedStock.id,
}));
throw error;
}
this.#logger.debug('Successfully fetched product groups', () => ({
groupCount: res.result?.length || 0,
}));
return res.result as KeyValueStringAndString[];
}
}

View File

@@ -1,6 +1,9 @@
import { inject, Injectable } from '@angular/core';
import { ReturnService } from '@generated/swagger/inventory-api';
import { ResponseArgsError, takeUntilAborted } from '@isa/common/data-access';
import {
catchResponseArgsErrorPipe,
takeUntilAborted,
} from '@isa/common/data-access';
import { firstValueFrom } from 'rxjs';
import { KeyValueStringAndString } from '../models';
import { logger } from '@isa/core/logging';
@@ -74,29 +77,30 @@ export class RemissionReasonService {
stockId: assignedStock?.id,
}));
let req$ = this.#returnService.ReturnGetReturnReasons({
stockId: assignedStock?.id,
});
let req$ = this.#returnService
.ReturnGetReturnReasons({
stockId: assignedStock?.id,
})
.pipe(catchResponseArgsErrorPipe());
if (abortSignal) {
this.#logger.debug('Request configured with abort signal');
req$ = req$.pipe(takeUntilAborted(abortSignal));
}
const res = await firstValueFrom(req$);
try {
const res = await firstValueFrom(req$);
if (res.error) {
this.#logger.error(
'Failed to fetch return reasons',
new Error(res.message || 'Unknown error'),
);
throw new ResponseArgsError(res);
this.#logger.debug('Successfully fetched return reasons', () => ({
reasonCount: res.result?.length || 0,
}));
return res.result as KeyValueStringAndString[];
} catch (error) {
this.#logger.error('Failed to fetch return reasons', error, () => ({
stockId: assignedStock?.id,
}));
throw error;
}
this.#logger.debug('Successfully fetched return reasons', () => ({
reasonCount: res.result?.length || 0,
}));
return res.result as KeyValueStringAndString[];
}
}

View File

@@ -540,15 +540,15 @@ describe('RemissionReturnReceiptService', () => {
});
});
it('should handle abort signal', async () => {
it('should call API with correct parameters', async () => {
mockReturnService.ReturnCreateAndAssignPackage.mockReturnValue(
of({ result: mockReceipt, error: null }),
);
const abortController = new AbortController();
await service.assignPackage(
{ returnId: 123, receiptId: 456, packageNumber: 'PKG-789' },
abortController.signal,
);
await service.assignPackage({
returnId: 123,
receiptId: 456,
packageNumber: 'PKG-789',
});
expect(mockReturnService.ReturnCreateAndAssignPackage).toHaveBeenCalled();
});
@@ -1052,24 +1052,20 @@ describe('RemissionReturnReceiptService', () => {
});
});
it('should handle abort signal', async () => {
it('should call API with correct parameters', async () => {
// Arrange
mockReturnService.ReturnAddReturnItem.mockReturnValue(
of({ result: mockTuple, error: null }),
);
const abortController = new AbortController();
// Act
await service.addReturnItem(
{
returnId: 1,
receiptId: 2,
returnItemId: 3,
quantity: 4,
inStock: 5,
},
abortController.signal,
);
await service.addReturnItem({
returnId: 1,
receiptId: 2,
returnItemId: 3,
quantity: 4,
inStock: 5,
});
// Assert
expect(mockReturnService.ReturnAddReturnItem).toHaveBeenCalled();
@@ -1163,24 +1159,20 @@ describe('RemissionReturnReceiptService', () => {
});
});
it('should handle abort signal', async () => {
it('should call API with correct parameters', async () => {
// Arrange
mockReturnService.ReturnAddReturnSuggestion.mockReturnValue(
of({ result: mockTuple, error: null }),
);
const abortController = new AbortController();
// Act
await service.addReturnSuggestionItem(
{
returnId: 1,
receiptId: 2,
returnSuggestionId: 3,
quantity: 4,
inStock: 5,
},
abortController.signal,
);
await service.addReturnSuggestionItem({
returnId: 1,
receiptId: 2,
returnSuggestionId: 3,
quantity: 4,
inStock: 5,
});
// Assert
expect(mockReturnService.ReturnAddReturnSuggestion).toHaveBeenCalled();

View File

@@ -1,8 +1,8 @@
import { inject, Injectable } from '@angular/core';
import { ReturnService } from '@generated/swagger/inventory-api';
import {
catchResponseArgsErrorPipe,
ResponseArgs,
ResponseArgsError,
takeUntilAborted,
} from '@isa/common/data-access';
import { firstValueFrom } from 'rxjs';
@@ -102,86 +102,21 @@ export class RemissionReturnReceiptService {
req$ = req$.pipe(takeUntilAborted(abortSignal));
}
const res = await firstValueFrom(req$);
try {
const res = await firstValueFrom(req$.pipe(catchResponseArgsErrorPipe()));
if (res?.error) {
this.#logger.error(
'Failed to fetch completed returns',
new Error(res.message || 'Unknown error'),
);
throw new ResponseArgsError(res);
const returns = (res?.result as Return[]) || [];
this.#logger.debug('Successfully fetched completed returns', () => ({
returnCount: returns.length,
}));
return returns;
} catch (error) {
this.#logger.error('Failed to fetch completed returns', error);
throw error;
}
const returns = (res?.result as Return[]) || [];
this.#logger.debug('Successfully fetched completed returns', () => ({
returnCount: returns.length,
}));
return returns;
}
// /**
// * Fetches a specific remission return receipt by receipt and return IDs.
// * Validates parameters using FetchRemissionReturnReceiptSchema before making the request.
// *
// * @async
// * @param {FetchRemissionReturnParams} params - The receipt and return identifiers
// * @param {FetchRemissionReturnParams} params.receiptId - ID of the receipt to fetch
// * @param {FetchRemissionReturnParams} params.returnId - ID of the return containing the receipt
// * @param {AbortSignal} [abortSignal] - Optional signal to abort the request
// * @returns {Promise<Receipt | undefined>} The receipt object if found, undefined otherwise
// * @throws {ResponseArgsError} When the API request fails
// * @throws {z.ZodError} When parameter validation fails
// *
// * @example
// * const receipt = await service.fetchRemissionReturnReceipt({
// * receiptId: '123',
// * returnId: '456'
// * });
// */
// async fetchRemissionReturnReceipt(
// params: FetchRemissionReturnParams,
// abortSignal?: AbortSignal,
// ): Promise<Receipt | undefined> {
// this.#logger.debug('Fetching remission return receipt', () => ({ params }));
// const { receiptId, returnId } =
// FetchRemissionReturnReceiptSchema.parse(params);
// this.#logger.info('Fetching return receipt from API', () => ({
// receiptId,
// returnId,
// }));
// let req$ = this.#returnService.ReturnGetReturnReceipt({
// receiptId,
// returnId,
// eagerLoading: 2,
// });
// if (abortSignal) {
// this.#logger.debug('Request configured with abort signal');
// req$ = req$.pipe(takeUntilAborted(abortSignal));
// }
// const res = await firstValueFrom(req$);
// if (res?.error) {
// this.#logger.error(
// 'Failed to fetch return receipt',
// new Error(res.message || 'Unknown error'),
// );
// throw new ResponseArgsError(res);
// }
// const receipt = res?.result as Receipt | undefined;
// this.#logger.debug('Successfully fetched return receipt', () => ({
// found: !!receipt,
// }));
// return receipt;
// }
/**
* Fetches a remission return by its ID.
* Validates parameters using FetchReturnSchema before making the request.
@@ -218,22 +153,19 @@ export class RemissionReturnReceiptService {
req$ = req$.pipe(takeUntilAborted(abortSignal));
}
const res = await firstValueFrom(req$);
try {
const res = await firstValueFrom(req$.pipe(catchResponseArgsErrorPipe()));
if (res?.error) {
this.#logger.error(
'Failed to fetch return',
new Error(res.message || 'Unknown error'),
);
throw new ResponseArgsError(res);
const returnData = res?.result as Return | undefined;
this.#logger.debug('Successfully fetched return', () => ({
found: !!returnData,
}));
return returnData;
} catch (error) {
this.#logger.error('Failed to fetch return', error);
throw error;
}
const returnData = res?.result as Return | undefined;
this.#logger.debug('Successfully fetched return', () => ({
found: !!returnData,
}));
return returnData;
}
/**
@@ -279,34 +211,26 @@ export class RemissionReturnReceiptService {
returnGroup,
}));
let req$ = this.#returnService.ReturnCreateReturn({
const req$ = this.#returnService.ReturnCreateReturn({
data: {
supplier: { id: firstSupplier.id },
returnGroup,
},
});
if (abortSignal) {
this.#logger.debug('Request configured with abort signal');
req$ = req$.pipe(takeUntilAborted(abortSignal));
try {
const res = await firstValueFrom(req$.pipe(catchResponseArgsErrorPipe()));
const returnResponse = res as ResponseArgs<Return> | undefined;
this.#logger.debug('Successfully created return', () => ({
found: !!returnResponse,
}));
return returnResponse;
} catch (error) {
this.#logger.error('Failed to create return', error);
throw error;
}
const res = await firstValueFrom(req$);
if (res?.error) {
this.#logger.error(
'Failed to create return',
new Error(res.message || 'Unknown error'),
);
throw new ResponseArgsError(res);
}
const returnResponse = res as ResponseArgs<Return> | undefined;
this.#logger.debug('Successfully created return', () => ({
found: !!returnResponse,
}));
return returnResponse;
}
/**
@@ -345,7 +269,7 @@ export class RemissionReturnReceiptService {
receiptNumber,
}));
let req$ = this.#returnService.ReturnCreateReceipt({
const req$ = this.#returnService.ReturnCreateReceipt({
returnId,
data: {
receiptNumber,
@@ -359,27 +283,19 @@ export class RemissionReturnReceiptService {
},
});
if (abortSignal) {
this.#logger.debug('Request configured with abort signal');
req$ = req$.pipe(takeUntilAborted(abortSignal));
try {
const res = await firstValueFrom(req$.pipe(catchResponseArgsErrorPipe()));
const receiptResponse = res as ResponseArgs<Receipt> | undefined;
this.#logger.debug('Successfully created return receipt', () => ({
found: !!receiptResponse,
}));
return receiptResponse;
} catch (error) {
this.#logger.error('Failed to create return receipt', error);
throw error;
}
const res = await firstValueFrom(req$);
if (res?.error) {
this.#logger.error(
'Failed to create return receipt',
new Error(res.message || 'Unknown error'),
);
throw new ResponseArgsError(res);
}
const receiptResponse = res as ResponseArgs<Receipt> | undefined;
this.#logger.debug('Successfully created return receipt', () => ({
found: !!receiptResponse,
}));
return receiptResponse;
}
/**
@@ -402,7 +318,6 @@ export class RemissionReturnReceiptService {
*/
async assignPackage(
params: AssignPackage,
abortSignal?: AbortSignal,
): Promise<ResponseArgs<Receipt> | undefined> {
this.#logger.debug('Assign package to return receipt', () => ({ params }));
@@ -414,7 +329,7 @@ export class RemissionReturnReceiptService {
packageNumber,
}));
let req$ = this.#returnService.ReturnCreateAndAssignPackage({
const req$ = this.#returnService.ReturnCreateAndAssignPackage({
returnId,
receiptId,
data: {
@@ -422,29 +337,21 @@ export class RemissionReturnReceiptService {
},
});
if (abortSignal) {
this.#logger.debug('Request configured with abort signal');
req$ = req$.pipe(takeUntilAborted(abortSignal));
try {
const res = await firstValueFrom(req$.pipe(catchResponseArgsErrorPipe()));
const receiptWithAssignedPackageResponse = res as
| ResponseArgs<Receipt>
| undefined;
this.#logger.debug('Successfully assigned package', () => ({
found: !!receiptWithAssignedPackageResponse,
}));
return receiptWithAssignedPackageResponse;
} catch (error) {
this.#logger.error('Failed to assign package', error);
throw error;
}
const res = await firstValueFrom(req$);
if (res?.error) {
this.#logger.error(
'Failed to assign package',
new Error(res.message || 'Unknown error'),
);
throw new ResponseArgsError(res);
}
const receiptWithAssignedPackageResponse = res as
| ResponseArgs<Receipt>
| undefined;
this.#logger.debug('Successfully assigned package', () => ({
found: !!receiptWithAssignedPackageResponse,
}));
return receiptWithAssignedPackageResponse;
}
async removeReturnItemFromReturnReceipt(params: {
@@ -452,16 +359,15 @@ export class RemissionReturnReceiptService {
receiptId: number;
receiptItemId: number;
}) {
const res = await firstValueFrom(
this.#returnService.ReturnRemoveReturnItem(params),
);
if (res?.error) {
this.#logger.error(
'Failed to remove item from return receipt',
new Error(res.message || 'Unknown error'),
try {
await firstValueFrom(
this.#returnService
.ReturnRemoveReturnItem(params)
.pipe(catchResponseArgsErrorPipe()),
);
throw new ResponseArgsError(res);
} catch (error) {
this.#logger.error('Failed to remove item from return receipt', error);
throw error;
}
}
@@ -480,16 +386,17 @@ export class RemissionReturnReceiptService {
returnId: number;
receiptId: number;
}): Promise<void> {
const res = await firstValueFrom(
this.#returnService.ReturnCancelReturnReceipt(params),
);
if (res?.error) {
this.#logger.error(
'Failed to cancel return receipt',
new Error(res.message || 'Unknown error'),
try {
await firstValueFrom(
this.#returnService
.ReturnCancelReturnReceipt(params)
.pipe(catchResponseArgsErrorPipe()),
);
throw new ResponseArgsError(res);
} catch (error) {
this.#logger.error('Failed to cancel return receipt', error, () => ({
params,
}));
throw error;
}
}
@@ -502,34 +409,36 @@ export class RemissionReturnReceiptService {
* @throws {ResponseArgsError} When the API request fails
*/
async cancelReturn(params: { returnId: number }): Promise<void> {
const res = await firstValueFrom(
this.#returnService.ReturnCancelReturn(params),
);
if (res?.error) {
this.#logger.error(
'Failed to cancel return',
new Error(res.message || 'Unknown error'),
try {
await firstValueFrom(
this.#returnService
.ReturnCancelReturn(params)
.pipe(catchResponseArgsErrorPipe()),
);
throw new ResponseArgsError(res);
} catch (error) {
this.#logger.error('Failed to cancel return', error, () => ({
params,
}));
throw error;
}
}
async deleteReturnItem(params: { itemId: number }) {
this.#logger.debug('Deleting return item', () => ({ params }));
const res = await firstValueFrom(
this.#returnService.ReturnDeleteReturnItem(params),
);
if (res?.error) {
this.#logger.error(
'Failed to delete return item',
new Error(res.message || 'Unknown error'),
try {
const res = await firstValueFrom(
this.#returnService
.ReturnDeleteReturnItem(params)
.pipe(catchResponseArgsErrorPipe()),
);
throw new ResponseArgsError(res);
}
return res?.result as ReturnItem;
return res?.result as ReturnItem;
} catch (error) {
this.#logger.error('Failed to delete return item', error, () => ({
params,
}));
throw error;
}
}
async updateReturnItemImpediment(params: UpdateItemImpediment) {
@@ -537,24 +446,27 @@ export class RemissionReturnReceiptService {
const { itemId, comment } = UpdateItemImpedimentSchema.parse(params);
const res = await firstValueFrom(
this.#returnService.ReturnReturnItemImpediment({
itemId,
data: {
comment,
},
}),
);
try {
const res = await firstValueFrom(
this.#returnService
.ReturnReturnItemImpediment({
itemId,
data: {
comment,
},
})
.pipe(catchResponseArgsErrorPipe()),
);
if (res?.error) {
return res?.result as ReturnItem;
} catch (error) {
this.#logger.error(
'Failed to update return item impediment',
new Error(res.message || 'Unknown error'),
error,
() => ({ itemId, comment }),
);
throw new ResponseArgsError(res);
throw error;
}
return res?.result as ReturnItem;
}
async updateReturnSuggestionImpediment(params: UpdateItemImpediment) {
@@ -562,22 +474,26 @@ export class RemissionReturnReceiptService {
params,
}));
const { itemId, comment } = UpdateItemImpedimentSchema.parse(params);
const res = await firstValueFrom(
this.#returnService.ReturnReturnSuggestionImpediment({
itemId,
data: {
comment,
},
}),
);
if (res?.error) {
try {
const res = await firstValueFrom(
this.#returnService
.ReturnReturnSuggestionImpediment({
itemId,
data: {
comment,
},
})
.pipe(catchResponseArgsErrorPipe()),
);
return res?.result as ReturnSuggestion;
} catch (error) {
this.#logger.error(
'Failed to update return suggestion impediment',
new Error(res.message || 'Unknown error'),
error,
() => ({ itemId, comment }),
);
throw new ResponseArgsError(res);
throw error;
}
return res?.result as ReturnSuggestion;
}
async completeReturnReceipt({
@@ -588,23 +504,25 @@ export class RemissionReturnReceiptService {
receiptId: number;
}): Promise<Receipt> {
this.#logger.debug('Completing return receipt', () => ({ returnId }));
const res = await firstValueFrom(
this.#returnService.ReturnFinalizeReceipt({
try {
const res = await firstValueFrom(
this.#returnService
.ReturnFinalizeReceipt({
returnId,
receiptId,
data: {},
})
.pipe(catchResponseArgsErrorPipe()),
);
return res?.result as Receipt;
} catch (error) {
this.#logger.error('Failed to complete return receipt', error, () => ({
returnId,
receiptId,
data: {},
}),
);
if (res?.error) {
this.#logger.error(
'Failed to complete return receipt',
new Error(res.message || 'Unknown error'),
);
throw new ResponseArgsError(res);
}));
throw error;
}
return res?.result as Receipt;
}
async completeReturn(params: { returnId: number }): Promise<Return> {
@@ -612,23 +530,24 @@ export class RemissionReturnReceiptService {
returnId: params.returnId,
}));
const res = await firstValueFrom(
this.#returnService.ReturnFinalizeReturn(params),
);
if (res?.error) {
this.#logger.error(
'Failed to complete return',
new Error(res.message || 'Unknown error'),
try {
const res = await firstValueFrom(
this.#returnService
.ReturnFinalizeReturn(params)
.pipe(catchResponseArgsErrorPipe()),
);
throw new ResponseArgsError(res);
this.#logger.info('Successfully completed return', () => ({
returnId: params.returnId,
}));
return res?.result as Return;
} catch (error) {
this.#logger.error('Failed to complete return', error, () => ({
returnId: params.returnId,
}));
throw error;
}
this.#logger.info('Successfully completed return', () => ({
returnId: params.returnId,
}));
return res?.result as Return;
}
async completeReturnGroup(params: { returnGroup: string }) {
@@ -636,23 +555,24 @@ export class RemissionReturnReceiptService {
returnId: params.returnGroup,
}));
const res = await firstValueFrom(
this.#returnService.ReturnFinalizeReturnGroup(params),
);
if (res?.error) {
this.#logger.error(
'Failed to complete return group',
new Error(res.message || 'Unknown error'),
try {
const res = await firstValueFrom(
this.#returnService
.ReturnFinalizeReturnGroup(params)
.pipe(catchResponseArgsErrorPipe()),
);
throw new ResponseArgsError(res);
this.#logger.info('Successfully completed return group', () => ({
returnId: params.returnGroup,
}));
return res?.result as Return[];
} catch (error) {
this.#logger.error('Failed to complete return group', error, () => ({
returnGroup: params.returnGroup,
}));
throw error;
}
this.#logger.info('Successfully completed return group', () => ({
returnId: params.returnGroup,
}));
return res?.result as Return[];
}
async completeReturnReceiptAndReturn(params: {
@@ -703,7 +623,6 @@ export class RemissionReturnReceiptService {
*/
async addReturnItem(
params: AddReturnItem,
abortSignal?: AbortSignal,
): Promise<ReceiptReturnTuple | undefined> {
this.#logger.debug('Adding return item', () => ({ params }));
@@ -718,7 +637,7 @@ export class RemissionReturnReceiptService {
inStock,
}));
let req$ = this.#returnService.ReturnAddReturnItem({
const req$ = this.#returnService.ReturnAddReturnItem({
returnId,
receiptId,
data: {
@@ -728,27 +647,23 @@ export class RemissionReturnReceiptService {
},
});
if (abortSignal) {
this.#logger.debug('Request configured with abort signal');
req$ = req$.pipe(takeUntilAborted(abortSignal));
try {
const res = await firstValueFrom(req$.pipe(catchResponseArgsErrorPipe()));
const updatedReturn = res?.result as ReceiptReturnTuple | undefined;
this.#logger.debug('Successfully added return item', () => ({
found: !!updatedReturn,
}));
return updatedReturn;
} catch (error) {
this.#logger.error('Failed to add return item', error, () => ({
returnId,
receiptId,
returnItemId,
}));
throw error;
}
const res = await firstValueFrom(req$);
if (res?.error) {
this.#logger.error(
'Failed to add return item',
new Error(res.message || 'Unknown error'),
);
throw new ResponseArgsError(res);
}
const updatedReturn = res?.result as ReceiptReturnTuple | undefined;
this.#logger.debug('Successfully added return item', () => ({
found: !!updatedReturn,
}));
return updatedReturn;
}
/**
@@ -775,7 +690,6 @@ export class RemissionReturnReceiptService {
*/
async addReturnSuggestionItem(
params: AddReturnSuggestionItem,
abortSignal?: AbortSignal,
): Promise<ReceiptReturnSuggestionTuple | undefined> {
this.#logger.debug('Adding return suggestion item', () => ({ params }));
@@ -799,7 +713,7 @@ export class RemissionReturnReceiptService {
remainingQuantity,
}));
let req$ = this.#returnService.ReturnAddReturnSuggestion({
const req$ = this.#returnService.ReturnAddReturnSuggestion({
returnId,
receiptId,
data: {
@@ -811,29 +725,25 @@ export class RemissionReturnReceiptService {
},
});
if (abortSignal) {
this.#logger.debug('Request configured with abort signal');
req$ = req$.pipe(takeUntilAborted(abortSignal));
try {
const res = await firstValueFrom(req$.pipe(catchResponseArgsErrorPipe()));
const updatedReturnSuggestion = res?.result as
| ReceiptReturnSuggestionTuple
| undefined;
this.#logger.debug('Successfully added return suggestion item', () => ({
found: !!updatedReturnSuggestion,
}));
return updatedReturnSuggestion;
} catch (error) {
this.#logger.error('Failed to add return suggestion item', error, () => ({
returnId,
receiptId,
returnSuggestionId,
}));
throw error;
}
const res = await firstValueFrom(req$);
if (res?.error) {
this.#logger.error(
'Failed to add return suggestion item',
new Error(res.message || 'Unknown error'),
);
throw new ResponseArgsError(res);
}
const updatedReturnSuggestion = res?.result as
| ReceiptReturnSuggestionTuple
| undefined;
this.#logger.debug('Successfully added return suggestion item', () => ({
found: !!updatedReturnSuggestion,
}));
return updatedReturnSuggestion;
}
/**
@@ -853,22 +763,23 @@ export class RemissionReturnReceiptService {
* receiptId: 456,
* });
*/
async createRemission({
returnGroup,
receiptNumber,
}: {
returnGroup: string | undefined;
receiptNumber: string | undefined;
}): Promise<CreateRemission | undefined> {
async createRemission(
{
returnGroup,
receiptNumber,
}: {
returnGroup: string | undefined;
receiptNumber: string | undefined;
},
abortSignal?: AbortSignal,
): Promise<CreateRemission | undefined> {
this.#logger.debug('Create remission', () => ({
returnGroup,
receiptNumber,
}));
const createdReturn: ResponseArgs<Return> | undefined =
await this.createReturn({
returnGroup,
});
await this.createReturn({ returnGroup }, abortSignal);
if (!createdReturn || !createdReturn.result) {
this.#logger.error('Failed to create return for remission');
@@ -876,10 +787,13 @@ export class RemissionReturnReceiptService {
}
const createdReceipt: ResponseArgs<Receipt> | undefined =
await this.createReceipt({
returnId: createdReturn.result.id,
receiptNumber,
});
await this.createReceipt(
{
returnId: createdReturn.result.id,
receiptNumber,
},
abortSignal,
);
if (!createdReceipt || !createdReceipt.result) {
this.#logger.error('Failed to create return receipt');

View File

@@ -19,8 +19,8 @@ import {
import { firstValueFrom } from 'rxjs';
import {
BatchResponseArgs,
catchResponseArgsErrorPipe,
ListResponseArgs,
ResponseArgsError,
takeUntilAborted,
} from '@isa/common/data-access';
import { logger } from '@isa/core/logging';
@@ -122,16 +122,18 @@ export class RemissionSearchService {
},
});
const res = await firstValueFrom(req$);
try {
const res = await firstValueFrom(req$.pipe(catchResponseArgsErrorPipe()));
if (res.error) {
const error = new ResponseArgsError(res);
this.#logger.error('Failed to fetch required capacity', error);
this.#logger.debug('Successfully fetched required capacity');
return (res?.result ?? []) as ValueTupleOfStringAndInteger[];
} catch (error) {
this.#logger.error('Failed to fetch required capacity', error, () => ({
stockId: parsed.stockId,
supplierId: parsed.supplierId,
}));
throw error;
}
this.#logger.debug('Successfully fetched required capacity');
return (res?.result ?? []) as ValueTupleOfStringAndInteger[];
}
/**
@@ -401,14 +403,18 @@ export class RemissionSearchService {
req = req.pipe(takeUntilAborted(abortSignal));
}
const res = await firstValueFrom(req);
if (res.error) {
const error = new ResponseArgsError(res);
this.#logger.error('Failed to check item addition', error);
try {
const res = await firstValueFrom(req.pipe(catchResponseArgsErrorPipe()));
return res as BatchResponseArgs<ReturnItem>;
} catch (error) {
this.#logger.error(
'Failed to check if items can be added to remission list',
error,
() => ({ stockId: stock.id, itemCount: items.length }),
);
throw error;
}
return res as BatchResponseArgs<ReturnItem>;
}
async addToList(
@@ -437,14 +443,17 @@ export class RemissionSearchService {
})),
});
const res = await firstValueFrom(req$);
try {
const res = await firstValueFrom(req$.pipe(catchResponseArgsErrorPipe()));
if (res.error) {
const error = new ResponseArgsError(res);
this.#logger.error('Failed to add item to remission list', error);
return res.successful?.map((r) => r.value) as ReturnItem[];
} catch (error) {
this.#logger.error(
'Failed to add items to remission list',
error,
() => ({ stockId: stock.id, itemCount: items.length }),
);
throw error;
}
return res.successful?.map((r) => r.value) as ReturnItem[];
}
}

View File

@@ -87,13 +87,13 @@ describe('RemissionStockService', () => {
);
});
it('should throw ResponseArgsError when API returns no result', async () => {
it('should throw Error when API returns no result', async () => {
mockStockService.StockCurrentStock.mockReturnValue(
of({ error: false, result: undefined }),
);
await expect(service.fetchAssignedStock()).rejects.toThrow(
ResponseArgsError,
'Assigned stock has no ID',
);
});
@@ -196,16 +196,17 @@ describe('RemissionStockService', () => {
expect(mockStockService.StockInStock).toHaveBeenCalled();
});
it('should throw ResponseArgsError when API returns no result', async () => {
it('should return empty array when API returns no result', async () => {
// Arrange
mockStockService.StockInStock.mockReturnValue(
of({ error: false, result: undefined }),
);
// Act & Assert
await expect(service.fetchStockInfos(validParams)).rejects.toThrow(
ResponseArgsError,
);
// Act
const result = await service.fetchStockInfos(validParams);
// Assert
expect(result).toBe(undefined);
expect(mockStockService.StockInStock).toHaveBeenCalled();
});

View File

@@ -3,7 +3,10 @@ import { StockService } from '@generated/swagger/inventory-api';
import { firstValueFrom } from 'rxjs';
import { Stock, StockInfo } from '../models';
import { FetchStockInStock, FetchStockInStockSchema } from '../schemas';
import { ResponseArgsError, takeUntilAborted } from '@isa/common/data-access';
import {
catchResponseArgsErrorPipe,
takeUntilAborted,
} from '@isa/common/data-access';
import { logger } from '@isa/core/logging';
import { InFlight, Cache, CacheTimeToLive } from '@isa/common/decorators';
@@ -56,73 +59,74 @@ export class RemissionStockService {
@InFlight()
async fetchAssignedStock(abortSignal?: AbortSignal): Promise<Stock> {
this.#logger.info('Fetching assigned stock from API');
let req$ = this.#stockService.StockCurrentStock();
let req$ = this.#stockService
.StockCurrentStock()
.pipe(catchResponseArgsErrorPipe());
if (abortSignal) {
this.#logger.debug('Request configured with abort signal');
req$ = req$.pipe(takeUntilAborted(abortSignal));
}
const res = await firstValueFrom(req$);
try {
const res = await firstValueFrom(req$);
const result = res.result;
if (result?.id === undefined) {
const error = new Error('Assigned stock has no ID');
this.#logger.error('Invalid stock response', error);
throw error;
}
if (res.error || !res.result) {
this.#logger.error(
'Failed to fetch assigned stock',
new Error(res.message || 'Unknown error'),
);
throw new ResponseArgsError(res);
}
this.#logger.debug('Successfully fetched assigned stock', () => ({
stockId: result.id,
}));
const result = res.result;
if (result.id === undefined) {
const error = new Error('Assigned stock has no ID');
this.#logger.error('Invalid stock response', error);
// TypeScript cannot narrow StockDTO to Stock based on the id check above,
// so we use a minimal type assertion after runtime validation
return result as Stock;
} catch (error) {
this.#logger.error('Failed to fetch assigned stock', error);
throw error;
}
this.#logger.debug('Successfully fetched assigned stock', () => ({
stockId: result.id,
}));
// TypeScript cannot narrow StockDTO to Stock based on the id check above,
// so we use a minimal type assertion after runtime validation
return result as Stock;
}
async fetchStock(
branchId: number,
abortSignal?: AbortSignal,
): Promise<Stock | undefined> {
let req$ = this.#stockService.StockGetStocks();
let req$ = this.#stockService
.StockGetStocks()
.pipe(catchResponseArgsErrorPipe());
if (abortSignal) {
this.#logger.debug('Request configured with abort signal');
req$ = req$.pipe(takeUntilAborted(abortSignal));
}
const res = await firstValueFrom(req$);
try {
const res = await firstValueFrom(req$);
if (res.error || !res.result) {
this.#logger.error(
'Failed to fetch stocks',
new Error(res.message || 'Unknown error'),
);
throw new ResponseArgsError(res);
const stock = res.result?.find((s) => s.branch?.id === branchId);
if (!stock) {
return undefined;
}
if (stock.id === undefined) {
this.#logger.warn('Found stock without ID for branch', () => ({
branchId,
}));
return undefined;
}
// TypeScript cannot narrow StockDTO to Stock based on the id check above,
// so we use a minimal type assertion after runtime validation
return stock as Stock;
} catch (error) {
this.#logger.error('Failed to fetch stock', error, () => ({
branchId,
}));
throw error;
}
const stock = res.result.find((s) => s.branch?.id === branchId);
if (!stock) {
return undefined;
}
if (stock.id === undefined) {
this.#logger.warn('Found stock without ID for branch', () => ({ branchId }));
return undefined;
}
// TypeScript cannot narrow StockDTO to Stock based on the id check above,
// so we use a minimal type assertion after runtime validation
return stock as Stock;
}
/**
@@ -179,30 +183,31 @@ export class RemissionStockService {
itemCount: parsed.itemIds.length,
}));
let req$ = this.#stockService.StockInStock({
stockId: assignedStockId,
articleIds: parsed.itemIds,
});
let req$ = this.#stockService
.StockInStock({
stockId: assignedStockId,
articleIds: parsed.itemIds,
})
.pipe(catchResponseArgsErrorPipe());
if (abortSignal) {
this.#logger.debug('Request configured with abort signal');
req$ = req$.pipe(takeUntilAborted(abortSignal));
}
const res = await firstValueFrom(req$);
try {
const res = await firstValueFrom(req$);
if (res.error || !res.result) {
this.#logger.error(
'Failed to fetch stock info',
new Error(res.message || 'Unknown error'),
);
throw new ResponseArgsError(res);
this.#logger.debug('Successfully fetched stock info', () => ({
itemCount: res.result?.length || 0,
}));
return res.result as StockInfo[];
} catch (error) {
this.#logger.error('Failed to fetch stock info', error, () => ({
stockId: assignedStockId,
}));
throw error;
}
this.#logger.debug('Successfully fetched stock info', () => ({
itemCount: res.result?.length || 0,
}));
return res.result as StockInfo[];
}
}

View File

@@ -0,0 +1,185 @@
import {
effect,
Signal,
untracked,
ResourceStatus,
inject,
} from '@angular/core';
import { injectDialog } from '@isa/ui/dialog';
import { SearchItemToRemitDialogComponent } from '@isa/remission/shared/search-item-to-remit-dialog';
import { RemissionStore, RemissionItem } from '@isa/remission/data-access';
/**
* Configuration for the empty search result handler.
* Provides all necessary signals and callbacks for handling empty search scenarios.
*/
export interface EmptySearchResultHandlerConfig {
/**
* Signal returning the status of the remission resource.
*/
remissionResourceStatus: Signal<ResourceStatus>;
/**
* Signal returning the status of the stock resource.
*/
stockResourceStatus: Signal<ResourceStatus>;
/**
* Signal returning the current search term.
*/
searchTerm: Signal<string | undefined>;
/**
* Signal returning the number of search hits.
*/
hits: Signal<number>;
/**
* Signal indicating whether there is a valid search term.
*/
hasValidSearchTerm: Signal<boolean>;
/**
* Signal indicating whether the search was triggered by user interaction.
*/
searchTriggeredByUser: Signal<boolean>;
/**
* Signal indicating whether a remission has been started.
*/
remissionStarted: Signal<boolean>;
/**
* Signal indicating whether the current list type is "Abteilung".
*/
isDepartment: Signal<boolean>;
/**
* Signal returning the first item in the list (for auto-preselection).
*/
firstItem: Signal<RemissionItem | undefined>;
/**
* Callback to preselect a remission item.
*/
preselectItem: (item: RemissionItem) => void;
/**
* Callback to remit items after dialog selection.
* @param options - Options for the remit operation
*/
remitItems: (options: { addItemFlow: boolean }) => Promise<void>;
/**
* Callback to navigate to the default remission list.
*/
navigateToDefaultList: () => Promise<void>;
/**
* Callback to reload the list and return data.
*/
reloadData: () => void;
}
/**
* Creates an effect that handles scenarios where a search yields no or few results.
*
* This handler implements two behaviors:
* 1. **Auto-Preselection**: When exactly one item is found and remission is started,
* automatically preselects that item for convenience.
* 2. **Empty Search Dialog**: When no items are found after a user-initiated search,
* opens a dialog allowing the user to add items to remit.
*
* @param config - Configuration object containing all required signals and callbacks
* @returns The created effect (for potential cleanup if needed)
*
* @example
* ```typescript
* // In a component
* emptySearchEffect = injectEmptySearchResultHandler({
* remissionResourceStatus: () => this.remissionResource.status(),
* stockResourceStatus: () => this.inStockResource.status(),
* searchTerm: this.searchTerm,
* hits: this.hits,
* // ... other config
* });
* ```
*
* @remarks
* - The effect tracks `remissionResourceStatus`, `stockResourceStatus`, and `searchTerm`
* - Other signals are accessed via `untracked()` to avoid unnecessary re-evaluations
* - The dialog subscription handles async flows for adding items to remission
*/
export const injectEmptySearchResultHandler = (
config: EmptySearchResultHandlerConfig,
) => {
const store = inject(RemissionStore);
const searchItemToRemitDialog = injectDialog(
SearchItemToRemitDialogComponent,
);
return effect(() => {
const status = config.remissionResourceStatus();
const stockStatus = config.stockResourceStatus();
const searchTerm = config.searchTerm();
// Wait until both resources are resolved
if (status !== 'resolved' || stockStatus !== 'resolved') {
return;
}
untracked(() => {
const hits = config.hits();
// Early return conditions - only proceed if:
// - No hits (hits === 0, so !!hits is false)
// - Valid search term exists
// - Search was triggered by user
if (
!!hits ||
!searchTerm ||
!config.hasValidSearchTerm() ||
!config.searchTriggeredByUser()
) {
// #5338 - Auto-select item if exactly one hit after search
if (hits === 1 && config.remissionStarted()) {
store.clearSelectedItems();
const firstItem = config.firstItem();
if (firstItem) {
config.preselectItem(firstItem);
}
}
return;
}
// Open dialog to allow user to add items when search returns no results
searchItemToRemitDialog({
data: {
searchTerm,
},
}).closed.subscribe(async (result) => {
store.clearSelectedItems();
if (result) {
if (config.remissionStarted()) {
// Select all items from dialog result
for (const item of result) {
if (item?.id) {
store.selectRemissionItem(item.id, item);
}
}
// Remit the selected items
await config.remitItems({ addItemFlow: true });
} else if (config.isDepartment()) {
// Navigate to default list if in department mode without active remission
await config.navigateToDefaultList();
return;
}
}
// Always reload data after dialog closes
config.reloadData();
});
});
});
};

View File

@@ -0,0 +1,6 @@
export { RemissionActionComponent } from './remission-action.component';
export {
RemissionActionService,
RemitItemsContext,
RemitItemsOptions,
} from './remission-action.service';

View File

@@ -0,0 +1,21 @@
@if (remissionStarted()) {
<ui-stateful-button
(clicked)="remitItems()"
(action)="remitItems()"
[(state)]="actionService.state"
defaultContent="Remittieren"
defaultWidth="13rem"
[errorContent]="actionService.error()"
errorWidth="32rem"
errorAction="Erneut versuchen"
successContent="Hinzugefügt"
successWidth="20rem"
size="large"
color="brand"
[pending]="actionService.inProgress()"
[disabled]="isDisabled()"
data-what="button"
data-which="remit-items"
>
</ui-stateful-button>
}

View File

@@ -0,0 +1,124 @@
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
input,
output,
} from '@angular/core';
import { StatefulButtonComponent } from '@isa/ui/buttons';
import {
RemissionStore,
RemissionItem,
RemissionListType,
} from '@isa/remission/data-access';
import {
RemissionActionService,
RemitItemsContext,
RemitItemsOptions,
} from './remission-action.service';
/**
* RemissionActionComponent
*
* Standalone component that encapsulates the "Remittieren" (remit) button
* and its associated logic. Manages the remit workflow including:
* - Displaying the stateful button with appropriate states
* - Triggering the remit action
* - Handling loading, success, and error states
*
* @remarks
* This component requires the RemissionActionService to be provided,
* either by itself or by a parent component.
*
* @example
* ```html
* <remi-feature-remission-action
* [getAvailableStockForItem]="getAvailableStockForItem"
* [selectedRemissionListType]="selectedRemissionListType()"
* [disabled]="removeItemInProgress()"
* (actionCompleted)="onActionCompleted()"
* />
* ```
*/
@Component({
selector: 'remi-feature-remission-action',
templateUrl: './remission-action.component.html',
styleUrl: './remission-action.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [StatefulButtonComponent],
providers: [RemissionActionService],
})
export class RemissionActionComponent {
readonly #store = inject(RemissionStore);
readonly actionService = inject(RemissionActionService);
/**
* Function to get available stock for a remission item.
* Required for calculating quantities during remit.
*/
getAvailableStockForItem = input.required<(item: RemissionItem) => number>();
/**
* The currently selected remission list type.
* Required for determining item types during remit.
*/
selectedRemissionListType = input.required<RemissionListType>();
/**
* Additional disabled state from parent component.
* Combined with internal disabled logic.
*/
disabled = input<boolean>(false);
/**
* Emitted when the remit action is completed (success or error).
* Parent component should use this to reload list data.
*/
actionCompleted = output<void>();
/**
* Computed signal indicating whether a remission has been started.
* The button is only visible when remission is started.
*/
remissionStarted = computed(() => this.#store.remissionStarted());
/**
* Computed signal indicating whether there are selected items.
*/
hasSelectedItems = computed(
() => Object.keys(this.#store.selectedItems()).length > 0,
);
/**
* Computed signal for the combined disabled state.
* Button is disabled when:
* - No items are selected
* - An external disabled condition is true
* - A remit operation is in progress
*/
isDisabled = computed(
() =>
!this.hasSelectedItems() ||
this.disabled() ||
this.actionService.inProgress(),
);
/**
* Handles the remit button click.
* Delegates to the action service and emits completion event.
*
* @param options - Options for the remit operation
*/
async remitItems(
options: RemitItemsOptions = { addItemFlow: false },
): Promise<void> {
const context: RemitItemsContext = {
getAvailableStockForItem: this.getAvailableStockForItem(),
selectedRemissionListType: this.selectedRemissionListType(),
};
await this.actionService.remitItems(context, options);
this.actionCompleted.emit();
}
}

View File

@@ -0,0 +1,217 @@
import { inject, Injectable, signal } from '@angular/core';
import { logger } from '@isa/core/logging';
import {
RemissionStore,
RemissionReturnReceiptService,
RemissionListType,
RemissionResponseArgsErrorMessage,
RemissionItem,
getStockToRemit,
getItemType,
} from '@isa/remission/data-access';
import { StatefulButtonState } from '@isa/ui/buttons';
import { injectFeedbackErrorDialog } from '@isa/ui/dialog';
import { firstValueFrom } from 'rxjs';
/**
* Configuration options for the remit items operation.
*/
export interface RemitItemsOptions {
/** Whether this operation is part of an add-item flow (e.g., from search dialog) */
addItemFlow: boolean;
}
/**
* Context required for remitting items.
* Provides stock information lookup for calculating quantities.
*/
export interface RemitItemsContext {
/** Function to get available stock for a remission item */
getAvailableStockForItem: (item: RemissionItem) => number;
/** The currently selected remission list type */
selectedRemissionListType: RemissionListType;
}
/**
* Service responsible for handling the remission action workflow.
* Manages the state and logic for remitting selected items.
*
* This service encapsulates:
* - State management for the remit button (progress, error, success states)
* - The remitItems business logic
* - Error handling and user feedback
* - Navigation after successful remission
*
* @remarks
* This service should be provided at the component level, not root level,
* as it maintains UI state specific to a single remission action context.
*
* @example
* ```typescript
* // In component providers
* providers: [RemissionActionService]
*
* // Usage
* readonly actionService = inject(RemissionActionService);
* await this.actionService.remitItems(context);
* ```
*/
@Injectable()
export class RemissionActionService {
readonly #store = inject(RemissionStore);
readonly #remissionReturnReceiptService = inject(
RemissionReturnReceiptService,
);
readonly #errorDialog = injectFeedbackErrorDialog();
readonly #logger = logger(() => ({
service: 'RemissionActionService',
}));
/**
* Signal representing the current state of the remit button.
*/
readonly state = signal<StatefulButtonState>('default');
/**
* Signal containing the current error message, if any.
*/
readonly error = signal<string | null>(null);
/**
* Signal indicating whether a remit operation is currently in progress.
*/
readonly inProgress = signal(false);
/**
* Computed signal indicating whether there are selected items in the store.
*/
get hasSelectedItems(): boolean {
return Object.keys(this.#store.selectedItems()).length > 0;
}
/**
* Computed signal indicating whether a remission has been started.
*/
get remissionStarted(): boolean {
return this.#store.remissionStarted();
}
/**
* Initiates the process to remit selected items.
*
* If remission is already started, items are added directly to the remission.
* Handles the full workflow including:
* - Preventing duplicate operations
* - Processing each selected item
* - Error handling with user feedback
* - State management for UI feedback
*
* @param context - Context providing stock information and list type
* @param options - Options for the remit operation
* @returns A promise that resolves when the operation is complete
*/
async remitItems(
context: RemitItemsContext,
options: RemitItemsOptions = { addItemFlow: false },
): Promise<void> {
if (this.inProgress()) {
return;
}
this.inProgress.set(true);
try {
await this.#processSelectedItems(context, options);
this.state.set('success');
} catch (error) {
await this.#handleRemitItemsError(error);
}
this.#store.clearSelectedItems();
this.inProgress.set(false);
}
/**
* Processes all selected items for remission.
* @param context - Context providing stock information and list type
* @param options - Options for the remit operation
*/
async #processSelectedItems(
context: RemitItemsContext,
options: RemitItemsOptions,
): Promise<void> {
// #5273, #5280 Fix - Bei gestarteter Remission dürfen Items die über den AddItemDialog
// hinzugefügt und direkt remittiert werden, nur als ReturnItem (statt ReturnSuggestion)
// zum WBS hinzugefügt werden
const remissionListType = options.addItemFlow
? RemissionListType.Pflicht
: context.selectedRemissionListType;
const selected = this.#store.selectedItems();
const quantities = this.#store.selectedQuantity();
for (const [remissionItemId, item] of Object.entries(selected)) {
const returnId = this.#store.returnId();
const receiptId = this.#store.receiptId();
const remissionItemIdNumber = Number(remissionItemId);
const quantity = quantities[remissionItemIdNumber];
const inStock = context.getAvailableStockForItem(item);
const stockToRemit = getStockToRemit({
remissionItem: item,
remissionListType,
availableStock: inStock,
});
const quantityToRemit = quantity ?? stockToRemit;
if (returnId && receiptId) {
await this.#remissionReturnReceiptService.remitItem({
itemId: remissionItemIdNumber,
addItem: {
returnId,
receiptId,
quantity: quantityToRemit,
inStock,
impedimentComment: stockToRemit > quantity ? 'Restmenge' : '',
remainingQuantity:
isNaN(quantity) || inStock - quantity <= 0
? undefined
: inStock - quantity,
},
type: getItemType(item, remissionListType),
});
}
}
}
/**
* Handles errors that occur during the remission of items.
* Logs the error, displays an error dialog, and updates state.
*
* @param error - The error object caught during the remission process
*/
async #handleRemitItemsError(error: unknown): Promise<void> {
this.#logger.error('Failed to remit items', error as Error);
const errorMessage =
(error as { error?: { message?: string }; message?: string })?.error
?.message ??
(error as { message?: string })?.message ??
'Artikel konnten nicht remittiert werden';
this.error.set(errorMessage);
await firstValueFrom(
this.#errorDialog({
data: {
errorMessage,
},
}).closed,
);
if (errorMessage === RemissionResponseArgsErrorMessage.AlreadyCompleted) {
this.#store.clearState();
}
this.state.set('error');
}
}

View File

@@ -59,23 +59,10 @@
[class.scroll-top-button-spacing-bottom]="remissionStarted()"
></utils-scroll-top-button>
@if (remissionStarted()) {
<ui-stateful-button
class="flex flex-col self-end fixed bottom-6 mr-6"
(clicked)="remitItems()"
(action)="remitItems()"
[(state)]="remitItemsState"
defaultContent="Remittieren"
defaultWidth="13rem"
[errorContent]="remitItemsError()"
errorWidth="32rem"
errorAction="Erneut versuchen"
successContent="Hinzugefügt"
successWidth="20rem"
size="large"
color="brand"
[pending]="remitItemsInProgress()"
[disabled]="!hasSelectedItems() || removeItemInProgress()"
>
</ui-stateful-button>
}
<remi-feature-remission-action
class="flex flex-col self-end fixed bottom-6 mr-6"
[getAvailableStockForItem]="getAvailableStockForItem.bind(this)"
[selectedRemissionListType]="selectedRemissionListType()"
[disabled]="removeItemInProgress()"
(actionCompleted)="reloadListAndReturnData()"
></remi-feature-remission-action>

View File

@@ -3,10 +3,9 @@ import {
Component,
inject,
computed,
effect,
untracked,
signal,
linkedSignal,
viewChild,
} from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import {
@@ -29,12 +28,9 @@ import {
createRemissionProductGroupResource,
} from './resources';
import { injectRemissionListType } from './injects/inject-remission-list-type';
import { injectEmptySearchResultHandler } from './injects/inject-empty-search-result-handler';
import { RemissionListItemComponent } from './remission-list-item/remission-list-item.component';
import {
IconButtonComponent,
StatefulButtonComponent,
StatefulButtonState,
} from '@isa/ui/buttons';
import { IconButtonComponent } from '@isa/ui/buttons';
import {
ReturnItem,
StockInfo,
@@ -42,22 +38,16 @@ import {
RemissionStore,
RemissionItem,
calculateAvailableStock,
RemissionReturnReceiptService,
getStockToRemit,
RemissionListType,
RemissionResponseArgsErrorMessage,
UpdateItem,
orderByListItems,
getItemType,
getStockToRemit,
} from '@isa/remission/data-access';
import { injectDialog, injectFeedbackErrorDialog } from '@isa/ui/dialog';
import { SearchItemToRemitDialogComponent } from '@isa/remission/shared/search-item-to-remit-dialog';
import { RemissionReturnCardComponent } from './remission-return-card/remission-return-card.component';
import { logger } from '@isa/core/logging';
import { RemissionListDepartmentElementsComponent } from './remission-list-department-elements/remission-list-department-elements.component';
import { injectTabId } from '@isa/core/tabs';
import { RemissionListEmptyStateComponent } from './remission-list-empty-state/remission-list-empty-state.component';
import { firstValueFrom } from 'rxjs';
import { RemissionActionComponent } from './remission-action';
function querySettingsFactory() {
return inject(ActivatedRoute).snapshot.data['querySettings'];
@@ -96,7 +86,7 @@ function querySettingsFactory() {
RemissionListSelectComponent,
RemissionListItemComponent,
IconButtonComponent,
StatefulButtonComponent,
RemissionActionComponent,
RemissionListDepartmentElementsComponent,
RemissionListEmptyStateComponent,
ScrollTopButtonComponent,
@@ -125,8 +115,10 @@ export class RemissionListComponent {
*/
activatedTabId = injectTabId();
searchItemToRemitDialog = injectDialog(SearchItemToRemitDialogComponent);
errorDialog = injectFeedbackErrorDialog();
/**
* Reference to the RemissionActionComponent for triggering remit actions.
*/
remissionAction = viewChild(RemissionActionComponent);
/**
* FilterService instance for managing filter state and queries.
@@ -140,20 +132,6 @@ export class RemissionListComponent {
*/
#store = inject(RemissionStore);
/**
* RemissionReturnReceiptService instance for handling return receipt operations.
* @private
*/
#remissionReturnReceiptService = inject(RemissionReturnReceiptService);
/**
* Logger instance for logging component events and errors.
* @private
*/
#logger = logger(() => ({
component: 'RemissionListComponent',
}));
/**
* Restores scroll position when navigating back to this component.
*/
@@ -294,32 +272,6 @@ export class RemissionListComponent {
*/
remissionStarted = computed(() => this.#store.remissionStarted());
/**
* Computed signal indicating whether there are selected items in the remission store.
* @returns True if there are selected items, false otherwise.
*/
hasSelectedItems = computed(() => {
return Object.keys(this.#store.selectedItems()).length > 0;
});
/**
* Signal for the current remission list type.
* @returns The current RemissionListType.
*/
remitItemsState = signal<StatefulButtonState>('default');
/**
* Signal for any error messages related to remission items.
* @returns Error message string or null if no error.
*/
remitItemsError = signal<string | null>(null);
/**
* Signal indicating whether remission items are currently being processed.
* @returns True if in progress, false otherwise.
*/
remitItemsInProgress = signal(false);
/**
* Commits the current filter state and triggers a new search.
*
@@ -414,132 +366,35 @@ export class RemissionListComponent {
});
/**
* Effect that handles scenarios where a search yields no results.
* If the search was user-initiated and returned no hits, it opens a dialog
* to allow the user to add a new item to remit.
* If only one hit is found and a remission is started, it selects that item automatically.
* This effect runs whenever the remission or stock resource status changes,
* or when the search term changes.
* It ensures that the user is prompted appropriately based on their actions and the current state of the remission process.
* It also checks if the remission is started or if the list type is 'Abteilung' to determine navigation behavior.
* @see {@link
* https://angular.dev/guide/effects} for more information on Angular effects.
* @remarks This effect uses `untracked` to avoid unnecessary re-evaluations
* when accessing certain signals.
* Computed signal returning the first item in the list.
* Used for auto-preselection when exactly one item is found.
*/
emptySearchResultEffect = effect(() => {
const status = this.remissionResource.status();
const stockStatus = this.inStockResource.status();
const searchTerm: string | undefined = this.searchTerm();
#firstItem = computed(() => this.items()[0]);
if (status !== 'resolved' || stockStatus !== 'resolved') {
return;
}
untracked(() => {
const hits = this.hits();
// #5338 - Select item automatically if only one hit after search
if (
!!hits ||
!searchTerm ||
!this.hasValidSearchTerm() ||
!this.searchTriggeredByUser()
) {
if (hits === 1 && this.remissionStarted()) {
this.#store.clearSelectedItems();
this.preselectRemissionItem(this.items()[0]);
}
return;
}
this.searchItemToRemitDialog({
data: {
searchTerm,
},
}).closed.subscribe(async (result) => {
this.#store.clearSelectedItems();
if (result) {
if (this.remissionStarted()) {
for (const item of result) {
if (item?.id) {
this.#store.selectRemissionItem(item.id, item);
}
}
await this.remitItems({ addItemFlow: true });
} else if (this.isDepartment()) {
return await this.navigateToDefaultRemissionList();
}
}
this.reloadListAndReturnData();
});
});
});
// TODO: Improvement - In Separate Komponente zusammen mit Remi-Button Auslagern
/**
* Initiates the process to remit selected items.
* If remission is already started, items are added directly to the remission.
* If not, navigates to the default remission list.
* @param options - Options for remitting items, including whether it's part of an add-item flow.
* @returns A promise that resolves when the operation is complete.
* Effect that handles scenarios where a search yields no or few results.
* - Auto-preselects item when exactly one hit is found
* - Opens dialog to add items when no results are found
*
* @see injectEmptySearchResultHandler for implementation details
*/
async remitItems(options: { addItemFlow: boolean } = { addItemFlow: false }) {
if (this.remitItemsInProgress()) {
return;
}
this.remitItemsInProgress.set(true);
try {
// #5273, #5280 Fix - Bei gestarteter Remission dürfen Items die über den AddItemDialog hinzugefügt und direkt remittiert werden, nur als ReturnItem (statt ReturnSuggestion) zum WBS hinzugefügt werden
const remissionListType = options.addItemFlow
? RemissionListType.Pflicht
: this.selectedRemissionListType();
const selected = this.#store.selectedItems();
const quantities = this.#store.selectedQuantity();
for (const [remissionItemId, item] of Object.entries(selected)) {
const returnId = this.#store.returnId();
const receiptId = this.#store.receiptId();
const remissionItemIdNumber = Number(remissionItemId);
const quantity = quantities[remissionItemIdNumber];
const inStock = this.getAvailableStockForItem(item);
const stockToRemit = getStockToRemit({
remissionItem: item,
remissionListType,
availableStock: inStock,
});
const quantityToRemit = quantity ?? stockToRemit;
if (returnId && receiptId) {
await this.#remissionReturnReceiptService.remitItem({
itemId: remissionItemIdNumber,
addItem: {
returnId,
receiptId,
quantity: quantityToRemit,
inStock,
impedimentComment: stockToRemit > quantity ? 'Restmenge' : '',
remainingQuantity:
isNaN(quantity) || inStock - quantity <= 0
? undefined
: inStock - quantity,
},
type: getItemType(item, remissionListType),
});
}
}
this.remitItemsState.set('success');
this.reloadListAndReturnData();
} catch (error) {
await this.handleRemitItemsError(error);
}
this.#store.clearSelectedItems();
this.remitItemsInProgress.set(false);
}
emptySearchResultEffect = injectEmptySearchResultHandler({
remissionResourceStatus: computed(() => this.remissionResource.status()),
stockResourceStatus: computed(() => this.inStockResource.status()),
searchTerm: this.searchTerm,
hits: this.hits,
hasValidSearchTerm: this.hasValidSearchTerm,
searchTriggeredByUser: this.searchTriggeredByUser,
remissionStarted: this.remissionStarted,
isDepartment: this.isDepartment,
firstItem: this.#firstItem,
preselectItem: (item) => this.preselectRemissionItem(item),
remitItems: async (options) => {
await this.remissionAction()?.remitItems(options);
},
navigateToDefaultList: () => this.navigateToDefaultRemissionList(),
reloadData: () => this.reloadListAndReturnData(),
});
/**
* Reloads the remission list and return data.
@@ -572,41 +427,6 @@ export class RemissionListComponent {
}
}
/**
* Handles errors that occur during the remission of items.
* Logs the error, displays an error dialog, and reloads the list and return data.
* If the error indicates that the remission is already completed, it clears the remission state.
* Sets the stateful button to 'error' to indicate the failure.
* @param error - The error object caught during the remission process.
* @returns A promise that resolves when the error handling is complete.
*/
async handleRemitItemsError(error: any) {
this.#logger.error('Failed to remit items', error);
const errorMessage =
error?.error?.message ??
error?.message ??
'Artikel konnten nicht remittiert werden';
this.remitItemsError.set(errorMessage);
await firstValueFrom(
this.errorDialog({
data: {
errorMessage,
},
}).closed,
);
if (errorMessage === RemissionResponseArgsErrorMessage.AlreadyCompleted) {
this.#store.clearState();
}
this.reloadListAndReturnData();
this.remitItemsState.set('error'); // Stateful-Button auf Error setzen
}
/**
* Navigates to the default remission list based on the current activated tab ID.
* This method is used to redirect the user to the remission list after completing or starting a remission.

View File

@@ -63,6 +63,18 @@
}
</div>
}
<button
type="button"
uiButton
color="tertiary"
size="large"
(click)="abortRemission()"
class="fixed right-[15rem] bottom-6"
>
Warenbegleitschein abbrechen
</button>
@if (!returnLoading() && !returnData()?.completed) {
<lib-remission-return-receipt-complete
[returnId]="returnId()"

View File

@@ -17,6 +17,7 @@ import {
getPackageNumbersFromReturn,
getReceiptItemsFromReturn,
getReceiptNumberFromReturn,
RemissionStore,
} from '@isa/remission/data-access';
import { EmptyStateComponent } from '@isa/ui/empty-state';
import { EMPTY_WBS_DESCRIPTION, EMPTY_WBS_TITLE } from './constants';
@@ -24,6 +25,8 @@ import {
RemissionReturnReceiptActionsComponent,
RemissionReturnReceiptCompleteComponent,
} from '@isa/remission/shared/return-receipt-actions';
import { Router } from '@angular/router';
import { injectTabId } from '@isa/core/tabs';
@Component({
selector: 'remi-remission-return-receipt-details',
@@ -53,6 +56,15 @@ export class RemissionReturnReceiptDetailsComponent {
/** Angular Location service for navigation */
location = inject(Location);
/** Remission store for managing remission state */
#store = inject(RemissionStore);
/** Angular Router for navigation */
#router = inject(Router);
/** Injects the current activated tab ID as a signal. */
#tabId = injectTabId();
/**
* Required input for the return ID.
* Automatically coerced to a number from string input.
@@ -111,4 +123,9 @@ export class RemissionReturnReceiptDetailsComponent {
const returnData = this.returnData();
return getPackageNumbersFromReturn(returnData!) !== '';
});
async abortRemission() {
this.#store.clearState();
await this.#router.navigate(['/', this.#tabId(), 'remission']);
}
}

View File

@@ -12,7 +12,7 @@
></filter-search-bar-input>
}
<div class="flex flex-row gap-4 items-center">
<div class="flex flex-row gap-4 items-center flex-wrap justify-end">
<ng-content></ng-content>
@for (switchFilter of switchFilters(); track switchFilter.filter.key) {

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