Compare commits

...

26 Commits

Author SHA1 Message Date
Nino
a4241cbd7a Merge branch 'release/4.0' 2025-08-14 16:40:46 +02:00
Nino Righi
ac728f2dd9 Merged PR 1912: hotfix(isa-app-ui/shared-searchbox): improve component initialization and met...
hotfix(isa-app-ui/shared-searchbox): improve component initialization and method safety

Enhance searchbox component reliability by addressing initialization
issues and improving method safety across both shared and ui implementations.

Key changes:
- Fix potential null reference errors in cancel search functionality
- Improve method parameter typing with explicit defaults
- Add proper initialization for ControlValueAccessor callbacks
- Enhance component property initialization with explicit types
- Add hintCleared output event for better hint management

These changes resolve runtime errors and improve type safety
for the searchbox components used throughout the application.

Refs: #5245
2025-08-07 17:55:25 +00:00
Nino Righi
c2f393d249 Merged PR 1911: hotfix(isa-app-store, core-storage): prevent caching of erroneous user state
hotfix(isa-app-store, core-storage): prevent caching of erroneous user state

Remove shareReplay(1) operator from user state observable to ensure
fresh state retrieval on each request. This prevents the system from
retaining and reusing failed or invalid state data across multiple
operations.

The current implementation now makes two API calls (GET + POST) per
set operation to guarantee the latest state is always used, trading
performance for reliability in error scenarios.

Refs: #5270, #5249
2025-08-06 15:47:49 +00:00
Nino Righi
0addf392b6 Merged PR 1901: hotfix(return-summary): disable navigation during return processing
hotfix(return-summary): disable navigation during return processing

Replace Router navigation with Location.back() for better UX and add
disabled states to prevent user actions during pending operations.

Changes:
- Replace navigateBack() method with direct Location.back() calls
- Add returnItemsAndPrintReciptPending input to ReturnSummaryItemComponent
- Disable edit and back buttons when return operation is pending
- Update parent component to pass pending state to child components
- Fix template binding to use computed pending status signal

This prevents users from navigating away during critical return
operations and provides consistent disabled states across the UI.

Ref: #5257
2025-07-31 16:41:59 +00:00
Lorenz Hilpert
afe6c6abcc chore: update package.json to override stylus version to 0.64.0 and ensure jsdom is set to 26.0.0 2025-07-23 17:29:37 +02:00
Lorenz Hilpert
6f9d4d9218 Merge branch 'release/4.0' 2025-07-23 16:35:08 +02:00
Lorenz Hilpert
e674378080 Merged PR 1883: fix(return-details): update email validation and improve error handling
fix(return-details): update email validation and improve error handling

Refs: #5211
2025-07-14 14:57:41 +00:00
Nino Righi
7c907645dc Merged PR 1880: hotfix(oms-data-access): initial implementation of OMS data access layer
hotfix(oms-data-access): initial implementation of OMS data access layer

Introduce the foundational OMS data access module, including service scaffolding and integration points for future API communication. This establishes a clear separation of concerns for order management system data retrieval and manipulation, following project architecture guidelines.

Ref: #5210
2025-07-10 11:32:42 +00:00
Nino Righi
6fee35c756 Merged PR 1872: fix(isa-app-moment-locale): correct locale initialization for date formatting
fix(isa-app-moment-locale): correct locale initialization for date formatting

Ensures proper setup of moment.js locale in the ISA app to provide accurate date and time formatting for users. Addresses issues with incorrect or inconsistent locale application.

Ref: #5188
2025-06-25 08:35:43 +00:00
Nino Righi
c15077aa86 Merged PR 1870: fix(oms-return-search): fix display and logic issues in return search results
fix(oms-return-search): fix display and logic issues in return search results

Resolve display inconsistencies and correct logic in the return search result component to improve user experience and maintain alignment with design and business requirements.

Ref: #5009
2025-06-23 21:23:27 +00:00
Nino Righi
f051a97e53 Merged PR 1871: fix(ui-dropdown): improve dropdown usability and conditional rendering
fix(ui-dropdown): improve dropdown usability and conditional rendering

Refines the logic for displaying quantity and product category dropdowns in the return details order group item controls. Ensures dropdowns are only shown when appropriate and maintains accessibility and user experience.

Ref: #5189
2025-06-23 15:32:56 +00:00
Nino Righi
1b26a44a37 Merged PR 1869: fix(oms-task-list-item): address styling and layout issues in return task lis...
fix(oms-task-list-item): address styling and layout issues in return task list item

Improves SCSS for the return task list item component to ensure consistent appearance and resolve layout inconsistencies. Enhances maintainability and visual alignment with design standards.

Ref: #5191
2025-06-23 15:25:34 +00:00
Nino Righi
80b2508708 Merged PR 1868: fix(oms-return-search): resolve issues in return search result item rendering
fix(oms-return-search): resolve issues in return search result item rendering

Corrects rendering logic and improves template structure for the return search result item component. Ensures compliance with Angular control flow best practices and enhances maintainability.

Ref: #5190
2025-06-23 15:24:26 +00:00
Nino
e9affd2359 fix(return-details): Small Layout Fix, Refs: #5171 2025-06-17 16:52:03 +02:00
Nino
8f8b9153b0 Merge branch 'develop' into release/4.0 2025-06-17 16:45:37 +02:00
Lorenz Hilpert
9a4121e2bf fix(return-details): correct storage key retrieval in ReturnDetailsStore 2025-06-16 10:53:58 +02:00
Lorenz Hilpert
50b7f21394 Merge branch 'develop' into release/4.0 2025-06-12 21:12:08 +02:00
Lorenz Hilpert
a67375557d Merge branch 'develop' into release/4.0 2025-06-02 11:41:54 +02:00
Lorenz Hilpert
6e7c56fcb9 style(errors): standardize quotation marks in error exports 2025-05-28 21:32:41 +02:00
Lorenz Hilpert
05e257b922 Merge branch 'develop' into release/4.0 2025-05-13 18:52:00 +02:00
Lorenz Hilpert
d7d61915fa Merge branch 'develop' into release/4.0 2025-05-13 18:36:09 +02:00
Nino
d0220b6246 Merge branch 'develop' into release/4.0 2025-05-09 17:26:47 +02:00
Nino
32336ba5b4 Update index file return data-access 2025-05-09 12:13:25 +02:00
Nino
1f26d5285b Merge branch 'develop' into release/4.0 2025-05-09 12:12:19 +02:00
Michael Auer
be0bff0535 Cherry Pick: PR 1824: ISA-Frontend - Expliziter Pfad für Traefik IngressRoute
(cherry picked from commit c9b2762bbc)
2025-02-28 09:36:06 +01:00
Lorenz Hilpert
cb7391e66f Update version numbers in azure-pipelines.yml to 4.0 2025-02-10 10:43:23 +01:00
45 changed files with 3353 additions and 3010 deletions

View File

@@ -4,7 +4,7 @@ applyTo: '**'
// This file is automatically generated by Nx Console
You are in an nx workspace using Nx 20.4.6 and npm as the package manager.
You are in an nx workspace using Nx 21.2.1 and npm as the package manager.
You have access to the Nx MCP server and the tools it provides. Use them. Follow these guidelines in order to best help the user:
@@ -28,5 +28,13 @@ If the user wants to generate something, use the following flow:
- read the generator log file using the 'nx_read_generator_log' tool
- use the information provided in the log file to answer the user's question or continue with what they were doing
# Running Tasks Guidelines
If the user wants help with tasks or commands (which include keywords like "test", "build", "lint", or other similar actions), use the following flow:
- Use the 'nx_current_running_tasks_details' tool to get the list of tasks (this can include tasks that were completed, stopped or failed).
- If there are any tasks, ask the user if they would like help with a specific task then use the 'nx_current_running_task_output' tool to get the terminal output for that task/command
- Use the terminal output from 'nx_current_running_task_output' to see what's wrong and help the user fix their problem. Use the appropriate tools if necessary
- If the user would like to rerun the task or command, always use `nx run <taskId>` to rerun in the terminal. This will ensure that the task will run in the nx context and will be run the same way it originally executed
- If the task was marked as "continuous" do not offer to rerun the task. This task is already running and the user can see the output in the terminal. You can use 'nx_current_running_task_output' to get the output of the task to verify the output.

View File

@@ -1,11 +1,16 @@
import { HttpErrorResponse } from '@angular/common/http';
import { ErrorHandler, Injectable } from '@angular/core';
import { AuthService } from '@core/auth';
import { DialogModel, UiDialogModalComponent, UiErrorModalComponent, UiModalService } from '@ui/modal';
import { IsaLogProvider } from './isa.log-provider';
import { LogLevel } from '@core/logger';
import { HttpErrorResponse } from "@angular/common/http";
import { ErrorHandler, Injectable } from "@angular/core";
import { AuthService } from "@core/auth";
import {
DialogModel,
UiDialogModalComponent,
UiErrorModalComponent,
UiModalService,
} from "@ui/modal";
import { IsaLogProvider } from "./isa.log-provider";
import { LogLevel } from "@core/logger";
@Injectable({ providedIn: 'root' })
@Injectable({ providedIn: "root" })
export class IsaErrorHandler implements ErrorHandler {
constructor(
private _modal: UiModalService,
@@ -17,7 +22,7 @@ export class IsaErrorHandler implements ErrorHandler {
console.error(error);
// Bei Klick auf Abbrechen auf der Login Seite erneut zur Login Seite weiterleiten
if (error?.type === 'token_error') {
if (error?.type === "token_error") {
this._authService.login();
return;
}
@@ -26,11 +31,14 @@ export class IsaErrorHandler implements ErrorHandler {
await this._modal
.open({
content: UiDialogModalComponent,
title: 'Sitzung abgelaufen',
title: "Sitzung abgelaufen",
data: {
handleCommand: false,
content: 'Sie waren zu lange nicht in der ISA aktiv. Bitte melden Sie sich erneut an',
actions: [{ command: 'CLOSE', selected: true, label: 'Erneut anmelden' }],
content:
"Sie waren zu lange nicht in der ISA aktiv. Bitte melden Sie sich erneut an",
actions: [
{ command: "CLOSE", selected: true, label: "Erneut anmelden" },
],
} as DialogModel,
})
.afterClosed$.toPromise();
@@ -39,7 +47,11 @@ export class IsaErrorHandler implements ErrorHandler {
return;
}
this._isaLogProvider.log(LogLevel.ERROR, 'Client Error', error);
try {
this._isaLogProvider.log(LogLevel.ERROR, "Client Error", error);
} catch (logError) {
console.error("Error logging to IsaLogProvider:", logError);
}
// this._modal.open({
// content: UiErrorModalComponent,

View File

@@ -1,28 +1,36 @@
import { Injectable, Injector } from '@angular/core';
import { LogLevel, LogProvider } from '@core/logger';
import { UserStateService } from '@generated/swagger/isa-api';
import { environment } from '../../environments/environment';
import { Injectable } from "@angular/core";
import { LogLevel, LogProvider } from "@core/logger";
import { UserStateService } from "@generated/swagger/isa-api";
import { environment } from "../../environments/environment";
@Injectable({ providedIn: 'root' })
@Injectable({ providedIn: "root" })
export class IsaLogProvider implements LogProvider {
static InfoService: UserStateService | undefined;
constructor() {}
log(logLevel: LogLevel, message: string, error: Error, ...optionalParams: any[]): void {
if (!environment.production && (logLevel === LogLevel.WARN || logLevel === LogLevel.ERROR)) {
IsaLogProvider.InfoService?.UserStateSaveLog({
logType: logLevel,
message: message,
content: JSON.stringify({
error: error?.name,
message: error?.message,
stack: error?.stack,
data: optionalParams,
}),
})
.toPromise()
.catch(() => {});
log(
logLevel: LogLevel,
message: string,
error: Error,
...optionalParams: any[]
): void {
try {
if (
!environment.production &&
(logLevel === LogLevel.WARN || logLevel === LogLevel.ERROR)
) {
IsaLogProvider.InfoService?.UserStateSaveLog({
logType: logLevel,
message: message,
content: JSON.stringify({
error: error?.name,
message: error?.message,
stack: error?.stack,
data: optionalParams,
}),
}).toPromise();
}
} catch (error) {
console.error("Error logging to InfoService:", error);
}
}
}

View File

@@ -1,18 +1,18 @@
import { Injectable } from '@angular/core';
import { Logger, LogLevel } from '@core/logger';
import { Store } from '@ngrx/store';
import { debounceTime, switchMap, takeUntil } from 'rxjs/operators';
import { RootState } from './root.state';
import packageInfo from 'packageJson';
import { environment } from '../../environments/environment';
import { Subject } from 'rxjs';
import { AuthService } from '@core/auth';
import { injectStorage, UserStorageProvider } from '@isa/core/storage';
import { isEqual } from 'lodash';
import { Injectable } from "@angular/core";
import { Logger, LogLevel } from "@core/logger";
import { Store } from "@ngrx/store";
import { debounceTime, switchMap, takeUntil } from "rxjs/operators";
import { RootState } from "./root.state";
import packageInfo from "packageJson";
import { environment } from "../../environments/environment";
import { Subject } from "rxjs";
import { AuthService } from "@core/auth";
import { injectStorage, UserStorageProvider } from "@isa/core/storage";
import { isEqual } from "lodash";
@Injectable({ providedIn: 'root' })
@Injectable({ providedIn: "root" })
export class RootStateService {
static LOCAL_STORAGE_KEY = 'ISA_APP_INITIALSTATE';
static LOCAL_STORAGE_KEY = "ISA_APP_INITIALSTATE";
#storage = injectStorage(UserStorageProvider);
@@ -29,14 +29,17 @@ export class RootStateService {
);
}
window['clearUserState'] = () => {
window["clearUserState"] = () => {
this.clear();
};
}
async init() {
await this.load();
this._store.dispatch({ type: 'HYDRATE', payload: RootStateService.LoadFromLocalStorage() });
this._store.dispatch({
type: "HYDRATE",
payload: RootStateService.LoadFromLocalStorage(),
});
this.initSave();
}
@@ -50,14 +53,10 @@ export class RootStateService {
const data = {
...state,
version: packageInfo.version,
sub: this._authService.getClaimByKey('sub'),
sub: this._authService.getClaimByKey("sub"),
};
RootStateService.SaveToLocalStorageRaw(JSON.stringify(data));
return this.#storage.set('state', {
...state,
version: packageInfo.version,
sub: this._authService.getClaimByKey('sub'),
});
return this.#storage.set("state", data);
}),
)
.subscribe();
@@ -68,7 +67,7 @@ export class RootStateService {
*/
async load(): Promise<boolean> {
try {
const res = await this.#storage.get('state');
const res = await this.#storage.get("state");
const storageContent = RootStateService.LoadFromLocalStorageRaw();
@@ -88,7 +87,7 @@ export class RootStateService {
async clear() {
try {
this._cancelSave.next();
await this.#storage.clear('state');
await this.#storage.clear("state");
await new Promise((resolve) => setTimeout(resolve, 100));
RootStateService.RemoveFromLocalStorage();
await new Promise((resolve) => setTimeout(resolve, 100));
@@ -112,7 +111,7 @@ export class RootStateService {
try {
return JSON.parse(raw);
} catch (error) {
console.error('Error parsing local storage:', error);
console.error("Error parsing local storage:", error);
this.RemoveFromLocalStorage();
}
}

View File

@@ -1,5 +1,4 @@
import { Injectable } from '@angular/core';
import { LogLevel } from './log-level';
import { LogLevel } from "./log-level";
export interface LogProvider {
log(logLevel: LogLevel, message: string, ...optionalParams: any[]): void;

View File

@@ -1,21 +1,22 @@
import { enableProdMode, isDevMode } from '@angular/core';
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
import { CONFIG_DATA } from '@isa/core/config';
import { setDefaultOptions } from 'date-fns';
import { de } from 'date-fns/locale';
import * as moment from 'moment';
import { enableProdMode, isDevMode } from "@angular/core";
import { platformBrowserDynamic } from "@angular/platform-browser-dynamic";
import { CONFIG_DATA } from "@isa/core/config";
import { setDefaultOptions } from "date-fns";
import { de } from "date-fns/locale";
import * as moment from "moment";
import "moment/locale/de";
setDefaultOptions({ locale: de });
moment.locale('de');
moment.locale("de");
import { AppModule } from './app/app.module';
import { AppModule } from "./app/app.module";
if (!isDevMode()) {
enableProdMode();
}
async function bootstrap() {
const configRes = await fetch('/config/config.json');
const configRes = await fetch("/config/config.json");
const config = await configRes.json();

View File

@@ -16,20 +16,20 @@ import {
forwardRef,
Optional,
inject,
} from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
import { UiAutocompleteComponent } from '@ui/autocomplete';
import { UiFormControlDirective } from '@ui/form-control';
import { containsElement } from '@utils/common';
import { Subscription } from 'rxjs';
import { ScanAdapterService } from '@adapter/scan';
import { injectCancelSearch } from '@shared/services/cancel-subject';
import { EnvironmentService } from '@core/environment';
} from "@angular/core";
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from "@angular/forms";
import { UiAutocompleteComponent } from "@ui/autocomplete";
import { UiFormControlDirective } from "@ui/form-control";
import { containsElement } from "@utils/common";
import { Subscription } from "rxjs";
import { ScanAdapterService } from "@adapter/scan";
import { injectCancelSearch } from "@shared/services/cancel-subject";
import { EnvironmentService } from "@core/environment";
@Component({
selector: 'shared-searchbox',
templateUrl: 'searchbox.component.html',
styleUrls: ['searchbox.component.scss'],
selector: "shared-searchbox",
templateUrl: "searchbox.component.html",
styleUrls: ["searchbox.component.scss"],
providers: [
{
provide: NG_VALUE_ACCESSOR,
@@ -49,9 +49,9 @@ export class SearchboxComponent
cancelSearch = injectCancelSearch({ optional: true });
disabled: boolean;
type = 'text';
type = "text";
@ViewChild('input', { read: ElementRef, static: true })
@ViewChild("input", { read: ElementRef, static: true })
input: ElementRef;
@ContentChild(UiAutocompleteComponent)
@@ -61,9 +61,9 @@ export class SearchboxComponent
focusAfterViewInit = true;
@Input()
placeholder = '';
placeholder = "";
private _query = '';
private _query = "";
@Input()
get query() {
@@ -94,7 +94,7 @@ export class SearchboxComponent
scanner = false;
@Input()
hint = '';
hint = "";
@Input()
autocompleteValueSelector: (item: any) => string = (item: any) => item;
@@ -104,11 +104,11 @@ export class SearchboxComponent
}
clear(): void {
this.setQuery('');
this.setQuery("");
this.cancelSearch();
}
@HostBinding('class.autocomplete-opend')
@HostBinding("class.autocomplete-opend")
get autocompleteOpen() {
return this.autocomplete?.opend;
}
@@ -213,13 +213,13 @@ export class SearchboxComponent
}
clearHint() {
this.hint = '';
this.hint = "";
this.focused.emit(true);
this.cdr.markForCheck();
}
onKeyup(event: KeyboardEvent) {
if (event.key === 'Enter') {
if (event.key === "Enter") {
if (this.autocomplete?.opend && this.autocomplete?.activeItem) {
this.setQuery(this.autocomplete?.activeItem?.item);
this.autocomplete?.close();
@@ -227,7 +227,7 @@ export class SearchboxComponent
this.search.emit(this.query);
event.preventDefault();
} else if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
} else if (event.key === "ArrowUp" || event.key === "ArrowDown") {
this.handleArrowUpDownEvent(event);
}
}
@@ -242,7 +242,7 @@ export class SearchboxComponent
}
}
@HostListener('window:click', ['$event'])
@HostListener("window:click", ["$event"])
focusLost(event: MouseEvent) {
if (
this.autocomplete?.opend &&
@@ -256,9 +256,11 @@ export class SearchboxComponent
this.search.emit(this.query);
}
@HostListener('focusout', ['$event'])
@HostListener("focusout", ["$event"])
onBlur() {
this.onTouched();
if (typeof this.onTouched === "function") {
this.onTouched();
}
this.focused.emit(false);
this.cdr.markForCheck();
}

View File

@@ -16,20 +16,20 @@ import {
forwardRef,
Optional,
inject,
} from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
import { UiAutocompleteComponent } from '@ui/autocomplete';
import { UiFormControlDirective } from '@ui/form-control';
import { Subscription } from 'rxjs';
import { ScanAdapterService } from '@adapter/scan';
import { injectCancelSearch } from '@shared/services/cancel-subject';
import { containsElement } from '@utils/common';
import { EnvironmentService } from '@core/environment';
} from "@angular/core";
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from "@angular/forms";
import { UiAutocompleteComponent } from "@ui/autocomplete";
import { UiFormControlDirective } from "@ui/form-control";
import { Subscription } from "rxjs";
import { ScanAdapterService } from "@adapter/scan";
import { injectCancelSearch } from "@shared/services/cancel-subject";
import { containsElement } from "@utils/common";
import { EnvironmentService } from "@core/environment";
@Component({
selector: 'ui-searchbox',
templateUrl: 'searchbox.component.html',
styleUrls: ['searchbox.component.scss'],
selector: "ui-searchbox",
templateUrl: "searchbox.component.html",
styleUrls: ["searchbox.component.scss"],
providers: [
{
provide: NG_VALUE_ACCESSOR,
@@ -49,9 +49,9 @@ export class UiSearchboxNextComponent
private readonly _cancelSearch = injectCancelSearch({ optional: true });
disabled: boolean;
type = 'text';
type = "text";
@ViewChild('input', { read: ElementRef, static: true })
@ViewChild("input", { read: ElementRef, static: true })
input: ElementRef;
@ContentChild(UiAutocompleteComponent)
@@ -61,9 +61,9 @@ export class UiSearchboxNextComponent
focusAfterViewInit: boolean = true;
@Input()
placeholder: string = '';
placeholder: string = "";
private _query = '';
private _query = "";
@Input()
get query() {
@@ -94,7 +94,7 @@ export class UiSearchboxNextComponent
scanner = false;
@Input()
hint: string = '';
hint: string = "";
@Output()
hintCleared = new EventEmitter<void>();
@@ -107,11 +107,11 @@ export class UiSearchboxNextComponent
}
clear(): void {
this.setQuery('');
this.setQuery("");
this._cancelSearch();
}
@HostBinding('class.autocomplete-opend')
@HostBinding("class.autocomplete-opend")
get autocompleteOpen() {
return this.autocomplete?.opend;
}
@@ -212,14 +212,14 @@ export class UiSearchboxNextComponent
}
clearHint() {
this.hint = '';
this.hint = "";
this.focused.emit(true);
this.hintCleared.emit();
this.cdr.markForCheck();
}
onKeyup(event: KeyboardEvent) {
if (event.key === 'Enter') {
if (event.key === "Enter") {
if (this.autocomplete?.opend && this.autocomplete?.activeItem) {
this.setQuery(this.autocomplete?.activeItem?.item);
this.autocomplete?.close();
@@ -227,7 +227,7 @@ export class UiSearchboxNextComponent
this.search.emit(this.query);
event.preventDefault();
} else if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
} else if (event.key === "ArrowUp" || event.key === "ArrowDown") {
this.handleArrowUpDownEvent(event);
}
}
@@ -235,12 +235,14 @@ export class UiSearchboxNextComponent
handleArrowUpDownEvent(event: KeyboardEvent) {
this.autocomplete?.handleKeyboardEvent(event);
if (this.autocomplete?.activeItem) {
const query = this.autocompleteValueSelector(this.autocomplete.activeItem.item);
const query = this.autocompleteValueSelector(
this.autocomplete.activeItem.item,
);
this.setQuery(query, false, false);
}
}
@HostListener('window:click', ['$event'])
@HostListener("window:click", ["$event"])
focusLost(event: MouseEvent) {
if (
this.autocomplete?.opend &&
@@ -254,9 +256,11 @@ export class UiSearchboxNextComponent
this.search.emit(this.query);
}
@HostListener('focusout', ['$event'])
@HostListener("focusout", ["$event"])
onBlur() {
this.onTouched();
if (typeof this.onTouched === "function") {
this.onTouched();
}
this.focused.emit(false);
this.cdr.markForCheck();
}

View File

@@ -1,9 +1,13 @@
import { type Meta, type StoryObj, moduleMetadata } from '@storybook/angular';
import { ClientRowComponent, ClientRowImports, ItemRowDataImports } from '@isa/ui/item-rows';
import { type Meta, type StoryObj, moduleMetadata } from "@storybook/angular";
import {
ClientRowComponent,
ClientRowImports,
ItemRowDataImports,
} from "@isa/ui/item-rows";
const meta: Meta<ClientRowComponent> = {
component: ClientRowComponent,
title: 'ui/item-rows/ClientRow',
title: "ui/item-rows/ClientRow",
decorators: [
moduleMetadata({
imports: [ClientRowImports, ItemRowDataImports],
@@ -21,25 +25,25 @@ const meta: Meta<ClientRowComponent> = {
<ui-item-row-data-row>
<ui-item-row-data-label>Belegdatum</ui-item-row-data-label>
<ui-item-row-data-value>
<span class="isa-text-body-2-bold">
<span class="isa-text-body-2-bold">
01.11.2024
</span>
</ui-item-row-data-value>
</ui-item-row-data-row>
<ui-item-row-data-row>
<ui-item-row-data-label>Rechnugsnr.</ui-item-row-data-label>
<ui-item-row-data-label>Beleg-Nr.</ui-item-row-data-label>
<ui-item-row-data-value>
<span class="isa-text-body-2-bold">
<span class="isa-text-body-2-bold">
1234567890
</span>
</ui-item-row-data-value>
</ui-item-row-data-row>
<ui-item-row-data-row>
<ui-item-row-data-label >Vorgangs-ID</ui-item-row-data-label>
<ui-item-row-data-value >
<span class="isa-text-body-2-bold">
640175214390060/0
</span>
<ui-item-row-data-value >
<span class="isa-text-body-2-bold">
640175214390060/0
</span>
</ui-item-row-data-value>
</ui-item-row-data-row>
</ui-item-row-data>

View File

@@ -9,10 +9,10 @@ trigger:
variables:
# Major Version einstellen
- name: 'Major'
value: '3'
value: '4'
# Minor Version einstellen
- name: 'Minor'
value: '4'
value: '0'
- name: 'Patch'
value: "$[counter(format('{0}.{1}', variables['Major'], variables['Minor']),0)]"
- name: 'BuildUniqueID'

View File

@@ -1,2 +1,2 @@
export * from './item';
export * from './product';
export * from "./item";
export * from "./product";

View File

@@ -1,27 +1,42 @@
import { inject, Injectable } from '@angular/core';
import { StorageProvider } from './storage-provider';
import { UserStateService } from '@generated/swagger/isa-api';
import { firstValueFrom, map, shareReplay } from 'rxjs';
import { inject, Injectable } from "@angular/core";
import { StorageProvider } from "./storage-provider";
import { UserStateService } from "@generated/swagger/isa-api";
import { catchError, firstValueFrom, map, of } from "rxjs";
import { isEmpty } from "lodash";
@Injectable({ providedIn: 'root' })
@Injectable({ providedIn: "root" })
export class UserStorageProvider implements StorageProvider {
#userStateService = inject(UserStateService);
private state$ = this.#userStateService.UserStateGetUserState().pipe(
map((res) => {
if (res.result?.content) {
if (res?.result?.content) {
return JSON.parse(res.result.content);
}
return {};
}),
shareReplay(1),
catchError((err) => {
console.warn(
"No UserStateGetUserState found, returning empty object:",
err,
);
return of({}); // Return empty state fallback
}),
// shareReplay(1), #5249, #5270 Würde beim Fehlerfall den fehlerhaften Zustand behalten
// Aktuell wird nun jedes mal 2 mal der UserState aufgerufen (GET + POST)
// Damit bei der set Funktion immer der aktuelle Zustand verwendet wird
);
async set(key: string, value: unknown): Promise<void> {
async set(key: string, value: Record<string, unknown>): Promise<void> {
const current = await firstValueFrom(this.state$);
firstValueFrom(
const content =
current && !isEmpty(current)
? { ...current, [key]: value }
: { [key]: value };
await firstValueFrom(
this.#userStateService.UserStateSetUserState({
content: JSON.stringify({ ...current, [key]: value }),
content: JSON.stringify(content),
}),
);
}
@@ -32,7 +47,6 @@ export class UserStorageProvider implements StorageProvider {
}
async clear(key: string): Promise<void> {
const current = await firstValueFrom(this.state$);
delete current[key];
firstValueFrom(this.#userStateService.UserStateResetUserState());

View File

@@ -1,27 +1,51 @@
import { DataAccessError } from '@isa/common/data-access';
import { Receipt, ReceiptItem } from '../../models';
import { DataAccessError } from "@isa/common/data-access";
import { Receipt, ReceiptItem } from "../../models";
import {
CreateReturnProcessError,
CreateReturnProcessErrorReason,
CreateReturnProcessErrorMessages,
} from './create-return-process.error';
} from "./create-return-process.error";
import { ProductCategory } from "../../questions";
describe('CreateReturnProcessError', () => {
describe("CreateReturnProcessError", () => {
const params = {
processId: 123,
returns: [
{
receipt: { id: 321 } as Receipt,
items: [] as ReceiptItem[],
items: [
// Provide at least one valid item object, or an empty array if testing "no items"
// For NO_RETURNABLE_ITEMS, an empty array is valid, but must match the expected shape
// So, keep as [], but type is now correct
],
},
],
};
it('should create an error instance with NO_RETURNABLE_ITEMS reason', () => {
// For tests that require items, use the correct shape:
const validParams = {
processId: 123,
returns: [
{
receipt: { id: 321 } as Receipt,
items: [
{
receiptItem: { id: 111 } as ReceiptItem,
quantity: 1,
category: "A" as ProductCategory,
},
],
},
],
};
it("should create an error instance with NO_RETURNABLE_ITEMS reason", () => {
// Arrange, Act
const error = new CreateReturnProcessError(
CreateReturnProcessErrorReason.NO_RETURNABLE_ITEMS,
params,
);
// Assert
expect(error).toBeInstanceOf(CreateReturnProcessError);
expect(error).toBeInstanceOf(DataAccessError);
expect(error.reason).toBe(
@@ -33,25 +57,103 @@ describe('CreateReturnProcessError', () => {
CreateReturnProcessErrorReason.NO_RETURNABLE_ITEMS
],
);
expect(error.code).toBe('CREATE_RETURN_PROCESS');
expect(error.code).toBe("CREATE_RETURN_PROCESS");
});
it('should create an error instance with MISMATCH_RETURNABLE_ITEMS reason', () => {
it("should create an error instance with MISMATCH_RETURNABLE_ITEMS reason", () => {
// Arrange, Act
const error = new CreateReturnProcessError(
CreateReturnProcessErrorReason.MISMATCH_RETURNABLE_ITEMS,
params,
validParams,
);
// Assert
expect(error).toBeInstanceOf(CreateReturnProcessError);
expect(error).toBeInstanceOf(DataAccessError);
expect(error.reason).toBe(
CreateReturnProcessErrorReason.MISMATCH_RETURNABLE_ITEMS,
);
expect(error.params).toEqual(params);
expect(error.params).toEqual(validParams);
expect(error.message).toBe(
CreateReturnProcessErrorMessages[
CreateReturnProcessErrorReason.MISMATCH_RETURNABLE_ITEMS
],
);
expect(error.code).toBe('CREATE_RETURN_PROCESS');
expect(error.code).toBe("CREATE_RETURN_PROCESS");
});
it("should expose the correct params structure", () => {
const error = new CreateReturnProcessError(
CreateReturnProcessErrorReason.NO_RETURNABLE_ITEMS,
params,
);
expect(error.params).toHaveProperty("processId", 123);
expect(error.params).toHaveProperty("returns");
expect(Array.isArray(error.params.returns)).toBe(true);
expect(error.params.returns[0]).toHaveProperty("receipt");
expect(error.params.returns[0]).toHaveProperty("items");
});
it("should throw and be catchable as CreateReturnProcessError", () => {
try {
throw new CreateReturnProcessError(
CreateReturnProcessErrorReason.NO_RETURNABLE_ITEMS,
params,
);
} catch (err) {
expect(err).toBeInstanceOf(CreateReturnProcessError);
expect((err as CreateReturnProcessError).reason).toBe(
CreateReturnProcessErrorReason.NO_RETURNABLE_ITEMS,
);
}
});
it("should use the correct message for each reason", () => {
Object.values(CreateReturnProcessErrorReason).forEach((reason) => {
const error = new CreateReturnProcessError(reason, params);
expect(error.message).toBe(CreateReturnProcessErrorMessages[reason]);
});
});
it('should have code "CREATE_RETURN_PROCESS" for all reasons', () => {
Object.values(CreateReturnProcessErrorReason).forEach((reason) => {
const error = new CreateReturnProcessError(reason, params);
expect(error.code).toBe("CREATE_RETURN_PROCESS");
});
});
it("should support params with multiple returns and items", () => {
const extendedParams = {
processId: 999,
returns: [
{
receipt: { id: 1 } as Receipt,
items: [
{
receiptItem: { id: 10 } as ReceiptItem,
quantity: 2,
category: "A" as ProductCategory,
},
],
},
{
receipt: { id: 2 } as Receipt,
items: [
{
receiptItem: { id: 20 } as ReceiptItem,
quantity: 1,
category: "B" as ProductCategory,
},
],
},
],
};
const error = new CreateReturnProcessError(
CreateReturnProcessErrorReason.MISMATCH_RETURNABLE_ITEMS,
extendedParams,
);
expect(error.params.processId).toBe(999);
expect(error.params.returns.length).toBe(2);
expect(error.params.returns[0].items[0].quantity).toBe(2);
expect(error.params.returns[1].items[0].category).toBe("B");
});
});

View File

@@ -1,13 +1,14 @@
import { DataAccessError } from '@isa/common/data-access';
import { Receipt, ReceiptItem } from '../../models';
import { DataAccessError } from "@isa/common/data-access";
import { Receipt, ReceiptItem } from "../../models";
import { ProductCategory } from "../../questions";
/**
* Enum-like object defining possible reasons for return process creation failures.
* Used to provide consistent and type-safe error categorization.
*/
export const CreateReturnProcessErrorReason = {
NO_RETURNABLE_ITEMS: 'NO_RETURNABLE_ITEMS',
MISMATCH_RETURNABLE_ITEMS: 'MISMATCH_RETURNABLE_ITEMS',
NO_RETURNABLE_ITEMS: "NO_RETURNABLE_ITEMS",
MISMATCH_RETURNABLE_ITEMS: "MISMATCH_RETURNABLE_ITEMS",
} as const;
/**
@@ -32,9 +33,9 @@ export const CreateReturnProcessErrorMessages: Record<
string
> = {
[CreateReturnProcessErrorReason.NO_RETURNABLE_ITEMS]:
'No returnable items found.',
"No returnable items found.",
[CreateReturnProcessErrorReason.MISMATCH_RETURNABLE_ITEMS]:
'Mismatch in the number of returnable items.',
"Mismatch in the number of returnable items.",
};
/**
@@ -73,14 +74,21 @@ export const CreateReturnProcessErrorMessages: Record<
* }
* ```
*/
export class CreateReturnProcessError extends DataAccessError<'CREATE_RETURN_PROCESS'> {
export class CreateReturnProcessError extends DataAccessError<"CREATE_RETURN_PROCESS"> {
constructor(
public readonly reason: CreateReturnProcessErrorReason,
public readonly params: {
processId: number;
returns: { receipt: Receipt; items: ReceiptItem[] }[];
returns: {
receipt: Receipt;
items: {
receiptItem: ReceiptItem;
quantity: number;
category: ProductCategory;
}[];
}[];
},
) {
super('CREATE_RETURN_PROCESS', CreateReturnProcessErrorMessages[reason]);
super("CREATE_RETURN_PROCESS", CreateReturnProcessErrorMessages[reason]);
}
}

View File

@@ -1,2 +1,2 @@
export * from './create-return-process.error';
export * from './return-process-is-not-complete.error';
export * from "./create-return-process.error";
export * from "./return-process-is-not-complete.error";

View File

@@ -1,39 +1,39 @@
import { returnReceiptValuesMapping } from './return-receipt-values-mapping.helper';
import { PropertyNullOrUndefinedError } from '@isa/common/data-access';
import { getReturnProcessQuestions } from './get-return-process-questions.helper';
import { getReturnInfo } from './get-return-info.helper';
import { serializeReturnDetails } from './return-details-mapping.helper';
import { returnReceiptValuesMapping } from "./return-receipt-values-mapping.helper";
import { PropertyNullOrUndefinedError } from "@isa/common/data-access";
import { getReturnProcessQuestions } from "./get-return-process-questions.helper";
import { getReturnInfo } from "./get-return-info.helper";
import { serializeReturnDetails } from "./return-details-mapping.helper";
// Mock dependencies
jest.mock('./get-return-process-questions.helper', () => ({
jest.mock("./get-return-process-questions.helper", () => ({
getReturnProcessQuestions: jest.fn(),
}));
jest.mock('./get-return-info.helper', () => ({
jest.mock("./get-return-info.helper", () => ({
getReturnInfo: jest.fn(),
}));
jest.mock('./return-details-mapping.helper', () => ({
jest.mock("./return-details-mapping.helper", () => ({
serializeReturnDetails: jest.fn(),
}));
describe('returnReceiptValuesMapping', () => {
describe("returnReceiptValuesMapping", () => {
const processMock: any = {
receiptItem: {
id: 'item-1',
quantity: { quantity: 2 },
features: { category: 'shoes' },
id: "item-1",
},
answers: { foo: 'bar' },
quantity: 2, // <-- Add this
productCategory: "shoes", // <-- Add this
answers: { foo: "bar" },
};
const questionsMock = [{ id: 'q1' }];
const questionsMock = [{ id: "q1" }];
const returnInfoMock = {
comment: 'Test comment',
itemCondition: 'NEW',
otherProduct: 'Other',
returnDetails: { detail: 'details' },
returnReason: 'Damaged',
comment: "Test comment",
itemCondition: "NEW",
otherProduct: "Other",
returnDetails: { detail: "details" },
returnReason: "Damaged",
};
const serializedDetails = { detail: 'serialized' };
const serializedDetails = { detail: "serialized" };
beforeEach(() => {
jest.clearAllMocks();
@@ -42,32 +42,24 @@ describe('returnReceiptValuesMapping', () => {
(serializeReturnDetails as jest.Mock).mockReturnValue(serializedDetails);
});
it('should map values correctly when all dependencies return valid data', () => {
it("should map values correctly when all dependencies return valid data", () => {
// Act
const result = returnReceiptValuesMapping(processMock);
// Assert
expect(result).toEqual({
quantity: 2,
comment: 'Test comment',
itemCondition: 'NEW',
otherProduct: 'Other',
comment: "Test comment",
itemCondition: "NEW",
otherProduct: "Other",
returnDetails: serializedDetails,
returnReason: 'Damaged',
category: 'shoes',
receiptItem: { id: 'item-1' },
returnReason: "Damaged",
category: "shoes",
receiptItem: { id: "item-1" },
});
expect(getReturnProcessQuestions).toHaveBeenCalledWith(processMock);
expect(getReturnInfo).toHaveBeenCalledWith({
questions: questionsMock,
answers: processMock.answers,
});
expect(serializeReturnDetails).toHaveBeenCalledWith(
returnInfoMock.returnDetails,
);
});
it('should throw PropertyNullOrUndefinedError if questions is undefined', () => {
it("should throw PropertyNullOrUndefinedError if questions is undefined", () => {
// Arrange
(getReturnProcessQuestions as jest.Mock).mockReturnValue(undefined);
@@ -75,10 +67,10 @@ describe('returnReceiptValuesMapping', () => {
expect(() => returnReceiptValuesMapping(processMock)).toThrow(
PropertyNullOrUndefinedError,
);
expect(() => returnReceiptValuesMapping(processMock)).toThrow('questions');
expect(() => returnReceiptValuesMapping(processMock)).toThrow("questions");
});
it('should throw PropertyNullOrUndefinedError if returnInfo is undefined', () => {
it("should throw PropertyNullOrUndefinedError if returnInfo is undefined", () => {
// Arrange
(getReturnInfo as jest.Mock).mockReturnValue(undefined);
@@ -86,28 +78,55 @@ describe('returnReceiptValuesMapping', () => {
expect(() => returnReceiptValuesMapping(processMock)).toThrow(
PropertyNullOrUndefinedError,
);
expect(() => returnReceiptValuesMapping(processMock)).toThrow('returnInfo');
expect(() => returnReceiptValuesMapping(processMock)).toThrow("returnInfo");
});
it('should handle missing category gracefully', () => {
// Arrange
const processNoCategory = {
...processMock,
receiptItem: { ...processMock.receiptItem, features: {} },
};
// Act
const result = returnReceiptValuesMapping(processNoCategory);
// Assert
expect(result?.category).toBeUndefined();
});
it('should handle missing receiptItem gracefully (may throw)', () => {
it("should handle missing receiptItem gracefully (may throw)", () => {
// Arrange
const processNoReceiptItem = { ...processMock, receiptItem: undefined };
// Act & Assert
expect(() => returnReceiptValuesMapping(processNoReceiptItem)).toThrow();
});
// Additional tests for edge cases and error scenarios
it("should return correct quantity when process.quantity is 0", () => {
const processZeroQuantity = { ...processMock, quantity: 0 };
const result = returnReceiptValuesMapping(processZeroQuantity);
expect(result?.quantity).toBe(0);
});
it("should propagate the correct receiptItem id", () => {
const result = returnReceiptValuesMapping(processMock);
expect(result?.receiptItem).toEqual({ id: "item-1" });
});
it("should throw if process is null", () => {
expect(() => returnReceiptValuesMapping(null as any)).toThrow();
});
it("should throw if process is undefined", () => {
expect(() => returnReceiptValuesMapping(undefined as any)).toThrow();
});
it("should call serializeReturnDetails with undefined if returnDetails is missing", () => {
// Arrange
const returnInfoNoDetails = { ...returnInfoMock, returnDetails: undefined };
(getReturnInfo as jest.Mock).mockReturnValue(returnInfoNoDetails);
// Act
returnReceiptValuesMapping(processMock);
// Assert
expect(serializeReturnDetails).toHaveBeenCalledWith(undefined);
});
it("should return undefined if process.quantity is undefined", () => {
const processNoQuantity = { ...processMock };
delete processNoQuantity.quantity;
// Should not throw, but quantity will be undefined in result
const result = returnReceiptValuesMapping(processNoQuantity);
expect(result?.quantity).toBeUndefined();
});
});

View File

@@ -1,16 +1,16 @@
import { ReturnProcess } from '../../models';
import { ReturnReceiptValues } from '../../schemas';
import { getReturnProcessQuestions } from './get-return-process-questions.helper';
import { getReturnInfo } from './get-return-info.helper';
import { PropertyNullOrUndefinedError } from '@isa/common/data-access';
import { serializeReturnDetails } from './return-details-mapping.helper';
import { ReturnProcess } from "../../models";
import { ReturnReceiptValues } from "../../schemas";
import { getReturnProcessQuestions } from "./get-return-process-questions.helper";
import { getReturnInfo } from "./get-return-info.helper";
import { PropertyNullOrUndefinedError } from "@isa/common/data-access";
import { serializeReturnDetails } from "./return-details-mapping.helper";
export const returnReceiptValuesMapping = (
process: ReturnProcess,
): ReturnReceiptValues | undefined => {
const questions = getReturnProcessQuestions(process);
if (!questions) {
throw new PropertyNullOrUndefinedError('questions');
throw new PropertyNullOrUndefinedError("questions");
}
const returnInfo = getReturnInfo({
@@ -19,17 +19,17 @@ export const returnReceiptValuesMapping = (
});
if (!returnInfo) {
throw new PropertyNullOrUndefinedError('returnInfo');
throw new PropertyNullOrUndefinedError("returnInfo");
}
return {
quantity: process.receiptItem.quantity.quantity,
quantity: process.quantity,
comment: returnInfo.comment,
itemCondition: returnInfo.itemCondition,
otherProduct: returnInfo.otherProduct,
returnDetails: serializeReturnDetails(returnInfo.returnDetails),
returnReason: returnInfo.returnReason,
category: process?.receiptItem?.features?.['category'],
category: process.productCategory,
receiptItem: {
id: process.receiptItem.id,
},

View File

@@ -1,23 +1,23 @@
export * from './address-type';
export * from './buyer';
export * from './can-return';
export * from './eligible-for-return';
export * from './gender';
export * from './product';
export * from './quantity';
export * from './receipt-item-list-item';
export * from './receipt-item-task-list-item';
export * from './receipt-item';
export * from './receipt-list-item';
export * from './receipt-type';
export * from './receipt';
export * from './return-info';
export * from './return-process-answer';
export * from './return-process-question-key';
export * from './return-process-question-type';
export * from './return-process-question';
export * from './return-process-status';
export * from './return-process';
export * from './shipping-address-2';
export * from './shipping-type';
export * from './task-action-type';
export * from "./address-type";
export * from "./buyer";
export * from "./can-return";
export * from "./eligible-for-return";
export * from "./gender";
export * from "./product";
export * from "./quantity";
export * from "./receipt-item-list-item";
export * from "./receipt-item-task-list-item";
export * from "./receipt-item";
export * from "./receipt-list-item";
export * from "./receipt-type";
export * from "./receipt";
export * from "./return-info";
export * from "./return-process-answer";
export * from "./return-process-question-key";
export * from "./return-process-question-type";
export * from "./return-process-question";
export * from "./return-process-status";
export * from "./return-process";
export * from "./shipping-address-2";
export * from "./shipping-type";
export * from "./task-action-type";

View File

@@ -1,5 +1,5 @@
import { Receipt } from './receipt';
import { ReceiptItem } from './receipt-item';
import { Receipt } from "./receipt";
import { ReceiptItem } from "./receipt-item";
/**
* Interface representing a return process within the OMS system.
@@ -21,6 +21,7 @@ export interface ReturnProcess {
receiptItem: ReceiptItem;
receiptDate: string | undefined;
answers: Record<string, unknown>;
productCategory?: string;
productCategory: string;
quantity: number;
returnReceipt?: Receipt;
}

View File

@@ -1,17 +1,17 @@
import { inject, Injectable } from '@angular/core';
import { inject, Injectable } from "@angular/core";
import {
FetchReturnDetails,
FetchReturnDetailsSchema,
ReturnReceiptValues,
} from '../schemas';
import { firstValueFrom } from 'rxjs';
import { ReceiptService } from '@generated/swagger/oms-api';
import { CanReturn, Receipt, ReceiptItem, ReceiptListItem } from '../models';
import { CategoryQuestions, ProductCategory } from '../questions';
import { KeyValue } from '@angular/common';
import { ReturnCanReturnService } from './return-can-return.service';
import { takeUntilAborted } from '@isa/common/data-access';
import { z } from 'zod';
} from "../schemas";
import { firstValueFrom } from "rxjs";
import { ReceiptService } from "@generated/swagger/oms-api";
import { CanReturn, Receipt, ReceiptItem, ReceiptListItem } from "../models";
import { CategoryQuestions, ProductCategory } from "../questions";
import { KeyValue } from "@angular/common";
import { ReturnCanReturnService } from "./return-can-return.service";
import { takeUntilAborted } from "@isa/common/data-access";
import { z } from "zod";
/**
* Service responsible for managing receipt return details and operations.
@@ -22,7 +22,7 @@ import { z } from 'zod';
* - Query receipts by customer email
* - Get available product categories for returns
*/
@Injectable({ providedIn: 'root' })
@Injectable({ providedIn: "root" })
export class ReturnDetailsService {
#receiptService = inject(ReceiptService);
#returnCanReturnService = inject(ReturnCanReturnService);
@@ -38,13 +38,17 @@ export class ReturnDetailsService {
* @throws Will throw an error if the return check fails or is aborted.
*/
async canReturn(
{ item, category }: { item: ReceiptItem; category: ProductCategory },
{
receiptItemId,
quantity,
category,
}: { receiptItemId: number; quantity: number; category: ProductCategory },
abortSignal?: AbortSignal,
): Promise<CanReturn> {
const returnReceiptValues: ReturnReceiptValues = {
quantity: item.quantity.quantity,
quantity,
receiptItem: {
id: item.id,
id: receiptItemId,
},
category,
};
@@ -102,7 +106,7 @@ export class ReturnDetailsService {
const res = await firstValueFrom(req$);
if (res.error || !res.result) {
throw new Error(res.message || 'Failed to fetch return details');
throw new Error(res.message || "Failed to fetch return details");
}
return res.result as Receipt;
@@ -112,7 +116,7 @@ export class ReturnDetailsService {
* Validates that the email parameter is a properly formatted email address.
*/
static FetchReceiptsEmailParamsSchema = z.object({
email: z.string().email(),
email: z.string(),
});
/**
@@ -137,7 +141,7 @@ export class ReturnDetailsService {
let req$ = this.#receiptService.ReceiptQueryReceipt({
queryToken: {
input: { qs: email },
filter: { receipt_type: '1;128;1024' },
filter: { receipt_type: "1;128;1024" },
},
});
@@ -147,7 +151,7 @@ export class ReturnDetailsService {
const res = await firstValueFrom(req$);
if (res.error || !res.result) {
throw new Error(res.message || 'Failed to fetch return items by email');
throw new Error(res.message || "Failed to fetch return items by email");
}
return res.result as ReceiptListItem[];

View File

@@ -1,13 +1,12 @@
import { computed, inject, resource, untracked } from '@angular/core';
import { computed, inject, resource } from "@angular/core";
import {
CanReturn,
ProductCategory,
Receipt,
ReceiptItem,
ReturnDetailsService,
} from '@isa/oms/data-access';
} from "@isa/oms/data-access";
import {
getState,
patchState,
signalStore,
type,
@@ -15,20 +14,18 @@ import {
withMethods,
withProps,
withState,
} from '@ngrx/signals';
import { setEntity, withEntities, entityConfig } from '@ngrx/signals/entities';
} from "@ngrx/signals";
import { setEntity, withEntities, entityConfig } from "@ngrx/signals/entities";
import {
canReturnReceiptItem,
getReceiptItemQuantity,
getReceiptItemProductCategory,
receiptItemHasCategory,
} from '../helpers/return-process';
import { SessionStorageProvider } from '@isa/core/storage';
import { logger } from '@isa/core/logging';
import { clone } from 'lodash';
getReceiptItemReturnedQuantity,
} from "../helpers/return-process";
import { logger } from "@isa/core/logging";
interface ReturnDetailsState {
_storageId: number | undefined;
_selectedItemIds: number[];
selectedProductCategory: Record<number, ProductCategory>;
selectedQuantity: Record<number, number>;
@@ -36,7 +33,6 @@ interface ReturnDetailsState {
}
const initialState: ReturnDetailsState = {
_storageId: undefined,
_selectedItemIds: [],
selectedProductCategory: {},
selectedQuantity: {},
@@ -45,40 +41,15 @@ const initialState: ReturnDetailsState = {
export const receiptConfig = entityConfig({
entity: type<Receipt>(),
collection: 'receipts',
collection: "receipts",
});
export const ReturnDetailsStore = signalStore(
{ providedIn: 'root' },
withState(initialState),
withEntities(receiptConfig),
withProps(() => ({
_logger: logger(() => ({ store: 'ReturnDetailsStore' })),
_logger: logger(() => ({ store: "ReturnDetailsStore" })),
_returnDetailsService: inject(ReturnDetailsService),
_storage: inject(SessionStorageProvider),
})),
withMethods((store) => ({
_storageKey: () => `ReturnDetailsStore:${store._storageId}`,
})),
withMethods((store) => ({
_storeState: () => {
const state = getState(store);
if (!store._storageId) {
return;
}
store._storage.set(store._storageKey(), state);
store._logger.debug('State stored:', () => state);
},
_restoreState: async () => {
const data = await store._storage.get(store._storageKey());
if (data) {
patchState(store, data);
store._logger.debug('State restored:', () => ({ data }));
} else {
patchState(store, { ...initialState, _storageId: store._storageId() });
store._logger.debug('No state found, initialized with default state');
}
},
})),
withComputed((store) => ({
items: computed<Array<ReceiptItem>>(() =>
@@ -86,43 +57,56 @@ export const ReturnDetailsStore = signalStore(
.receiptsEntities()
.map((receipt) => receipt.items)
.flat()
.map((container) => {
const item = container.data;
if (!item) {
const err = new Error('Item data is undefined');
store._logger.error('Item data is undefined', err, () => ({
item: container,
}));
throw err;
}
const itemData = clone(item);
const quantityMap = store.selectedQuantity();
if (quantityMap[itemData.id]) {
itemData.quantity = { quantity: quantityMap[itemData.id] };
} else {
const quantity = getReceiptItemQuantity(itemData);
if (!itemData.quantity) {
itemData.quantity = { quantity };
} else {
itemData.quantity.quantity = quantity;
}
}
if (!itemData.features) {
itemData.features = {};
}
itemData.features['category'] =
store.selectedProductCategory()[itemData.id] ||
getReceiptItemProductCategory(itemData);
return itemData;
}),
.map((container) => container.data!),
),
})),
withComputed((store) => ({
availableQuantityMap: computed(() => {
const items = store.items();
const availableQuantity: Record<number, number> = {};
items.forEach((item) => {
const itemId = item.id;
const quantity = getReceiptItemQuantity(item);
const returnedQuantity = getReceiptItemReturnedQuantity(item);
availableQuantity[itemId] = quantity - returnedQuantity;
});
return availableQuantity;
}),
itemCategoryMap: computed(() => {
const items = store.items();
const categoryMap: Record<number, ProductCategory> = {};
items.forEach((item) => {
const itemId = item.id;
const selectedCategory = store.selectedProductCategory()[itemId];
const category = getReceiptItemProductCategory(item);
categoryMap[itemId] = selectedCategory ?? category;
});
return categoryMap;
}),
})),
withComputed((store) => ({
selectedQuantityMap: computed(() => {
const items = store.items();
const selectedQuantity: Record<number, number> = {};
items.forEach((item) => {
const itemId = item.id;
const quantity =
store.selectedQuantity()[itemId] ||
store.availableQuantityMap()[itemId];
selectedQuantity[itemId] = quantity;
});
return selectedQuantity;
}),
})),
withComputed((store) => ({
selectedItemIds: computed(() => {
const selectedIds = store._selectedItemIds();
@@ -130,7 +114,7 @@ export const ReturnDetailsStore = signalStore(
return selectedIds.filter((id) => {
const canReturnResult = canReturn[id]?.result;
return typeof canReturnResult === 'boolean' ? canReturnResult : true;
return typeof canReturnResult === "boolean" ? canReturnResult : true;
});
}),
})),
@@ -167,8 +151,8 @@ export const ReturnDetailsStore = signalStore(
{ receiptId: request },
abortSignal,
);
patchState(store, setEntity(receipt, receiptConfig));
store._storeState();
return receipt;
},
}),
@@ -182,18 +166,21 @@ export const ReturnDetailsStore = signalStore(
return undefined;
}
const receiptItemId = item.id;
const quantity = store.selectedQuantityMap()[receiptItemId];
const category = store.itemCategoryMap()[receiptItemId];
return {
item: item,
category:
store.selectedProductCategory()[item.id] ||
getReceiptItemProductCategory(item),
receiptItemId,
quantity,
category,
};
},
loader: async ({ request, abortSignal }) => {
if (request === undefined) {
return undefined;
}
const key = `${request.item.id}:${request.category}`;
const key = `${request.receiptItemId}:${request.category}`;
if (store.canReturn()[key]) {
return store.canReturn()[key];
@@ -207,7 +194,6 @@ export const ReturnDetailsStore = signalStore(
canReturn: { ...store.canReturn(), [key]: res },
});
store._storeState();
return res;
},
}),
@@ -248,37 +234,25 @@ export const ReturnDetailsStore = signalStore(
})),
withMethods((store) => ({
selectStorage: (id: number) => {
untracked(() => {
patchState(store, { _storageId: id });
store._restoreState();
store._storeState();
store._logger.debug('Storage ID set:', () => ({ id }));
});
},
addSelectedItems(itemIds: number[]) {
const currentIds = store.selectedItemIds();
const newIds = Array.from(new Set([...currentIds, ...itemIds]));
patchState(store, { _selectedItemIds: newIds });
store._storeState();
},
removeSelectedItems(itemIds: number[]) {
const currentIds = store.selectedItemIds();
const newIds = currentIds.filter((id) => !itemIds.includes(id));
patchState(store, { _selectedItemIds: newIds });
store._storeState();
},
async setProductCategory(itemId: number, category: ProductCategory) {
const currentCategory = store.selectedProductCategory();
const newCategory = { ...currentCategory, [itemId]: category };
patchState(store, { selectedProductCategory: newCategory });
store._storeState();
},
setQuantity(itemId: number, quantity: number) {
const currentQuantity = store.selectedQuantity();
const newQuantity = { ...currentQuantity, [itemId]: quantity };
patchState(store, { selectedQuantity: newQuantity });
store._storeState();
},
})),
);

View File

@@ -1,69 +1,70 @@
import { createServiceFactory } from '@ngneat/spectator/jest';
import { ReturnProcessStore } from './return-process.store';
import { IDBStorageProvider } from '@isa/core/storage';
import { ProcessService } from '@isa/core/process';
import { patchState } from '@ngrx/signals';
import { setAllEntities, setEntity } from '@ngrx/signals/entities';
import { unprotected } from '@ngrx/signals/testing';
import { Product, ReturnProcess } from '../models';
import { CreateReturnProcessError } from '../errors/return-process';
import { createServiceFactory } from "@ngneat/spectator/jest";
import { ReturnProcessStore } from "./return-process.store";
import { IDBStorageProvider } from "@isa/core/storage";
import { ProcessService } from "@isa/core/process";
import { patchState } from "@ngrx/signals";
import { setAllEntities, setEntity } from "@ngrx/signals/entities";
import { unprotected } from "@ngrx/signals/testing";
import { Product, ReturnProcess } from "../models";
import { CreateReturnProcessError } from "../errors/return-process";
import { ProductCategory } from "../questions";
const TEST_ITEMS: Record<number, ReturnProcess['receiptItem']> = {
const TEST_ITEMS: Record<number, ReturnProcess["receiptItem"]> = {
1: {
id: 1,
actions: [{ key: 'canReturn', value: 'true' }],
actions: [{ key: "canReturn", value: "true" }],
product: {
ean: '1234567890',
format: 'TB',
formatDetail: 'Taschenbuch',
ean: "1234567890",
format: "TB",
formatDetail: "Taschenbuch",
} as Product,
quantity: { quantity: 1 },
receiptNumber: 'R-001',
receiptNumber: "R-001",
},
2: {
id: 2,
actions: [{ key: 'canReturn', value: 'false' }],
actions: [{ key: "canReturn", value: "false" }],
product: {
ean: '0987654321',
format: 'GEB',
formatDetail: 'Buch',
ean: "0987654321",
format: "GEB",
formatDetail: "Buch",
} as Product,
quantity: { quantity: 1 },
receiptNumber: 'R-002',
receiptNumber: "R-002",
},
3: {
id: 3,
actions: [{ key: 'canReturn', value: 'true' }],
actions: [{ key: "canReturn", value: "true" }],
product: {
ean: '1122334455',
format: 'AU',
formatDetail: 'Audio',
ean: "1122334455",
format: "AU",
formatDetail: "Audio",
} as Product,
quantity: { quantity: 1 },
receiptNumber: 'R-003',
receiptNumber: "R-003",
},
};
describe('ReturnProcessStore', () => {
describe("ReturnProcessStore", () => {
const createService = createServiceFactory({
service: ReturnProcessStore,
mocks: [IDBStorageProvider, ProcessService],
});
describe('Initialization', () => {
it('should create an instance of ReturnProcessStore', () => {
describe("Initialization", () => {
it("should create an instance of ReturnProcessStore", () => {
const spectator = createService();
expect(spectator.service).toBeTruthy();
});
it('should have a nextId computed property', () => {
it("should have a nextId computed property", () => {
const spectator = createService();
expect(spectator.service.nextId()).toBe(1); // Assuming no entities exist initially
});
});
describe('Entity Management', () => {
it('should remove all entities by process id', () => {
describe("Entity Management", () => {
it("should remove all entities by process id", () => {
const spectator = createService();
const store = spectator.service;
@@ -75,9 +76,10 @@ describe('ReturnProcessStore', () => {
processId: 1,
receiptId: 1,
receiptItem: TEST_ITEMS[1],
receiptDate: '',
receiptDate: "",
answers: {},
productCategory: undefined,
productCategory: ProductCategory.BookCalendar,
quantity: 1,
returnReceipt: undefined,
},
{
@@ -85,9 +87,10 @@ describe('ReturnProcessStore', () => {
processId: 2,
receiptId: 2,
receiptItem: TEST_ITEMS[2],
receiptDate: '',
receiptDate: "",
answers: {},
productCategory: undefined,
productCategory: ProductCategory.BookCalendar,
quantity: 1,
returnReceipt: undefined,
},
{
@@ -95,9 +98,10 @@ describe('ReturnProcessStore', () => {
processId: 1,
receiptId: 3,
receiptItem: TEST_ITEMS[3],
receiptDate: '',
receiptDate: "",
answers: {},
productCategory: undefined,
productCategory: ProductCategory.BookCalendar,
quantity: 1,
returnReceipt: undefined,
},
] as ReturnProcess[]),
@@ -108,7 +112,7 @@ describe('ReturnProcessStore', () => {
expect(store.entities()[0].processId).toBe(2);
});
it('should set an answer for a given entity', () => {
it("should set an answer for a given entity", () => {
const spectator = createService();
const store = spectator.service;
@@ -120,19 +124,20 @@ describe('ReturnProcessStore', () => {
processId: 1,
receiptId: 1,
receiptItem: TEST_ITEMS[1],
receiptDate: '',
receiptDate: "",
answers: {},
productCategory: undefined,
productCategory: ProductCategory.BookCalendar,
quantity: 1,
returnReceipt: undefined,
},
] as ReturnProcess[]),
);
store.setAnswer(1, 'question1', 'answer1');
expect(store.entityMap()[1].answers['question1']).toBe('answer1');
store.setAnswer(1, "question1", "answer1");
expect(store.entityMap()[1].answers["question1"]).toBe("answer1");
});
it('should remove an answer for a given entity', () => {
it("should remove an answer for a given entity", () => {
const spectator = createService();
const store = spectator.service;
@@ -141,25 +146,26 @@ describe('ReturnProcessStore', () => {
setEntity({
id: 1,
processId: 1,
answers: { question1: 'answer1', question2: 'answer2' } as Record<
answers: { question1: "answer1", question2: "answer2" } as Record<
string,
unknown
>,
receiptDate: new Date().toJSON(),
receiptItem: TEST_ITEMS[1],
receiptId: 123,
productCategory: undefined,
productCategory: ProductCategory.BookCalendar,
quantity: 1,
returnReceipt: undefined,
} as ReturnProcess),
);
store.removeAnswer(1, 'question1');
expect(store.entityMap()[1].answers['question1']).toBeUndefined();
store.removeAnswer(1, "question1");
expect(store.entityMap()[1].answers["question1"]).toBeUndefined();
});
});
describe('Process Management', () => {
it('should initialize a new return process', () => {
describe("Process Management", () => {
it("should initialize a new return process", () => {
const spectator = createService();
const store = spectator.service;
@@ -169,28 +175,44 @@ describe('ReturnProcessStore', () => {
{
receipt: {
id: 1,
printedDate: '',
printedDate: "",
items: [],
buyer: { buyerNumber: '' },
buyer: { buyerNumber: "" },
},
items: [TEST_ITEMS[1]],
items: [
{
receiptItem: TEST_ITEMS[1],
quantity: 1,
category: ProductCategory.BookCalendar,
},
],
},
{
receipt: {
id: 2,
printedDate: '',
printedDate: "",
items: [],
buyer: { buyerNumber: '' },
buyer: { buyerNumber: "" },
},
items: [TEST_ITEMS[3]],
items: [
{
receiptItem: TEST_ITEMS[3],
quantity: 1,
category: ProductCategory.BookCalendar,
},
],
},
],
});
expect(store.entities()).toHaveLength(2);
expect(store.entities()[0].productCategory).toBe(
ProductCategory.BookCalendar,
);
expect(store.entities()[0].quantity).toBe(1);
});
it('should throw an error if no returnable items are found', () => {
it("should throw an error if no returnable items are found", () => {
const spectator = createService();
const store = spectator.service;
@@ -201,18 +223,24 @@ describe('ReturnProcessStore', () => {
{
receipt: {
id: 2,
printedDate: '',
printedDate: "",
items: [],
buyer: { buyerNumber: '' },
buyer: { buyerNumber: "" },
},
items: [TEST_ITEMS[2]], // Non-returnable item
items: [
{
receiptItem: TEST_ITEMS[2], // Non-returnable item
quantity: 1,
category: ProductCategory.BookCalendar,
},
],
},
],
});
}).toThrow(CreateReturnProcessError);
});
it('should throw an error if the number of returnable items does not match the total items', () => {
it("should throw an error if the number of returnable items does not match the total items", () => {
const spectator = createService();
const store = spectator.service;
@@ -223,11 +251,27 @@ describe('ReturnProcessStore', () => {
{
receipt: {
id: 3,
printedDate: '',
printedDate: "",
items: [],
buyer: { buyerNumber: '' },
buyer: { buyerNumber: "" },
},
items: [TEST_ITEMS[1], TEST_ITEMS[2], TEST_ITEMS[3]],
items: [
{
receiptItem: TEST_ITEMS[1],
quantity: 1,
category: ProductCategory.BookCalendar,
},
{
receiptItem: TEST_ITEMS[2],
quantity: 1,
category: ProductCategory.BookCalendar,
},
{
receiptItem: TEST_ITEMS[3],
quantity: 1,
category: ProductCategory.BookCalendar,
},
],
},
],
});

View File

@@ -5,29 +5,37 @@ import {
withHooks,
withMethods,
withProps,
} from '@ngrx/signals';
} from "@ngrx/signals";
import {
withEntities,
setAllEntities,
updateEntity,
} from '@ngrx/signals/entities';
import { IDBStorageProvider, withStorage } from '@isa/core/storage';
import { computed, effect, inject } from '@angular/core';
import { ProcessService } from '@isa/core/process';
import { Receipt, ReceiptItem, ReturnProcess } from '../models';
} from "@ngrx/signals/entities";
import { IDBStorageProvider, withStorage } from "@isa/core/storage";
import { computed, effect, inject } from "@angular/core";
import { ProcessService } from "@isa/core/process";
import { Receipt, ReceiptItem, ReturnProcess } from "../models";
import {
CreateReturnProcessError,
CreateReturnProcessErrorReason,
} from '../errors/return-process';
import { logger } from '@isa/core/logging';
import { canReturnReceiptItem } from '../helpers/return-process';
} from "../errors/return-process";
import { logger } from "@isa/core/logging";
import { canReturnReceiptItem } from "../helpers/return-process";
import { ProductCategory } from "../questions";
/**
* Interface representing the parameters required to start a return process.
*/
export type StartProcess = {
processId: number;
returns: { receipt: Receipt; items: ReceiptItem[] }[];
returns: {
receipt: Receipt;
items: {
receiptItem: ReceiptItem;
quantity: number;
category: ProductCategory;
}[];
}[];
};
/**
@@ -55,12 +63,12 @@ export type StartProcess = {
* - Throws a MismatchReturnableItemsError if the number of returnable items does not match the expected count.
*/
export const ReturnProcessStore = signalStore(
{ providedIn: 'root' },
withStorage('return-process', IDBStorageProvider),
{ providedIn: "root" },
withStorage("return-process", IDBStorageProvider),
withEntities<ReturnProcess>(),
withProps(() => ({
_logger: logger(() => ({
store: 'ReturnProcessStore',
store: "ReturnProcessStore",
})),
})),
withComputed((store) => ({
@@ -142,6 +150,7 @@ export const ReturnProcessStore = signalStore(
const returnableItems = params.returns
.flatMap((r) => r.items)
.map((item) => item.receiptItem)
.filter(canReturnReceiptItem);
if (returnableItems.length === 0) {
@@ -170,9 +179,10 @@ export const ReturnProcessStore = signalStore(
id: nextId + entities.length,
processId: params.processId,
receiptId: receipt.id,
productCategory: item.features?.['category'],
productCategory: item.category,
quantity: item.quantity,
receiptDate: receipt.printedDate,
receiptItem: item,
receiptItem: item.receiptItem,
answers: {},
});
}

View File

@@ -1,9 +1,11 @@
<div class="flex flex-row w-full">
<div
class="flex flex-row justify-end -mb-4 desktop:mb-0 w-[13.4375rem] desktop:w-full"
>
@if (quantityDropdownValues().length > 1) {
<ui-dropdown
class="quantity-dropdown"
[disabled]="!canReturnReceiptItem()"
[value]="availableQuantity()"
[value]="selectedQuantity()"
(valueChange)="setQuantity($event)"
>
@for (quantity of quantityDropdownValues(); track quantity) {

View File

@@ -1,9 +1,13 @@
:host {
@apply flex flex-col-reverse items-end desktop:flex-row desktop:justify-center desktop:items-center gap-4;
@apply flex flex-col-reverse items-end desktop:flex-row desktop:justify-center desktop:items-center gap-4;
.product-dropdown.ui-dropdown {
@apply max-w-[13.4375rem] desktop:max-w-full;
}
:has(.product-dropdown):has(.quantity-dropdown) {
.quantity-dropdown.ui-dropdown {
@apply border-r-0 pr-4;
@apply border-r-0 pr-4 pl-5 max-w-20 desktop:max-w-full;
border-top-right-radius: 0;
border-bottom-right-radius: 0;
@@ -15,7 +19,7 @@
}
.product-dropdown.ui-dropdown {
@apply border-l-0 pl-4;
@apply border-l-0 max-w-[8.75rem] desktop:max-w-full pr-5 pl-4;
border-top-left-radius: 0;
border-bottom-left-radius: 0;

View File

@@ -1,50 +1,52 @@
import { createComponentFactory, Spectator } from '@ngneat/spectator/jest';
import { MockDirective } from 'ng-mocks';
import { createComponentFactory, Spectator } from "@ngneat/spectator/jest";
import { MockDirective } from "ng-mocks";
import {
ReceiptItem,
ReturnDetailsService,
ReturnDetailsStore,
} from '@isa/oms/data-access';
} from "@isa/oms/data-access";
import { ProductImageDirective } from '@isa/shared/product-image';
import { ReturnDetailsOrderGroupItemControlsComponent } from '../return-details-order-group-item-controls/return-details-order-group-item-controls.component';
import { CheckboxComponent } from '@isa/ui/input-controls';
import { signal } from '@angular/core';
import { ProductImageDirective } from "@isa/shared/product-image";
import { ReturnDetailsOrderGroupItemControlsComponent } from "../return-details-order-group-item-controls/return-details-order-group-item-controls.component";
import { CheckboxComponent } from "@isa/ui/input-controls";
import { signal } from "@angular/core";
// Helper function to create mock ReceiptItem data
const createMockItem = (
ean: string,
canReturn: boolean,
name = 'Test Product',
category = 'BOOK', // Add default category that's not 'unknown'
name = "Test Product",
category = "BOOK", // Add default category that's not 'unknown'
availableQuantity = 2,
selectedQuantity = 1,
): ReceiptItem =>
({
id: 123,
receiptNumber: 'R-123456', // Add the required receiptNumber property
quantity: { quantity: 1 },
receiptNumber: "R-123456",
quantity: { quantity: availableQuantity },
price: {
value: { value: 19.99, currency: 'EUR' },
value: { value: 19.99, currency: "EUR" },
vat: { inPercent: 19 },
},
product: {
ean: ean,
name: name,
contributors: 'Test Author',
format: 'HC',
formatDetail: 'Hardcover',
manufacturer: 'Test Publisher',
publicationDate: '2024-01-01T00:00:00Z',
catalogProductNumber: '1234567890',
volume: '1',
contributors: "Test Author",
format: "HC",
formatDetail: "Hardcover",
manufacturer: "Test Publisher",
publicationDate: "2024-01-01T00:00:00Z",
catalogProductNumber: "1234567890",
volume: "1",
},
actions: [{ key: 'canReturn', value: String(canReturn) }],
features: { category: category }, // Add the features property with category
actions: [{ key: "canReturn", value: String(canReturn) }],
features: { category: category },
}) as ReceiptItem;
describe('ReturnDetailsOrderGroupItemControlsComponent', () => {
describe("ReturnDetailsOrderGroupItemControlsComponent", () => {
let spectator: Spectator<ReturnDetailsOrderGroupItemControlsComponent>;
const mockItemSelectable = createMockItem('1234567890123', true);
const mockItemSelectable = createMockItem("1234567890123", true);
const mockIsSelectable = signal<boolean>(true);
const mockGetItemSelectted = signal<boolean>(false);
@@ -52,6 +54,11 @@ describe('ReturnDetailsOrderGroupItemControlsComponent', () => {
isLoading: signal<boolean>(true),
};
// Mocks for availableQuantityMap and selectedQuantityMap
const mockAvailableQuantityMap = { [mockItemSelectable.id]: 2 };
const mockSelectedQuantityMap = { [mockItemSelectable.id]: 1 };
const mockItemCategoryMap = { [mockItemSelectable.id]: "BOOK" };
function resetMocks() {
mockIsSelectable.set(true);
mockGetItemSelectted.set(false);
@@ -68,12 +75,16 @@ describe('ReturnDetailsOrderGroupItemControlsComponent', () => {
isSelectable: jest.fn(() => mockIsSelectable),
getItemSelected: jest.fn(() => mockGetItemSelectted),
canReturnResource: jest.fn(() => mockCanReturnResource),
availableQuantityMap: jest.fn(() => mockAvailableQuantityMap),
selectedQuantityMap: jest.fn(() => mockSelectedQuantityMap),
itemCategoryMap: jest.fn(() => mockItemCategoryMap),
setProductCategory: jest.fn(),
setQuantity: jest.fn(),
addSelectedItems: jest.fn(),
removeSelectedItems: jest.fn(),
},
},
],
// Spectator automatically stubs standalone dependencies like ItemRowComponent, CheckboxComponent etc.
// We don't need deep interaction, just verify the host component renders correctly.
// If specific interactions were needed, we could provide mocks or use overrideComponents.
overrideComponents: [
[
ReturnDetailsOrderGroupItemControlsComponent,
@@ -85,50 +96,41 @@ describe('ReturnDetailsOrderGroupItemControlsComponent', () => {
},
],
],
detectChanges: false, // Control initial detection manually
detectChanges: false,
});
beforeEach(() => {
// Default setup with a selectable item
spectator = createComponent({
props: {
item: mockItemSelectable, // Use signal for input
item: mockItemSelectable,
},
});
});
afterEach(() => {
resetMocks(); // Reset mocks after each test
resetMocks();
});
it('should create', () => {
// Arrange
spectator.detectChanges(); // Trigger initial render
// Assert
it("should create", () => {
spectator.detectChanges();
expect(spectator.component).toBeTruthy();
});
it('should display the checkbox when item is selectable', () => {
// Arrange
mockCanReturnResource.isLoading.set(false); // Simulate the resource being ready
mockIsSelectable.set(true); // Simulate the item being selectable
it("should display the checkbox when item is selectable and not loading", () => {
mockCanReturnResource.isLoading.set(false);
mockIsSelectable.set(true);
spectator.detectChanges();
// Assert
expect(spectator.component.selectable()).toBe(true);
const checkbox = spectator.query(CheckboxComponent);
expect(checkbox).toBeTruthy();
expect(spectator.query(CheckboxComponent)).toBeTruthy();
expect(
spectator.query(`input[data-what="return-item-checkbox"]`),
).toExist();
});
it('should NOT display the checkbox when item is not selectable', () => {
// Arrange
mockIsSelectable.set(false); // Simulate the item not being selectable
spectator.detectChanges();
spectator.detectComponentChanges();
// Assert
it("should NOT display the checkbox when item is not selectable", () => {
mockIsSelectable.set(false);
mockCanReturnResource.isLoading.set(false);
spectator.detectChanges();
expect(spectator.component.selectable()).toBe(false);
expect(
spectator.query(`input[data-what="return-item-checkbox"]`),
@@ -136,27 +138,73 @@ describe('ReturnDetailsOrderGroupItemControlsComponent', () => {
expect(spectator.query(CheckboxComponent)).toBeFalsy();
});
it('should be false when no canReturn action is present', () => {
// Arrange
const item = { ...createMockItem('0001', true), actions: [] };
spectator.setInput('item', item as any);
// Act
it("should show spinner when canReturnResource is loading", () => {
mockCanReturnResource.isLoading.set(true);
spectator.detectChanges();
expect(
spectator.query('ui-icon-button[data-what="load-spinner"]'),
).toExist();
});
// Assert
it("should render correct quantity dropdown values", () => {
spectator.detectChanges();
expect(spectator.component.quantityDropdownValues()).toEqual([1, 2]);
});
it("should call setQuantity when dropdown value changes", () => {
const store = spectator.inject(ReturnDetailsStore);
const spy = jest.spyOn(store, "setQuantity");
spectator.detectChanges();
// Simulate dropdown value change
spectator.component.setQuantity(2);
expect(spy).toHaveBeenCalledWith(mockItemSelectable.id, 2);
});
it("should call setProductCategory when product category changes", () => {
const store = spectator.inject(ReturnDetailsStore);
const spy = jest.spyOn(store, "setProductCategory");
spectator.detectChanges();
spectator.component.setProductCategory("Buch/Kalender");
expect(spy).toHaveBeenCalledWith(mockItemSelectable.id, "Buch/Kalender");
});
it("should call addSelectedItems when setSelected(true) is called", () => {
const store = spectator.inject(ReturnDetailsStore);
const spy = jest.spyOn(store, "addSelectedItems");
spectator.detectChanges();
spectator.component.setSelected(true);
expect(spy).toHaveBeenCalledWith([mockItemSelectable.id]);
});
it("should call removeSelectedItems when setSelected(false) is called", () => {
const store = spectator.inject(ReturnDetailsStore);
const spy = jest.spyOn(store, "removeSelectedItems");
spectator.detectChanges();
spectator.component.setSelected(false);
expect(spy).toHaveBeenCalledWith([mockItemSelectable.id]);
});
it("should be false when no canReturn action is present", () => {
const item = { ...createMockItem("0001", true), actions: [] };
spectator.setInput("item", item as any);
spectator.detectChanges();
expect(spectator.component.canReturnReceiptItem()).toBe(false);
});
it('should be false when canReturn action has falsy value', () => {
// Arrange
const item = createMockItem('0001', false);
spectator.setInput('item', item);
// Act
it("should be false when canReturn action has falsy value", () => {
const item = createMockItem("0001", false);
spectator.setInput("item", item);
spectator.detectChanges();
// Assert
expect(spectator.component.canReturnReceiptItem()).toBe(false);
});
it("should display correct selected quantity", () => {
spectator.detectChanges();
expect(spectator.component.selectedQuantity()).toBe(1);
});
it("should display correct product category", () => {
spectator.detectChanges();
expect(spectator.component.productCategory()).toBe("BOOK");
});
});

View File

@@ -5,30 +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,
getReceiptItemReturnedQuantity,
getReceiptItemProductCategory,
getReceiptItemQuantity,
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: [
@@ -40,7 +37,7 @@ import { FormsModule } from '@angular/forms';
],
providers: [
provideLoggerContext({
component: 'ReturnDetailsOrderGroupItemControlsComponent',
component: "ReturnDetailsOrderGroupItemControlsComponent",
}),
],
})
@@ -66,38 +63,11 @@ export class ReturnDetailsOrderGroupItemControlsComponent {
availableCategories = this.#returnDetailsService.availableCategories();
/**
* Computes the quantity of the current receipt item that has already been returned.
*
* This value is derived from the item's return history and is used to indicate
* how many units have already been processed for return.
*
* @returns The number of units already returned for this receipt item.
*/
returnedQuantity = computed(() => {
selectedQuantity = computed(() => {
const item = this.item();
return getReceiptItemReturnedQuantity(item);
return this.#store.selectedQuantityMap()[item.id];
});
/**
* Computes the total quantity for the current receipt item.
* Represents the original quantity as recorded in the receipt.
*
* @returns The total quantity for the item.
*/
quantity = computed(() => {
const item = this.item();
return getReceiptItemQuantity(item);
});
/**
* Computes the quantity of the item that is still available for return.
* Calculated as the difference between the total quantity and the returned quantity.
*
* @returns The number of units available to be returned.
*/
availableQuantity = computed(() => this.quantity() - this.returnedQuantity());
/**
* Generates the list of selectable quantities for the dropdown.
* The values range from 1 up to the available quantity.
@@ -105,13 +75,14 @@ export class ReturnDetailsOrderGroupItemControlsComponent {
* @returns An array of selectable quantity values.
*/
quantityDropdownValues = computed(() => {
const itemQuantity = this.availableQuantity();
const item = this.item();
const itemQuantity = this.#store.availableQuantityMap()[item.id];
return Array.from({ length: itemQuantity }, (_, i) => i + 1);
});
productCategory = computed(() => {
const item = this.item();
return getReceiptItemProductCategory(item);
return this.#store.itemCategoryMap()[item.id];
});
selectable = this.#store.isSelectable(this.item);
@@ -127,8 +98,9 @@ export class ReturnDetailsOrderGroupItemControlsComponent {
}
setQuantity(quantity: number | undefined) {
const item = this.item();
if (quantity === undefined) {
quantity = this.item().quantity.quantity;
quantity = this.#store.availableQuantityMap()[item.id];
}
this.#store.setQuantity(this.item().id, quantity);
}

View File

@@ -57,7 +57,7 @@
{{ 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.publicationDate | date: "dd. MMM yyyy" }}
</div>
</div>
<oms-feature-return-details-order-group-item-controls [item]="i">
@@ -73,11 +73,11 @@
</div>
}
@if (returnedQuantity() > 0 && itemQuantity() !== returnedQuantity()) {
@if (availableQuantity() !== quantity()) {
<div
class="flex items-center self-start text-isa-neutral-600 isa-text-body-2-bold pb-6"
>
Es wurden bereits {{ returnedQuantity() }} von {{ itemQuantity() }} Artikel
zurückgegeben.
Es wurden bereits {{ quantity() - availableQuantity() }} von
{{ quantity() }} Artikel zurückgegeben.
</div>
}

View File

@@ -1,29 +1,28 @@
import { CurrencyPipe, DatePipe, LowerCasePipe } from '@angular/common';
import { CurrencyPipe, DatePipe, LowerCasePipe } from "@angular/common";
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
input,
} from '@angular/core';
import { isaActionClose, ProductFormatIconGroup } from '@isa/icons';
} from "@angular/core";
import { isaActionClose, ProductFormatIconGroup } from "@isa/icons";
import {
getReceiptItemAction,
getReceiptItemReturnedQuantity,
getReceiptItemQuantity,
ReceiptItem,
ReturnDetailsStore,
} from '@isa/oms/data-access';
import { ProductImageDirective } from '@isa/shared/product-image';
import { ItemRowComponent } from '@isa/ui/item-rows';
import { NgIconComponent, provideIcons } from '@ng-icons/core';
import { ReturnDetailsOrderGroupItemControlsComponent } from '../return-details-order-group-item-controls/return-details-order-group-item-controls.component';
import { ProductRouterLinkDirective } from '@isa/shared/product-router-link';
} from "@isa/oms/data-access";
import { ProductImageDirective } from "@isa/shared/product-image";
import { ItemRowComponent } from "@isa/ui/item-rows";
import { NgIconComponent, provideIcons } from "@ng-icons/core";
import { ReturnDetailsOrderGroupItemControlsComponent } from "../return-details-order-group-item-controls/return-details-order-group-item-controls.component";
import { ProductRouterLinkDirective } from "@isa/shared/product-router-link";
@Component({
selector: 'oms-feature-return-details-order-group-item',
templateUrl: './return-details-order-group-item.component.html',
styleUrls: ['./return-details-order-group-item.component.scss'],
selector: "oms-feature-return-details-order-group-item",
templateUrl: "./return-details-order-group-item.component.html",
styleUrls: ["./return-details-order-group-item.component.scss"],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [
@@ -82,7 +81,7 @@ export class ReturnDetailsOrderGroupItemComponent {
*/
canReturnMessage = computed(() => {
const item = this.item();
const canReturnAction = getReceiptItemAction(item, 'canReturn');
const canReturnAction = getReceiptItemAction(item, "canReturn");
if (canReturnAction?.description) {
return canReturnAction.description;
@@ -90,30 +89,32 @@ export class ReturnDetailsOrderGroupItemComponent {
const canReturnMessage = this.canReturn()?.message;
return canReturnMessage ?? '';
return canReturnMessage ?? "";
});
/**
* Computes the quantity of the current receipt item that has already been returned.
* The original quantity of the item as recorded in the order.
* This value is retrieved from the store and represents the total number of units
* initially purchased for this receipt item.
*
* This value is derived using the item's return history and is used to display
* how many units of this item have been processed for return so far.
*
* @returns The number of units already returned for this receipt item.
* @readonly
* @returns {number} The original quantity of the item in the order.
*/
returnedQuantity = computed(() => {
const item = this.item();
return getReceiptItemReturnedQuantity(item);
});
/**
* Computes the total quantity for the current receipt item.
* Represents the original quantity of the item as recorded in the receipt.
*
* @returns The total quantity for the item.
*/
itemQuantity = computed(() => {
quantity = computed(() => {
const item = this.item();
return getReceiptItemQuantity(item);
});
/**
* The currently available quantity of the item for return.
* This value is computed based on the item's current state and may be less than
* the original quantity if some units have already been returned or are otherwise unavailable.
*
* @readonly
* @returns {number} The number of units available for return.
*/
availableQuantity = computed(() => {
const item = this.item();
return this.#store.availableQuantityMap()[item.id];
});
}

View File

@@ -22,7 +22,7 @@
></oms-feature-return-details-static>
@if (customerReceiptsResource.isLoading()) {
<ui-progress-bar class="w-full" mode="indeterminate"></ui-progress-bar>
} @else {
} @else if (!customerReceiptsResource.error()) {
@for (receipt of customerReceiptsResource.value(); track receipt.id) {
@if (r.id !== receipt.id) {
<oms-feature-return-details-lazy

View File

@@ -2,35 +2,34 @@ import {
ChangeDetectionStrategy,
Component,
computed,
effect,
inject,
resource,
} from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { toSignal } from '@angular/core/rxjs-interop';
import { z } from 'zod';
} from "@angular/core";
import { ActivatedRoute, Router } from "@angular/router";
import { toSignal } from "@angular/core/rxjs-interop";
import { z } from "zod";
import { NgIconComponent, provideIcons } from '@ng-icons/core';
import { isaActionChevronLeft } from '@isa/icons';
import { ButtonComponent } from '@isa/ui/buttons';
import { injectActivatedProcessId } from '@isa/core/process';
import { Location } from '@angular/common';
import { ExpandableDirectives } from '@isa/ui/expandable';
import { ProgressBarComponent } from '@isa/ui/progress-bar';
import { NgIconComponent, provideIcons } from "@ng-icons/core";
import { isaActionChevronLeft } from "@isa/icons";
import { ButtonComponent } from "@isa/ui/buttons";
import { injectActivatedProcessId } from "@isa/core/process";
import { Location } from "@angular/common";
import { ExpandableDirectives } from "@isa/ui/expandable";
import { ProgressBarComponent } from "@isa/ui/progress-bar";
import {
ReturnDetailsService,
ReturnProcessStore,
ReturnDetailsStore,
} from '@isa/oms/data-access';
import { ReturnDetailsStaticComponent } from './return-details-static/return-details-static.component';
import { ReturnDetailsLazyComponent } from './return-details-lazy/return-details-lazy.component';
import { logger } from '@isa/core/logging';
import { groupBy } from 'lodash';
} from "@isa/oms/data-access";
import { ReturnDetailsStaticComponent } from "./return-details-static/return-details-static.component";
import { ReturnDetailsLazyComponent } from "./return-details-lazy/return-details-lazy.component";
import { logger } from "@isa/core/logging";
import { groupBy } from "lodash";
@Component({
selector: 'oms-feature-return-details',
templateUrl: './return-details.component.html',
styleUrls: ['./return-details.component.scss'],
selector: "oms-feature-return-details",
templateUrl: "./return-details.component.html",
styleUrls: ["./return-details.component.scss"],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
ReturnDetailsStaticComponent,
@@ -40,11 +39,11 @@ import { groupBy } from 'lodash';
ExpandableDirectives,
ProgressBarComponent,
],
providers: [provideIcons({ isaActionChevronLeft })],
providers: [provideIcons({ isaActionChevronLeft }), ReturnDetailsStore],
})
export class ReturnDetailsComponent {
#logger = logger(() => ({
component: ReturnDetailsComponent.name,
component: "ReturnDetailsComponent",
itemId: this.receiptId(),
processId: this.processId(),
params: this.params(),
@@ -66,29 +65,30 @@ export class ReturnDetailsComponent {
receiptId = computed<number>(() => {
const params = this.params();
if (params) {
return z.coerce.number().parse(params['receiptId']);
return z.coerce.number().parse(params["receiptId"]);
}
throw new Error('No receiptId found in route params');
throw new Error("No receiptId found in route params");
});
// Effect resets the Store's state when the receiptId changes
// This ensures that the store is always in sync with the current receiptId
receiptIdEffect = effect(() => this.#store.selectStorage(this.receiptId()));
receiptResource = this.#store.receiptResource(this.receiptId);
customerReceiptsResource = resource({
request: this.receiptResource.value,
loader: async ({ request, abortSignal }) => {
console.log('Fetching customer receipts for:', request);
const email = request?.buyer?.communicationDetails?.email;
if (!email) {
return [];
}
return await this.#returnDetailsService.fetchReceiptsByEmail(
{ email },
abortSignal,
);
try {
return await this.#returnDetailsService.fetchReceiptsByEmail(
{ email },
abortSignal,
);
} catch (error) {
this.#logger.error("Failed to fetch customer receipts", error);
return [];
}
},
});
@@ -101,15 +101,17 @@ export class ReturnDetailsComponent {
startProcess() {
if (!this.canStartProcess()) {
this.#logger.warn(
'Cannot start process: No items selected or no process ID',
"Cannot start process: No items selected or no process ID",
);
return;
}
const processId = this.processId();
const selectedItems = this.#store.selectedItems();
const selectedQuantites = this.#store.selectedQuantityMap();
const selectedProductCategories = this.#store.itemCategoryMap();
this.#logger.info('Starting return process', () => ({
this.#logger.info("Starting return process", () => ({
processId: processId,
selectedItems: selectedItems.map((item) => item.id),
}));
@@ -127,11 +129,18 @@ export class ReturnDetailsComponent {
const returns = Object.entries(itemsGrouptByReceiptId).map(
([receiptId, items]) => ({
receipt: receipts[Number(receiptId)],
items,
items: items.map((item) => {
const receiptItem = item;
return {
receiptItem,
quantity: selectedQuantites[receiptItem.id],
category: selectedProductCategories[receiptItem.id],
};
}),
}),
);
this.#logger.info('Starting return process with returns', () => ({
this.#logger.info("Starting return process with returns", () => ({
processId,
returns,
}));
@@ -141,7 +150,7 @@ export class ReturnDetailsComponent {
returns,
});
this._router.navigate(['../../', 'process'], {
this._router.navigate(["../../", "process"], {
relativeTo: this._activatedRoute,
});
}

View File

@@ -3,28 +3,28 @@ import {
Component,
computed,
inject,
} from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { CallbackResult, ListResponseArgs } from '@isa/common/data-access';
import { injectActivatedProcessId } from '@isa/core/process';
} from "@angular/core";
import { ActivatedRoute, Router } from "@angular/router";
import { CallbackResult, ListResponseArgs } from "@isa/common/data-access";
import { injectActivatedProcessId } from "@isa/core/process";
import {
ReceiptListItem,
ReturnSearchStatus,
ReturnSearchStore,
} from '@isa/oms/data-access';
import { ReturnTaskListComponent } from '@isa/oms/shared/task-list';
} from "@isa/oms/data-access";
import { ReturnTaskListComponent } from "@isa/oms/shared/task-list";
import {
FilterService,
SearchBarInputComponent,
FilterInputMenuButtonComponent,
} from '@isa/shared/filter';
import { IconButtonComponent } from '@isa/ui/buttons';
import { TooltipIconComponent } from '@isa/ui/tooltip';
} from "@isa/shared/filter";
import { IconButtonComponent } from "@isa/ui/buttons";
import { TooltipIconComponent } from "@isa/ui/tooltip";
@Component({
selector: 'oms-feature-return-search-main',
templateUrl: './return-search-main.component.html',
styleUrls: ['./return-search-main.component.scss'],
selector: "oms-feature-return-search-main",
templateUrl: "./return-search-main.component.html",
styleUrls: ["./return-search-main.component.scss"],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
SearchBarInputComponent,
@@ -55,7 +55,7 @@ export class ReturnSearchMainComponent {
});
filterInputs = computed(() =>
this._filterService.inputs().filter((input) => input.group === 'filter'),
this._filterService.inputs().filter((input) => input.group === "filter"),
);
// TODO: Suche als Provider in FilterService auslagern (+ Cancel Search, + Fetching Status)
@@ -77,10 +77,10 @@ export class ReturnSearchMainComponent {
}: CallbackResult<ListResponseArgs<ReceiptListItem>>) => {
if (data) {
if (data.result.length === 1) {
this.navigate(['receipt', data.result[0].id]);
} else if (data.result.length > 1) {
this.navigate(['receipts']);
return this.navigate(["receipt", data.result[0].id]);
}
return this.navigate(["receipts"]);
}
};

View File

@@ -1,4 +1,7 @@
<ui-client-row data-what="search-result-item" [attr.data-which]="receiptNumber()">
<ui-client-row
data-what="search-result-item"
[attr.data-which]="receiptNumber()"
>
<ui-client-row-content>
<h3 class="isa-text-subtitle-1-regular">{{ name() }}</h3>
</ui-client-row-content>
@@ -7,12 +10,12 @@
<ui-item-row-data-label>Belegdatum</ui-item-row-data-label>
<ui-item-row-data-value>
<span class="isa-text-body-2-bold">
{{ receiptDate() | date: 'dd.MM.yy' }}
{{ receiptDate() | date: "dd.MM.yy" }}
</span>
</ui-item-row-data-value>
</ui-item-row-data-row>
<ui-item-row-data-row>
<ui-item-row-data-label>Rechnugsnr.</ui-item-row-data-label>
<ui-item-row-data-label>Beleg-Nr.</ui-item-row-data-label>
<ui-item-row-data-value>
<span class="isa-text-body-2-bold"> {{ receiptNumber() }} </span>
</ui-item-row-data-value>

View File

@@ -35,6 +35,7 @@
name="isaActionEdit"
data-what="button"
data-which="edit-return-item"
(click)="navigateBack()"
[disabled]="returnItemsAndPrintReciptPending()"
(click)="location.back()"
></ui-icon-button>
</div>

View File

@@ -1,16 +1,16 @@
import { createRoutingFactory, Spectator } from '@ngneat/spectator/jest';
import { ReturnSummaryItemComponent } from './return-summary-item.component';
import { MockComponents, MockProvider } from 'ng-mocks';
import { ReturnProductInfoComponent } from '@isa/oms/shared/product-info';
import { createRoutingFactory, Spectator } from "@ngneat/spectator/jest";
import { ReturnSummaryItemComponent } from "./return-summary-item.component";
import { MockComponents, MockProvider } from "ng-mocks";
import { ReturnProductInfoComponent } from "@isa/oms/shared/product-info";
import {
Product,
ReturnProcess,
ReturnProcessQuestionKey,
ReturnProcessService,
} from '@isa/oms/data-access';
import { NgIcon } from '@ng-icons/core';
import { IconButtonComponent } from '@isa/ui/buttons';
import { Router } from '@angular/router';
} from "@isa/oms/data-access";
import { NgIcon } from "@ng-icons/core";
import { IconButtonComponent } from "@isa/ui/buttons";
import { Location } from "@angular/common";
/**
* Creates a mock ReturnProcess with default values that can be overridden
@@ -21,20 +21,20 @@ function createMockReturnProcess(
return {
id: 1,
processId: 1,
productCategory: 'Electronics',
productCategory: "Electronics",
answers: {},
receiptId: 123,
receiptItem: {
id: 321,
product: {
name: 'Test Product',
name: "Test Product",
},
},
...partial,
} as ReturnProcess;
}
describe('ReturnSummaryItemComponent', () => {
describe("ReturnSummaryItemComponent", () => {
let spectator: Spectator<ReturnSummaryItemComponent>;
let returnProcessService: jest.Mocked<ReturnProcessService>;
@@ -48,7 +48,10 @@ describe('ReturnSummaryItemComponent', () => {
providers: [
MockProvider(ReturnProcessService, {
getReturnInfo: jest.fn(),
eligibleForReturn: jest.fn().mockReturnValue({ state: 'eligible' }),
eligibleForReturn: jest.fn().mockReturnValue({ state: "eligible" }),
}),
MockProvider(Location, {
back: jest.fn(),
}),
],
shallow: true,
@@ -64,38 +67,38 @@ describe('ReturnSummaryItemComponent', () => {
spectator.detectChanges();
});
describe('Component Creation', () => {
it('should create the component', () => {
describe("Component Creation", () => {
it("should create the component", () => {
expect(spectator.component).toBeTruthy();
});
});
describe('Return Information Display', () => {
describe("Return Information Display", () => {
const mockReturnInfo = {
itemCondition: 'itemCondition',
returnDetails: { [ReturnProcessQuestionKey.CaseDamaged]: 'no' },
returnReason: 'returnReason',
itemCondition: "itemCondition",
returnDetails: { [ReturnProcessQuestionKey.CaseDamaged]: "no" },
returnReason: "returnReason",
otherProduct: {
ean: 'ean',
ean: "ean",
} as Product,
comment: 'comment',
comment: "comment",
};
beforeEach(() => {
jest
.spyOn(returnProcessService, 'getReturnInfo')
.spyOn(returnProcessService, "getReturnInfo")
.mockReturnValue(mockReturnInfo);
spectator.setInput('returnProcess', createMockReturnProcess({ id: 2 }));
spectator.setInput("returnProcess", createMockReturnProcess({ id: 2 }));
spectator.detectChanges();
});
it('should provide correct return information array', () => {
it("should provide correct return information array", () => {
// Arrange
const expectedInfos = [
'itemCondition',
'returnReason',
'Gehäuse beschädigt: no',
'Geliefert wurde: ean',
'comment',
"itemCondition",
"returnReason",
"Gehäuse beschädigt: no",
"Geliefert wurde: ean",
"comment",
];
// Act
@@ -105,14 +108,14 @@ describe('ReturnSummaryItemComponent', () => {
expect(actualInfos).toEqual(expectedInfos);
expect(actualInfos.length).toBe(5);
});
it('should render return info items with correct content', () => {
it("should render return info items with correct content", () => {
// Arrange
const expectedInfos = [
'itemCondition',
'returnReason',
'Gehäuse beschädigt: no',
'Geliefert wurde: ean',
'comment',
"itemCondition",
"returnReason",
"Gehäuse beschädigt: no",
"Geliefert wurde: ean",
"comment",
];
// Act
@@ -125,14 +128,14 @@ describe('ReturnSummaryItemComponent', () => {
expect(listItems.length).toBe(expectedInfos.length);
listItems.forEach((item, index) => {
expect(item).toHaveText(expectedInfos[index]);
expect(item).toHaveAttribute('data-info-index', index.toString());
expect(item).toHaveAttribute("data-info-index", index.toString());
});
});
it('should handle undefined return info gracefully', () => {
it("should handle undefined return info gracefully", () => {
// Arrange
returnProcessService.getReturnInfo.mockReturnValue(undefined);
spectator.setInput('returnProcess', createMockReturnProcess({ id: 3 }));
spectator.setInput("returnProcess", createMockReturnProcess({ id: 3 }));
spectator.detectChanges();
// Act
@@ -146,26 +149,26 @@ describe('ReturnSummaryItemComponent', () => {
expect(listItems.length).toBe(0);
});
describe('returnDetails mapping', () => {
it('should map multiple returnDetails keys to correct info strings', () => {
describe("returnDetails mapping", () => {
it("should map multiple returnDetails keys to correct info strings", () => {
const expected = [
'itemCondition',
'returnReason',
'Gehäuse beschädigt: Ja',
'Display beschädigt: Nein',
'Geliefert wurde: ean',
'comment',
"itemCondition",
"returnReason",
"Gehäuse beschädigt: Ja",
"Display beschädigt: Nein",
"Geliefert wurde: ean",
"comment",
];
// Arrange
const details = {
[ReturnProcessQuestionKey.CaseDamaged]: 'Ja',
[ReturnProcessQuestionKey.DisplayDamaged]: 'Nein',
[ReturnProcessQuestionKey.CaseDamaged]: "Ja",
[ReturnProcessQuestionKey.DisplayDamaged]: "Nein",
};
returnProcessService.getReturnInfo.mockReturnValue({
...mockReturnInfo,
returnDetails: details,
});
spectator.setInput('returnProcess', createMockReturnProcess({ id: 4 }));
spectator.setInput("returnProcess", createMockReturnProcess({ id: 4 }));
spectator.detectChanges();
// Act
@@ -173,31 +176,31 @@ describe('ReturnSummaryItemComponent', () => {
expect(infos).toEqual(expected);
});
it('should not include returnDetails if empty', () => {
it("should not include returnDetails if empty", () => {
// Arrange
returnProcessService.getReturnInfo.mockReturnValue({
...mockReturnInfo,
returnDetails: {},
});
spectator.setInput('returnProcess', createMockReturnProcess({ id: 5 }));
spectator.setInput("returnProcess", createMockReturnProcess({ id: 5 }));
spectator.detectChanges();
// Act
const infos = spectator.component.returnInfos();
// Assert
expect(infos.some((info) => info.includes('Gehäuse beschädigt'))).toBe(
expect(infos.some((info) => info.includes("Gehäuse beschädigt"))).toBe(
false,
);
expect(infos.some((info) => info.includes('Zubehör fehlt'))).toBe(
expect(infos.some((info) => info.includes("Zubehör fehlt"))).toBe(
false,
);
});
});
});
describe('Navigation', () => {
it('should render edit button with correct attributes', () => {
describe("Navigation", () => {
it("should render edit button with correct attributes", () => {
// Assert
const editButton = spectator.query(
'[data-what="button"][data-which="edit-return-item"]',
@@ -205,7 +208,7 @@ describe('ReturnSummaryItemComponent', () => {
expect(editButton).toExist();
});
it('should navigate back when edit button is clicked', () => {
it("should navigate back when edit button is clicked", () => {
// Arrange
const editButton = spectator.query(
'[data-what="button"][data-which="edit-return-item"]',
@@ -217,25 +220,20 @@ describe('ReturnSummaryItemComponent', () => {
}
// Assert
expect(spectator.inject(Router).navigate).toHaveBeenCalledWith(
['..'],
expect.objectContaining({
relativeTo: expect.anything(),
}),
);
expect(spectator.inject(Location).back).toHaveBeenCalled();
});
});
it('should render the product info component', () => {
it("should render the product info component", () => {
const productInfo = spectator.query(ReturnProductInfoComponent);
expect(productInfo).toExist();
});
it('should compute eligibility state as eligible', () => {
it("should compute eligibility state as eligible", () => {
(returnProcessService.eligibleForReturn as jest.Mock).mockReturnValue({
state: 'eligible',
state: "eligible",
});
spectator.detectChanges();
expect(spectator.component.eligibleForReturn()?.state).toBe('eligible');
expect(spectator.component.eligibleForReturn()?.state).toBe("eligible");
});
});

View File

@@ -4,13 +4,13 @@ import {
computed,
inject,
input,
} from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
} from "@angular/core";
import { Location } from "@angular/common";
import {
isaActionChevronRight,
isaActionClose,
isaActionEdit,
} from '@isa/icons';
} from "@isa/icons";
import {
EligibleForReturn,
EligibleForReturnState,
@@ -18,10 +18,10 @@ import {
ReturnProcessService,
ProductCategory,
returnDetailsMapping,
} from '@isa/oms/data-access';
import { ReturnProductInfoComponent } from '@isa/oms/shared/product-info';
import { IconButtonComponent } from '@isa/ui/buttons';
import { NgIcon, provideIcons } from '@ng-icons/core';
} from "@isa/oms/data-access";
import { ReturnProductInfoComponent } from "@isa/oms/shared/product-info";
import { IconButtonComponent } from "@isa/ui/buttons";
import { NgIcon, provideIcons } from "@ng-icons/core";
/**
* Displays a single item in the return process summary, showing product details
@@ -47,30 +47,34 @@ import { NgIcon, provideIcons } from '@ng-icons/core';
* ```
*/
@Component({
selector: 'oms-feature-return-summary-item',
templateUrl: './return-summary-item.component.html',
styleUrls: ['./return-summary-item.component.scss'],
selector: "oms-feature-return-summary-item",
templateUrl: "./return-summary-item.component.html",
styleUrls: ["./return-summary-item.component.scss"],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ReturnProductInfoComponent, NgIcon, IconButtonComponent],
providers: [
provideIcons({ isaActionChevronRight, isaActionEdit, isaActionClose }),
],
host: {
'data-what': 'list-item',
'data-which': 'return-process-item',
'[attr.data-receipt-id]': 'returnProcess()?.receiptId',
'[attr.data-return-item-id]': 'returnProcess()?.returnItem?.id',
"data-what": "list-item",
"data-which": "return-process-item",
"[attr.data-receipt-id]": "returnProcess()?.receiptId",
"[attr.data-return-item-id]": "returnProcess()?.returnItem?.id",
},
})
export class ReturnSummaryItemComponent {
EligibleForReturnState = EligibleForReturnState;
#returnProcessService = inject(ReturnProcessService);
#router = inject(Router);
#activatedRoute = inject(ActivatedRoute);
/** Angular Location service for navigation */
location = inject(Location);
/** The return process object containing all information about the return */
returnProcess = input.required<ReturnProcess>();
/** The status of the return items and print receipt operation */
returnItemsAndPrintReciptPending = input<boolean>(false);
/**
* Computes whether the current return process is eligible for return.
*
@@ -149,8 +153,4 @@ export class ReturnSummaryItemComponent {
// remove duplicates
return Array.from(new Set(result));
});
navigateBack() {
this.#router.navigate(['..'], { relativeTo: this.#activatedRoute });
}
}

View File

@@ -3,6 +3,7 @@
color="tertiary"
size="small"
class="px-[0.875rem] py-1 min-w-0 bg-white gap-1 absolute top-0 left-0"
[disabled]="returnItemsAndPrintReciptStatusPending()"
(click)="location.back()"
>
<ng-icon name="isaActionChevronLeft" size="1.5rem" class="-ml-2"></ng-icon>
@@ -28,19 +29,22 @@
data-which="return-process-item"
[attr.data-item-id]="item.id"
[attr.data-item-category]="item.productCategory"
[returnItemsAndPrintReciptPending]="
returnItemsAndPrintReciptStatusPending()
"
></oms-feature-return-summary-item>
}
</div>
<div class="mt-6 text-center">
@if (returnItemsAndPrintReciptStatus() !== 'success') {
@if (returnItemsAndPrintReciptStatus() !== "success") {
<button
type="button"
size="large"
uiButton
color="brand"
(click)="returnItemsAndPrintRecipt()"
[pending]="returnItemsAndPrintReciptStatus() === 'pending'"
[disabled]="returnItemsAndPrintReciptStatus() === 'pending'"
[pending]="returnItemsAndPrintReciptStatusPending()"
[disabled]="returnItemsAndPrintReciptStatusPending()"
data-what="button"
data-which="return-and-print"
>

View File

@@ -1,30 +1,30 @@
import { createComponentFactory, Spectator } from '@ngneat/spectator/jest';
import { MockComponent } from 'ng-mocks';
import { ReturnSummaryComponent } from './return-summary.component';
import { createComponentFactory, Spectator } from "@ngneat/spectator/jest";
import { MockComponent } from "ng-mocks";
import { ReturnSummaryComponent } from "./return-summary.component";
import {
ReturnProcess,
ReturnProcessService,
ReturnProcessStore,
} from '@isa/oms/data-access';
import { ReturnSummaryItemComponent } from './return-summary-item/return-summary-item.component';
import { ActivatedRoute, Router } from '@angular/router';
} from "@isa/oms/data-access";
import { ReturnSummaryItemComponent } from "./return-summary-item/return-summary-item.component";
import { ActivatedRoute, Router } from "@angular/router";
jest.mock('@isa/core/process', () => ({
jest.mock("@isa/core/process", () => ({
injectActivatedProcessId: () => jest.fn(() => 1),
}));
const MOCK_RETURN_PROCESSES: ReturnProcess[] = [
{ id: 1, processId: 1, productCategory: 'Electronics' } as ReturnProcess,
{ id: 2, processId: 1, productCategory: 'Books' } as ReturnProcess,
{ id: 3, processId: 2, productCategory: 'Clothing' } as ReturnProcess,
{ id: 4, processId: 2, productCategory: 'Home Goods' } as ReturnProcess,
{ id: 5, processId: 3, productCategory: 'Electronics' } as ReturnProcess,
{ id: 6, processId: 3, productCategory: 'Toys' } as ReturnProcess,
{ id: 7, processId: 4, productCategory: 'Books' } as ReturnProcess,
{ id: 8, processId: 4, productCategory: 'Garden' } as ReturnProcess,
{ id: 1, processId: 1, productCategory: "Electronics" } as ReturnProcess,
{ id: 2, processId: 1, productCategory: "Books" } as ReturnProcess,
{ id: 3, processId: 2, productCategory: "Clothing" } as ReturnProcess,
{ id: 4, processId: 2, productCategory: "Home Goods" } as ReturnProcess,
{ id: 5, processId: 3, productCategory: "Electronics" } as ReturnProcess,
{ id: 6, processId: 3, productCategory: "Toys" } as ReturnProcess,
{ id: 7, processId: 4, productCategory: "Books" } as ReturnProcess,
{ id: 8, processId: 4, productCategory: "Garden" } as ReturnProcess,
];
describe('ReturnSummaryComponent', () => {
describe("ReturnSummaryComponent", () => {
let spectator: Spectator<ReturnSummaryComponent>;
// Use createComponentFactory for standalone components
const createComponent = createComponentFactory({
@@ -47,17 +47,17 @@ describe('ReturnSummaryComponent', () => {
});
});
it('should create the component', () => {
it("should create the component", () => {
// Assert: Check if the component instance was created successfully
expect(spectator.component).toBeTruthy();
});
it('should have a defined processId', () => {
it("should have a defined processId", () => {
// Assert: Check if the processId is defined
expect(spectator.component.processId()).toBeTruthy();
});
it('should have two return processes', () => {
it("should have two return processes", () => {
// Arrange:
const mockReturnProcesses: ReturnProcess[] = [
MOCK_RETURN_PROCESSES[0],
@@ -72,14 +72,14 @@ describe('ReturnSummaryComponent', () => {
expect(returnProcesses).toEqual(mockReturnProcesses);
});
it('should render the return summary item component', () => {
it("should render the return summary item component", () => {
// Arrange:
const mockReturnProcesses: ReturnProcess[] = [
MOCK_RETURN_PROCESSES[0],
MOCK_RETURN_PROCESSES[1],
];
jest
.spyOn(spectator.component, 'returnProcesses')
.spyOn(spectator.component, "returnProcesses")
.mockReturnValue(mockReturnProcesses);
// Act: Trigger change detection
@@ -91,14 +91,14 @@ describe('ReturnSummaryComponent', () => {
expect(returnSummaryItems.length).toBe(mockReturnProcesses.length);
});
it('should set the returnProcess input correctly', () => {
it("should set the returnProcess input correctly", () => {
// Arrange:
const mockReturnProcesses: ReturnProcess[] = [
MOCK_RETURN_PROCESSES[0],
MOCK_RETURN_PROCESSES[1],
];
jest
.spyOn(spectator.component, 'returnProcesses')
.spyOn(spectator.component, "returnProcesses")
.mockReturnValue(mockReturnProcesses);
// Act: Trigger change detection
@@ -110,7 +110,7 @@ describe('ReturnSummaryComponent', () => {
expect(returnSummaryItems[1].returnProcess).toEqual(mockReturnProcesses[1]);
});
it('should have proper E2E testing attributes', () => {
it("should have proper E2E testing attributes", () => {
// Arrange
const mockReturnProcesses: ReturnProcess[] = [
MOCK_RETURN_PROCESSES[0], // id: 1, processId: 1, category: 'Electronics'
@@ -118,7 +118,7 @@ describe('ReturnSummaryComponent', () => {
];
jest
.spyOn(spectator.component, 'returnProcesses')
.spyOn(spectator.component, "returnProcesses")
.mockReturnValue(mockReturnProcesses);
// Act
@@ -128,57 +128,57 @@ describe('ReturnSummaryComponent', () => {
// Check heading attributes
const heading = spectator.query('[data-what="heading"]');
expect(heading).toBeTruthy();
expect(heading).toHaveAttribute('data-which', 'return-summary-title');
expect(heading).toHaveAttribute("data-which", "return-summary-title");
// Check container attributes
const container = spectator.query('[data-what="container"]');
expect(container).toBeTruthy();
expect(container).toHaveAttribute('data-which', 'return-items-list');
expect(container).toHaveAttribute("data-which", "return-items-list");
// Check list item attributes
const listItems = spectator.queryAll('oms-feature-return-summary-item');
const listItems = spectator.queryAll("oms-feature-return-summary-item");
expect(listItems.length).toBe(mockReturnProcesses.length);
// Check attributes for the first item
expect(listItems[0]).toHaveAttribute('data-what', 'list-item');
expect(listItems[0]).toHaveAttribute('data-which', 'return-process-item');
expect(listItems[0]).toHaveAttribute("data-what", "list-item");
expect(listItems[0]).toHaveAttribute("data-which", "return-process-item");
expect(listItems[0]).toHaveAttribute(
'data-item-id',
"data-item-id",
`${mockReturnProcesses[0].id}`,
);
expect(listItems[0]).toHaveAttribute(
'data-item-category',
"data-item-category",
mockReturnProcesses[0].productCategory,
);
// Check attributes for the second item
expect(listItems[1]).toHaveAttribute('data-what', 'list-item');
expect(listItems[1]).toHaveAttribute('data-which', 'return-process-item');
expect(listItems[1]).toHaveAttribute("data-what", "list-item");
expect(listItems[1]).toHaveAttribute("data-which", "return-process-item");
expect(listItems[1]).toHaveAttribute(
'data-item-id',
"data-item-id",
`${mockReturnProcesses[1].id}`,
);
expect(listItems[1]).toHaveAttribute(
'data-item-category',
"data-item-category",
`${mockReturnProcesses[1].productCategory}`,
);
expect(listItems[1]).toHaveAttribute(
'data-item-category',
"data-item-category",
mockReturnProcesses[1].productCategory,
);
// Check button attributes
const button = spectator.query('[data-what="button"]');
expect(button).toBeTruthy();
expect(button).toHaveAttribute('data-which', 'return-and-print');
expect(button).toHaveAttribute("data-which", "return-and-print");
});
it('should call returnItemsAndPrintRecipt when button is clicked', () => {
it("should call returnItemsAndPrintRecipt when button is clicked", () => {
// Arrange: Spy on the returnItemsAndPrintRecipt method
const returnItemsAndPrintReciptSpy = jest.spyOn(
spectator.component,
'returnItemsAndPrintRecipt',
"returnItemsAndPrintRecipt",
);
// Act: Trigger button click

View File

@@ -4,20 +4,20 @@ import {
computed,
inject,
signal,
} from '@angular/core';
import { ReturnSummaryItemComponent } from './return-summary-item/return-summary-item.component';
import { injectActivatedProcessId } from '@isa/core/process';
} from "@angular/core";
import { ReturnSummaryItemComponent } from "./return-summary-item/return-summary-item.component";
import { injectActivatedProcessId } from "@isa/core/process";
import {
ReturnProcess,
ReturnProcessService,
ReturnProcessStore,
} from '@isa/oms/data-access';
import { ButtonComponent } from '@isa/ui/buttons';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { Location } from '@angular/common';
import { isaActionChevronLeft } from '@isa/icons';
import { logger } from '@isa/core/logging';
import { ActivatedRoute, Router } from '@angular/router';
} from "@isa/oms/data-access";
import { ButtonComponent } from "@isa/ui/buttons";
import { NgIcon, provideIcons } from "@ng-icons/core";
import { Location } from "@angular/common";
import { isaActionChevronLeft } from "@isa/icons";
import { logger } from "@isa/core/logging";
import { ActivatedRoute, Router } from "@angular/router";
/**
* Main component for the return summary feature. Displays a list of items being returned
@@ -35,9 +35,9 @@ import { ActivatedRoute, Router } from '@angular/router';
* ```
*/
@Component({
selector: 'oms-feature-return-summary',
templateUrl: './return-summary.component.html',
styleUrls: ['./return-summary.component.scss'],
selector: "oms-feature-return-summary",
templateUrl: "./return-summary.component.html",
styleUrls: ["./return-summary.component.scss"],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ReturnSummaryItemComponent, ButtonComponent, NgIcon],
providers: [provideIcons({ isaActionChevronLeft })],
@@ -74,9 +74,21 @@ export class ReturnSummaryComponent {
* - 'error': Operation failed
*/
returnItemsAndPrintReciptStatus = signal<
undefined | 'pending' | 'success' | 'error'
undefined | "pending" | "success" | "error"
>(undefined);
/**
* Computed signal to determine if the return items and print receipt operation is pending.
*
* This signal checks the current status of the returnItemsAndPrintReciptStatus signal
* and returns true if the status is 'pending', otherwise false.
*
* @returns {boolean} True if the operation is pending, false otherwise
*/
returnItemsAndPrintReciptStatusPending = computed(() => {
return this.returnItemsAndPrintReciptStatus() === "pending";
});
/**
* Handles the return and print process for multiple items.
*
@@ -93,35 +105,35 @@ export class ReturnSummaryComponent {
* @returns {Promise<void>}
*/
async returnItemsAndPrintRecipt() {
if (this.returnItemsAndPrintReciptStatus() === 'pending') {
this.#logger.warn('Return process already in progress', () => ({
function: 'returnItemsAndPrintRecipt',
if (this.returnItemsAndPrintReciptStatus() === "pending") {
this.#logger.warn("Return process already in progress", () => ({
function: "returnItemsAndPrintRecipt",
}));
return;
}
try {
this.returnItemsAndPrintReciptStatus.set('pending');
this.returnItemsAndPrintReciptStatus.set("pending");
const returnReceipts =
await this.#returnProcessService.completeReturnProcess(
this.returnProcesses(),
);
this.#logger.info('Return receipts created', () => ({
this.#logger.info("Return receipts created", () => ({
count: returnReceipts.length,
}));
this.returnItemsAndPrintReciptStatus.set('success');
this.returnItemsAndPrintReciptStatus.set("success");
this.#returnProcessStore.finishProcess(returnReceipts);
await this.#router.navigate(['../', 'review'], {
await this.#router.navigate(["../", "review"], {
relativeTo: this.#activatedRoute,
});
} catch (error) {
this.#logger.error('Error completing return process', error, () => ({
function: 'returnItemsAndPrintRecipt',
this.#logger.error("Error completing return process", error, () => ({
function: "returnItemsAndPrintRecipt",
}));
this.returnItemsAndPrintReciptStatus.set('error');
this.returnItemsAndPrintReciptStatus.set("error");
}
}
}

View File

@@ -3,12 +3,35 @@
}
.oms-shared-return-task-list-item__review {
@apply grid grid-cols-[1fr,1fr] desktop:grid-cols-[1fr,1fr,minmax(20rem,auto)] gap-x-6 py-6 text-isa-secondary-900 items-center border-b border-solid border-isa-neutral-300 last:pb-0 last:border-none;
@apply grid grid-cols-[1fr,1fr] desktop:grid-cols-[1fr,1fr,minmax(20rem,auto)] gap-x-6 desktop:gap-y-6 py-6 text-isa-secondary-900 items-center border-b border-solid border-isa-neutral-300 last:pb-0 last:border-none;
&:has(.task-unknown-actions):has(.tolino-print-cta) {
.tolino-print-cta {
@apply desktop:justify-self-start;
}
}
@media screen and (max-width: 1024px) {
grid-template-areas:
'product infos'
'unknown-comment actions';
"product infos"
"unknown-comment actions";
.tolino-print-cta,
.task-unknown-actions {
@apply mt-6 desktop:mt-0;
grid-area: actions;
}
&:has(.task-unknown-actions):has(.tolino-print-cta) {
.tolino-print-cta {
@apply mt-0 self-start;
grid-area: print;
}
grid-template-areas:
"product print"
"unknown-comment actions";
}
.product-info {
grid-area: product;
@@ -18,12 +41,6 @@
grid-area: infos;
}
.tolino-print-cta,
.task-unknown-actions {
@apply mt-6 desktop:mt-0;
grid-area: actions;
}
.processing-comment-unknown {
@apply mt-6 desktop:mt-0;
grid-area: unknown-comment;
@@ -48,7 +65,7 @@
}
.task-unknown-actions {
@apply flex flex-row gap-3 h-full py-2 items-center;
@apply flex flex-row gap-3 h-full py-2 items-center justify-self-end;
}
.processing-comment {

View File

@@ -17,7 +17,9 @@ class MockConfig {
}
}
jest.mock('scandit-web-datacapture-core');
jest.mock('scandit-web-datacapture-core', () => ({
configure: jest.fn(),
}));
jest.mock('scandit-web-datacapture-barcode', () => ({
barcodeCaptureLoader: jest.fn(() => 'mockedBarcodeLoader'),
}));

View File

@@ -10,7 +10,7 @@
justify-content: space-between;
ng-icon {
@apply size-6;
@apply min-w-5 size-5 mt-[0.125rem];
}
&.disabled {
@@ -58,6 +58,10 @@
}
}
.ui-dropdown__text {
@apply overflow-hidden text-ellipsis whitespace-nowrap;
}
.ui-dropdown__options {
// Fixed typo from ui-dorpdown__options
display: inline-flex;

View File

@@ -1,4 +1,4 @@
<span>{{ viewLabel() }}</span>
<span [class]="['ui-dropdown__text']">{{ viewLabel() }}</span>
<ng-icon [name]="isOpenIcon()"></ng-icon>
<ng-template

4432
package-lock.json generated
View File

File diff suppressed because it is too large Load Diff

View File

@@ -124,6 +124,7 @@
"overrides": {
"jest-environment-jsdom": {
"jsdom": "26.0.0"
}
},
"stylus": "github:stylus/stylus#0.64.0"
}
}