Merge branch 'release/4.0' into develop

This commit is contained in:
Nino
2025-07-10 14:16:29 +02:00
25 changed files with 611 additions and 392 deletions

View File

@@ -9,12 +9,14 @@ 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:
# General Guidelines
- When answering questions, use the nx_workspace tool first to gain an understanding of the workspace architecture
- For questions around nx configuration, best practices or if you're unsure, use the nx_docs tool to get relevant, up-to-date docs!! Always use this instead of assuming things about nx configuration
- If the user needs help with an Nx configuration or project graph error, use the 'nx_workspace' tool to get any errors
- To help answer questions about the workspace structure or simply help with demonstrating how tasks depend on each other, use the 'nx_visualize_graph' tool
# Generation Guidelines
If the user wants to generate something, use the following flow:
- learn about the nx workspace and any specifics the user needs by using the 'nx_workspace' tool and the 'nx_project_details' tool if applicable
@@ -29,12 +31,11 @@ If the user wants to generate something, use the following flow:
- 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.
- 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,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

@@ -31,7 +31,7 @@ const meta: Meta<ClientRowComponent> = {
</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">
1234567890

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,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,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,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;
@@ -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,4 +1,4 @@
import { computed, inject, resource, untracked } from '@angular/core';
import { computed, inject, resource } from '@angular/core';
import {
CanReturn,
ProductCategory,
@@ -7,7 +7,6 @@ import {
ReturnDetailsService,
} from '@isa/oms/data-access';
import {
getState,
patchState,
signalStore,
type,
@@ -22,13 +21,11 @@ import {
getReceiptItemQuantity,
getReceiptItemProductCategory,
receiptItemHasCategory,
getReceiptItemReturnedQuantity,
} from '../helpers/return-process';
import { SessionStorageProvider } from '@isa/core/storage';
import { logger } from '@isa/core/logging';
import { clone } from 'lodash';
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: {},
@@ -49,36 +45,11 @@ export const receiptConfig = entityConfig({
});
export const ReturnDetailsStore = signalStore(
{ providedIn: 'root' },
withState(initialState),
withEntities(receiptConfig),
withProps(() => ({
_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();
@@ -167,8 +151,8 @@ export const ReturnDetailsStore = signalStore(
{ receiptId: params },
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 ({ params, abortSignal }) => {
if (params === undefined) {
return undefined;
}
const key = `${params.item.id}:${params.category}`;
const key = `${params.receiptItemId}:${params.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

@@ -7,6 +7,7 @@ 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']> = {
1: {
@@ -77,7 +78,8 @@ describe('ReturnProcessStore', () => {
receiptItem: TEST_ITEMS[1],
receiptDate: '',
answers: {},
productCategory: undefined,
productCategory: ProductCategory.BookCalendar,
quantity: 1,
returnReceipt: undefined,
},
{
@@ -87,7 +89,8 @@ describe('ReturnProcessStore', () => {
receiptItem: TEST_ITEMS[2],
receiptDate: '',
answers: {},
productCategory: undefined,
productCategory: ProductCategory.BookCalendar,
quantity: 1,
returnReceipt: undefined,
},
{
@@ -97,7 +100,8 @@ describe('ReturnProcessStore', () => {
receiptItem: TEST_ITEMS[3],
receiptDate: '',
answers: {},
productCategory: undefined,
productCategory: ProductCategory.BookCalendar,
quantity: 1,
returnReceipt: undefined,
},
] as ReturnProcess[]),
@@ -122,7 +126,8 @@ describe('ReturnProcessStore', () => {
receiptItem: TEST_ITEMS[1],
receiptDate: '',
answers: {},
productCategory: undefined,
productCategory: ProductCategory.BookCalendar,
quantity: 1,
returnReceipt: undefined,
},
] as ReturnProcess[]),
@@ -148,7 +153,8 @@ describe('ReturnProcessStore', () => {
receiptDate: new Date().toJSON(),
receiptItem: TEST_ITEMS[1],
receiptId: 123,
productCategory: undefined,
productCategory: ProductCategory.BookCalendar,
quantity: 1,
returnReceipt: undefined,
} as ReturnProcess),
);
@@ -173,7 +179,13 @@ describe('ReturnProcessStore', () => {
items: [],
buyer: { buyerNumber: '' },
},
items: [TEST_ITEMS[1]],
items: [
{
receiptItem: TEST_ITEMS[1],
quantity: 1,
category: ProductCategory.BookCalendar,
},
],
},
{
receipt: {
@@ -182,12 +194,22 @@ describe('ReturnProcessStore', () => {
items: [],
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', () => {
@@ -205,7 +227,13 @@ describe('ReturnProcessStore', () => {
items: [],
buyer: { buyerNumber: '' },
},
items: [TEST_ITEMS[2]], // Non-returnable item
items: [
{
receiptItem: TEST_ITEMS[2], // Non-returnable item
quantity: 1,
category: ProductCategory.BookCalendar,
},
],
},
],
});
@@ -227,7 +255,23 @@ describe('ReturnProcessStore', () => {
items: [],
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

@@ -21,13 +21,21 @@ import {
} 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;
}[];
}[];
};
/**
@@ -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

@@ -2,7 +2,6 @@ import {
ChangeDetectionStrategy,
Component,
computed,
effect,
inject,
resource,
} from '@angular/core';
@@ -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(),
@@ -71,10 +70,6 @@ export class ReturnDetailsComponent {
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({
@@ -107,6 +102,8 @@ export class ReturnDetailsComponent {
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', () => ({
processId: processId,
@@ -126,7 +123,14 @@ 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],
};
}),
}),
);

View File

@@ -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

@@ -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

@@ -10,7 +10,7 @@
justify-content: space-between;
ng-icon {
@apply size-6;
@apply min-w-5 size-5 mt-[0.125rem];
}
&.disabled {
@@ -59,6 +59,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