mirror of
https://dev.azure.com/hugendubel/ISA/_git/ISA-Frontend
synced 2025-12-31 09:37:15 +01:00
Compare commits
25 Commits
86b0493591
...
feature/53
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a9a80a192e | ||
|
|
3d82e7f0af | ||
|
|
3101e8e8e0 | ||
|
|
a3415e450d | ||
|
|
de3edaa0f9 | ||
|
|
964a6026a0 | ||
|
|
83ad5f526e | ||
|
|
ccc5285602 | ||
|
|
7200eaefbf | ||
|
|
39e56a275e | ||
|
|
6c41214d69 | ||
|
|
6e55b7b0da | ||
|
|
5711a75188 | ||
|
|
3696fb5b2d | ||
|
|
7e7721b222 | ||
|
|
14be1365bd | ||
|
|
d5324675ef | ||
|
|
f10338a48b | ||
|
|
aa57d27924 | ||
|
|
6cb9aea7d1 | ||
|
|
fdfb54a3a0 | ||
|
|
5f94549539 | ||
|
|
aee63711e4 | ||
|
|
ee9f030a99 | ||
|
|
7884e1af32 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -80,3 +80,6 @@ CLAUDE.md
|
||||
*.pyc
|
||||
.vite
|
||||
reports/
|
||||
|
||||
# Local iPad dev setup (proxy)
|
||||
/local-dev/
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
48
apps/isa-app/src/ui/modal/dialog/dialog.helper.ts
Normal file
48
apps/isa-app/src/ui/modal/dialog/dialog.helper.ts
Normal 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);
|
||||
};
|
||||
152
apps/isa-app/src/ui/modal/dialog/dialog.model.spec.ts
Normal file
152
apps/isa-app/src/ui/modal/dialog/dialog.model.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -118,6 +118,11 @@ export interface ReceiptDTO extends EntityDTOBaseOfReceiptDTOAndIReceipt{
|
||||
*/
|
||||
receiptNumber?: string;
|
||||
|
||||
/**
|
||||
* Subtype of the receipt / Beleg-Unterart
|
||||
*/
|
||||
receiptSubType?: string;
|
||||
|
||||
/**
|
||||
* Belegtext
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}`;
|
||||
};
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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('; ');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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$);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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.
|
||||
@@ -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: {},
|
||||
},
|
||||
];
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(),
|
||||
);
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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'],
|
||||
},
|
||||
},
|
||||
}));
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
|
||||
@@ -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()"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
export { RemissionActionComponent } from './remission-action.component';
|
||||
export {
|
||||
RemissionActionService,
|
||||
RemitItemsContext,
|
||||
RemitItemsOptions,
|
||||
} from './remission-action.service';
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()"
|
||||
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user