diff --git a/.github/instructions/nx.instructions.md b/.github/instructions/nx.instructions.md index b00e68d1d..27ac23e40 100644 --- a/.github/instructions/nx.instructions.md +++ b/.github/instructions/nx.instructions.md @@ -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 ` 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. diff --git a/apps/isa-app/src/main.ts b/apps/isa-app/src/main.ts index 8ef65e650..ff648a905 100644 --- a/apps/isa-app/src/main.ts +++ b/apps/isa-app/src/main.ts @@ -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(); diff --git a/apps/isa-app/stories/ui/item-rows/client-row.stories.ts b/apps/isa-app/stories/ui/item-rows/client-row.stories.ts index e314eec70..0c91fa6b7 100644 --- a/apps/isa-app/stories/ui/item-rows/client-row.stories.ts +++ b/apps/isa-app/stories/ui/item-rows/client-row.stories.ts @@ -31,7 +31,7 @@ const meta: Meta = { - Rechnugsnr. + Beleg-Nr. 1234567890 diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 792d63640..e6baab74c 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -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' diff --git a/libs/oms/data-access/src/lib/errors/return-process/create-return-process.error.test.ts b/libs/oms/data-access/src/lib/errors/return-process/create-return-process.error.test.ts index 4d3cdab72..d2437e251 100644 --- a/libs/oms/data-access/src/lib/errors/return-process/create-return-process.error.test.ts +++ b/libs/oms/data-access/src/lib/errors/return-process/create-return-process.error.test.ts @@ -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"); }); }); diff --git a/libs/oms/data-access/src/lib/errors/return-process/create-return-process.error.ts b/libs/oms/data-access/src/lib/errors/return-process/create-return-process.error.ts index 54f4414da..2847845fc 100644 --- a/libs/oms/data-access/src/lib/errors/return-process/create-return-process.error.ts +++ b/libs/oms/data-access/src/lib/errors/return-process/create-return-process.error.ts @@ -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]); } } diff --git a/libs/oms/data-access/src/lib/helpers/return-process/return-receipt-values-mapping.helper.spec.ts b/libs/oms/data-access/src/lib/helpers/return-process/return-receipt-values-mapping.helper.spec.ts index a5ec5b885..40b21b717 100644 --- a/libs/oms/data-access/src/lib/helpers/return-process/return-receipt-values-mapping.helper.spec.ts +++ b/libs/oms/data-access/src/lib/helpers/return-process/return-receipt-values-mapping.helper.spec.ts @@ -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(); + }); }); diff --git a/libs/oms/data-access/src/lib/helpers/return-process/return-receipt-values-mapping.helper.ts b/libs/oms/data-access/src/lib/helpers/return-process/return-receipt-values-mapping.helper.ts index 0e43bcc2d..e40f1a3ae 100644 --- a/libs/oms/data-access/src/lib/helpers/return-process/return-receipt-values-mapping.helper.ts +++ b/libs/oms/data-access/src/lib/helpers/return-process/return-receipt-values-mapping.helper.ts @@ -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, }, diff --git a/libs/oms/data-access/src/lib/models/return-process.ts b/libs/oms/data-access/src/lib/models/return-process.ts index d100c4625..9c0e8b570 100644 --- a/libs/oms/data-access/src/lib/models/return-process.ts +++ b/libs/oms/data-access/src/lib/models/return-process.ts @@ -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; - productCategory?: string; + productCategory: string; + quantity: number; returnReceipt?: Receipt; } diff --git a/libs/oms/data-access/src/lib/services/return-details.service.ts b/libs/oms/data-access/src/lib/services/return-details.service.ts index 6965592ea..161566ccf 100644 --- a/libs/oms/data-access/src/lib/services/return-details.service.ts +++ b/libs/oms/data-access/src/lib/services/return-details.service.ts @@ -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 { 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[]; diff --git a/libs/oms/data-access/src/lib/stores/return-details.store.ts b/libs/oms/data-access/src/lib/stores/return-details.store.ts index 7730dbd25..1dbb48196 100644 --- a/libs/oms/data-access/src/lib/stores/return-details.store.ts +++ b/libs/oms/data-access/src/lib/stores/return-details.store.ts @@ -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; selectedQuantity: Record; @@ -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>(() => @@ -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 = {}; + + 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 = {}; + + 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 = {}; + + 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(); }, })), ); diff --git a/libs/oms/data-access/src/lib/stores/return-process.store.spec.ts b/libs/oms/data-access/src/lib/stores/return-process.store.spec.ts index 6dce8029c..602e074b5 100644 --- a/libs/oms/data-access/src/lib/stores/return-process.store.spec.ts +++ b/libs/oms/data-access/src/lib/stores/return-process.store.spec.ts @@ -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 = { 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, + }, + ], }, ], }); diff --git a/libs/oms/data-access/src/lib/stores/return-process.store.ts b/libs/oms/data-access/src/lib/stores/return-process.store.ts index 32072d585..1034fff5c 100644 --- a/libs/oms/data-access/src/lib/stores/return-process.store.ts +++ b/libs/oms/data-access/src/lib/stores/return-process.store.ts @@ -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: {}, }); } diff --git a/libs/oms/feature/return-details/src/lib/return-details-order-group-item-controls/return-details-order-group-item-controls.component.html b/libs/oms/feature/return-details/src/lib/return-details-order-group-item-controls/return-details-order-group-item-controls.component.html index 0c3a45f44..c0898367a 100644 --- a/libs/oms/feature/return-details/src/lib/return-details-order-group-item-controls/return-details-order-group-item-controls.component.html +++ b/libs/oms/feature/return-details/src/lib/return-details-order-group-item-controls/return-details-order-group-item-controls.component.html @@ -1,9 +1,11 @@ -
+
@if (quantityDropdownValues().length > 1) { @for (quantity of quantityDropdownValues(); track quantity) { diff --git a/libs/oms/feature/return-details/src/lib/return-details-order-group-item-controls/return-details-order-group-item-controls.component.scss b/libs/oms/feature/return-details/src/lib/return-details-order-group-item-controls/return-details-order-group-item-controls.component.scss index 11db8755a..601200a88 100644 --- a/libs/oms/feature/return-details/src/lib/return-details-order-group-item-controls/return-details-order-group-item-controls.component.scss +++ b/libs/oms/feature/return-details/src/lib/return-details-order-group-item-controls/return-details-order-group-item-controls.component.scss @@ -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; diff --git a/libs/oms/feature/return-details/src/lib/return-details-order-group-item-controls/return-details-order-group-item-controls.component.spec.ts b/libs/oms/feature/return-details/src/lib/return-details-order-group-item-controls/return-details-order-group-item-controls.component.spec.ts index a56e69208..0b64af5fb 100644 --- a/libs/oms/feature/return-details/src/lib/return-details-order-group-item-controls/return-details-order-group-item-controls.component.spec.ts +++ b/libs/oms/feature/return-details/src/lib/return-details-order-group-item-controls/return-details-order-group-item-controls.component.spec.ts @@ -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; - const mockItemSelectable = createMockItem('1234567890123', true); + const mockItemSelectable = createMockItem("1234567890123", true); const mockIsSelectable = signal(true); const mockGetItemSelectted = signal(false); @@ -52,6 +54,11 @@ describe('ReturnDetailsOrderGroupItemControlsComponent', () => { isLoading: signal(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"); + }); }); diff --git a/libs/oms/feature/return-details/src/lib/return-details-order-group-item-controls/return-details-order-group-item-controls.component.ts b/libs/oms/feature/return-details/src/lib/return-details-order-group-item-controls/return-details-order-group-item-controls.component.ts index 33d0e8408..1e0e11830 100644 --- a/libs/oms/feature/return-details/src/lib/return-details-order-group-item-controls/return-details-order-group-item-controls.component.ts +++ b/libs/oms/feature/return-details/src/lib/return-details-order-group-item-controls/return-details-order-group-item-controls.component.ts @@ -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); } diff --git a/libs/oms/feature/return-details/src/lib/return-details-order-group-item/return-details-order-group-item.component.html b/libs/oms/feature/return-details/src/lib/return-details-order-group-item/return-details-order-group-item.component.html index ac7c35842..85f01a2ab 100644 --- a/libs/oms/feature/return-details/src/lib/return-details-order-group-item/return-details-order-group-item.component.html +++ b/libs/oms/feature/return-details/src/lib/return-details-order-group-item/return-details-order-group-item.component.html @@ -57,7 +57,7 @@ {{ i.product.manufacturer }} | {{ i.product.ean }}
- {{ i.product.publicationDate | date: 'dd. MMM yyyy' }} + {{ i.product.publicationDate | date: "dd. MMM yyyy" }}
@@ -73,11 +73,11 @@ } -@if (returnedQuantity() > 0 && itemQuantity() !== returnedQuantity()) { +@if (availableQuantity() !== quantity()) {
- Es wurden bereits {{ returnedQuantity() }} von {{ itemQuantity() }} Artikel - zurückgegeben. + Es wurden bereits {{ quantity() - availableQuantity() }} von + {{ quantity() }} Artikel zurückgegeben.
} diff --git a/libs/oms/feature/return-details/src/lib/return-details-order-group-item/return-details-order-group-item.component.ts b/libs/oms/feature/return-details/src/lib/return-details-order-group-item/return-details-order-group-item.component.ts index f32d7235f..69594cc8c 100644 --- a/libs/oms/feature/return-details/src/lib/return-details-order-group-item/return-details-order-group-item.component.ts +++ b/libs/oms/feature/return-details/src/lib/return-details-order-group-item/return-details-order-group-item.component.ts @@ -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]; + }); } diff --git a/libs/oms/feature/return-details/src/lib/return-details.component.ts b/libs/oms/feature/return-details/src/lib/return-details.component.ts index 12ebb9c70..f1159500f 100644 --- a/libs/oms/feature/return-details/src/lib/return-details.component.ts +++ b/libs/oms/feature/return-details/src/lib/return-details.component.ts @@ -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], + }; + }), }), ); diff --git a/libs/oms/feature/return-search/src/lib/return-search-main/return-search-main.component.ts b/libs/oms/feature/return-search/src/lib/return-search-main/return-search-main.component.ts index e14e6fc3a..a645ca86e 100644 --- a/libs/oms/feature/return-search/src/lib/return-search-main/return-search-main.component.ts +++ b/libs/oms/feature/return-search/src/lib/return-search-main/return-search-main.component.ts @@ -77,10 +77,10 @@ export class ReturnSearchMainComponent { }: CallbackResult>) => { 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']); } }; diff --git a/libs/oms/feature/return-search/src/lib/return-search-result/return-search-result-item/return-search-result-item.component.html b/libs/oms/feature/return-search/src/lib/return-search-result/return-search-result-item/return-search-result-item.component.html index 2524c6c92..f79b84025 100644 --- a/libs/oms/feature/return-search/src/lib/return-search-result/return-search-result-item/return-search-result-item.component.html +++ b/libs/oms/feature/return-search/src/lib/return-search-result/return-search-result-item/return-search-result-item.component.html @@ -1,4 +1,7 @@ - +

{{ name() }}

@@ -7,12 +10,12 @@ Belegdatum - {{ receiptDate() | date: 'dd.MM.yy' }} + {{ receiptDate() | date: "dd.MM.yy" }}
- Rechnugsnr. + Beleg-Nr. {{ receiptNumber() }} diff --git a/libs/oms/shared/task-list/src/lib/return-task-list/return-task-list-item/return-task-list-item.component.scss b/libs/oms/shared/task-list/src/lib/return-task-list/return-task-list-item/return-task-list-item.component.scss index c4f04bf44..0cd6b06d6 100644 --- a/libs/oms/shared/task-list/src/lib/return-task-list/return-task-list-item/return-task-list-item.component.scss +++ b/libs/oms/shared/task-list/src/lib/return-task-list/return-task-list-item/return-task-list-item.component.scss @@ -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 { diff --git a/libs/ui/input-controls/src/lib/dropdown/_dropdown.scss b/libs/ui/input-controls/src/lib/dropdown/_dropdown.scss index 5f8d80893..60c87d04b 100644 --- a/libs/ui/input-controls/src/lib/dropdown/_dropdown.scss +++ b/libs/ui/input-controls/src/lib/dropdown/_dropdown.scss @@ -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; diff --git a/libs/ui/input-controls/src/lib/dropdown/dropdown.component.html b/libs/ui/input-controls/src/lib/dropdown/dropdown.component.html index 71104797b..5554e12ad 100644 --- a/libs/ui/input-controls/src/lib/dropdown/dropdown.component.html +++ b/libs/ui/input-controls/src/lib/dropdown/dropdown.component.html @@ -1,4 +1,4 @@ -{{ viewLabel() }} +{{ viewLabel() }}